Files
lotteryAdmin/src/modules/agents/agent-profile-fields.tsx
kang 641c87ff50 feat(docs, agents, risk): enhance documentation, API queries, and UI components
Updated the public documentation site with improved layout and accessibility, including new sections for client integration and admin guides. Enhanced API queries by adding 'active_only' and 'group_by' parameters for better data filtering in risk management. Refined UI components for agent management, ensuring consistent styling and improved user experience across the application. Added localization support for new documentation content in English and Nepali.
2026-06-15 17:21:50 +08:00

482 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { cn } from "@/lib/utils";
import type { AgentParentCaps } from "@/types/api/admin-agent";
import { Info } from "lucide-react";
import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import {
AGENT_PERCENT_HARD_MAX,
actualShareRateFromRelative,
creditLimitRangeIssue,
isNumericStepperOutOfRange,
maxCreditLimitFromParent,
maxDefaultRebatePercent,
maxRebatePercentFromParent,
} from "@/lib/agent-profile-caps";
export type AgentProfileFieldsProps = {
disabled?: boolean;
loading?: boolean;
parentCaps: AgentParentCaps | null;
availableCredit: number | null;
canCreateChildAgent: boolean;
isSuperAdmin: boolean;
shareRate: string;
onShareRateChange: (value: string) => void;
creditLimit: string;
onCreditLimitChange: (value: string) => void;
rebateLimit: string;
onRebateLimitChange: (value: string) => void;
defaultRebate: string;
onDefaultRebateChange: (value: string) => void;
extraRebate: boolean;
onExtraRebateChange: (value: boolean) => void;
canCreatePlayer: boolean;
onCanCreatePlayerChange: (value: boolean) => void;
canCreateChild: boolean;
onCanCreateChildChange: (value: boolean) => void;
riskTags: string;
onRiskTagsChange: (value: string) => void;
idPrefix?: string;
currencyCode?: string;
/** 打开表单时的授信 baseline用于计算相对上级的上调空间 */
baselineCreditLimit?: number;
/** 已下发额度下限(编辑时不可低于 allocated */
minCreditLimit?: number;
/** 一级代理占成/授信/回水仅超管可改false 时整表只读) */
profileScalarsEditable?: boolean;
/** card用于代理线路详情 Tab 内的卡片表单 */
variant?: "default" | "card";
};
export function AgentProfileFields({
disabled = false,
loading = false,
parentCaps,
availableCredit,
canCreateChildAgent,
isSuperAdmin,
shareRate,
onShareRateChange,
creditLimit,
onCreditLimitChange,
rebateLimit,
onRebateLimitChange,
defaultRebate,
onDefaultRebateChange,
extraRebate,
onExtraRebateChange,
canCreatePlayer,
onCanCreatePlayerChange,
canCreateChild,
onCanCreateChildChange,
riskTags,
onRiskTagsChange,
idPrefix = "agent-profile",
currencyCode = "NPR",
baselineCreditLimit = 0,
minCreditLimit = 0,
profileScalarsEditable = true,
variant = "default",
}: AgentProfileFieldsProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const fieldDisabled = disabled || loading;
const showReadOnlyDisplay = !loading && (!profileScalarsEditable || fieldDisabled);
const isCard = variant === "card";
const maxSharePercent = AGENT_PERCENT_HARD_MAX;
const maxRebatePercent = maxRebatePercentFromParent(parentCaps);
const maxDefaultRebate = maxDefaultRebatePercent(rebateLimit, parentCaps);
const maxCreditLimit = maxCreditLimitFromParent(parentCaps, baselineCreditLimit);
const actualShare = actualShareRateFromRelative(Number.parseFloat(shareRate) || 0, parentCaps);
const creditRangeIssue = creditLimitRangeIssue(creditLimit, {
min: minCreditLimit,
max: maxCreditLimit,
});
return (
<div className="space-y-6">
{(parentCaps || availableCredit !== null) && !loading ? (
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
<div className="flex flex-col gap-1.5 min-w-0">
{parentCaps ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium leading-snug">
{t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
share: parentCaps.total_share_rate,
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
</div>
) : null}
{availableCredit !== null ? (
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
{t("profile.availableCredit", {
defaultValue: "可下发额度 {{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
</div>
</div>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground animate-pulse">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
{!profileScalarsEditable && !loading ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
{t("profile.lineRootScalarsReadOnlyHint", {
defaultValue:
"一级代理的占成、站点授信总额与回水由平台超管配置;如需调整请联系平台管理员。",
})}
</p>
) : null}
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-share-rate`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{parentCaps
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %" })
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
) : (
<AdminNumericStepper
id={`${idPrefix}-share-rate`}
value={shareRate}
onValueChange={onShareRateChange}
min={0}
max={maxSharePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
{parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.relativeShareCapHint", {
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%",
parent: parentCaps.total_share_rate,
})}
</p>
) : (
<p className="text-xs text-muted-foreground/80">
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
</p>
)}
{parentCaps && shareRate && actualShare !== null ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
rate: actualShare,
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-credit-limit`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
) : (
<AdminNumericStepper
id={`${idPrefix}-credit-limit`}
value={creditLimit}
onValueChange={onCreditLimitChange}
min={minCreditLimit}
max={maxCreditLimit}
step={1000}
integer
preserveOverMaxOnBlur
/>
)}
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.creditParentCapHint", {
defaultValue: "最高 {{max}}(上级可再下发 {{available}}",
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
available: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
) : null}
{profileScalarsEditable && creditRangeIssue === "below_min" ? (
<p className="text-xs text-destructive">
{t("profile.validation.creditBelowAllocated", {
defaultValue: "授信额度不能低于已下发给下级/玩家的总额(当前至少 {{min}}",
min: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
})}
</p>
) : null}
{profileScalarsEditable && creditRangeIssue === "above_max" && maxCreditLimit !== undefined ? (
<p className="text-xs text-destructive">
{t("profile.validation.creditExceedsParentWithMax", {
defaultValue: "授信额度不能超过 {{max}}",
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
})}
</p>
) : null}
{minCreditLimit > 0 ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.creditAllocatedFloorHint", {
defaultValue: "不可低于已下发 {{amount}}",
amount: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-rebate-limit`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
) : (
<AdminNumericStepper
id={`${idPrefix}-rebate-limit`}
value={rebateLimit}
onValueChange={onRebateLimitChange}
min={0}
max={maxRebatePercent}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
{parentCaps ? (
<p className="text-xs text-muted-foreground/80">
{t("profile.rebateParentCapHint", {
defaultValue: "不得超过上级回水上限 {{max}}%",
max: maxRebatePercent,
})}
</p>
) : (
<p className="text-xs text-muted-foreground/80">
{t("profile.rebateHardCapHint", { defaultValue: "回水上限最高 100%" })}
</p>
)}
</div>
<div className="space-y-2">
<Label
htmlFor={`${idPrefix}-default-rebate`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
) : (
<AdminNumericStepper
id={`${idPrefix}-default-rebate`}
value={defaultRebate}
onValueChange={onDefaultRebateChange}
min={0}
max={maxDefaultRebate}
step={0.5}
suffix="%"
preserveOverMaxOnBlur
/>
)}
<p className="text-xs text-muted-foreground/80">
{t("profile.defaultRebateCapHint", {
defaultValue: "不得超过本节点回水上限 {{max}}%",
max: maxDefaultRebate,
})}
</p>
</div>
<div className="space-y-2 sm:col-span-2">
<Label
htmlFor={`${idPrefix}-risk-tags`}
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
>
{t("profile.riskTags", { defaultValue: "风控标签" })}
</Label>
{showReadOnlyDisplay ? (
<ReadOnlyScalar
id={`${idPrefix}-risk-tags`}
value={riskTags.trim() === "" ? "—" : riskTags}
className="justify-start font-normal"
/>
) : (
<Input
id={`${idPrefix}-risk-tags`}
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={riskTags}
onChange={(e) => onRiskTagsChange(e.target.value)}
placeholder={t("profile.riskTagsPlaceholder", {
defaultValue: "逗号分隔,如 overdue, high_turnover",
})}
/>
)}
</div>
</div>
<div className="pt-2">
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
{showReadOnlyDisplay ? (
<>
<ReadOnlySwitchRow
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
checked={extraRebate}
/>
<ReadOnlySwitchRow
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
checked={canCreatePlayer}
/>
<ReadOnlySwitchRow
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
checked={canCreateChild}
isLast
/>
</>
) : (
<>
<SwitchRow
checked={extraRebate}
onCheckedChange={onExtraRebateChange}
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
/>
<SwitchRow
checked={canCreatePlayer}
onCheckedChange={onCanCreatePlayerChange}
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
/>
<SwitchRow
checked={canCreateChild}
onCheckedChange={onCanCreateChildChange}
disabled={!canCreateChildAgent && !isSuperAdmin}
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
isLast
/>
</>
)}
</div>
{!isCard ? (
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
{t("profile.capabilityHint", {
defaultValue:
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
})}
</p>
) : null}
</div>
</div>
);
}
function ReadOnlyScalar({
id,
value,
suffix,
className,
}: {
id?: string;
value: string;
suffix?: string;
className?: string;
}): React.ReactElement {
return (
<div
id={id}
className={cn(
"flex h-10 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 text-sm font-semibold tabular-nums text-foreground shadow-xs",
className,
)}
>
<span>
{value}
{suffix ? <span className="ml-0.5 font-medium text-foreground/80">{suffix}</span> : null}
</span>
</div>
);
}
function ReadOnlySwitchRow({
label,
checked,
isLast = false,
}: {
label: string;
checked: boolean;
isLast?: boolean;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
return (
<div
className={cn(
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
!isLast && "border-b border-border/60",
)}
>
<span className="font-medium text-foreground">{label}</span>
<AdminStatusBadge tone={checked ? "success" : "neutral"}>
{checked
? t("common:status.enabled", { defaultValue: "已开启" })
: t("common:status.disabled", { defaultValue: "已关闭" })}
</AdminStatusBadge>
</div>
);
}
function SwitchRow({
checked,
onCheckedChange,
label,
disabled = false,
isLast = false,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: boolean;
isLast?: boolean;
}): React.ReactElement {
return (
<div className={cn(
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
!isLast && "border-b border-border/50"
)}>
<Label
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
onClick={() => !disabled && onCheckedChange(!checked)}
>
{label}
</Label>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}