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.
482 lines
17 KiB
TypeScript
482 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|