diff --git a/AGENTS.md b/AGENTS.md index 8f1f596..8122fd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,13 @@ This version has breaking changes — APIs, conventions, and file structure may 新增涉及玩家资金的页面时,先读 `src/lib/admin-player-display.ts`。 +## Learned User Preferences + +- 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]`。 + ## Learned Workspace Facts - 无接入站时依赖站点的页面展示 ``;仅 `profile.is_super_admin` 显示创建入口。 - 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。 +- 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作。 +- 站点管理员在代理下创建玩家须传 `agent_node_id`(与超管同逻辑),勿默认挂根代理。 diff --git a/src/components/admin/admin-numeric-stepper.tsx b/src/components/admin/admin-numeric-stepper.tsx new file mode 100644 index 0000000..0818322 --- /dev/null +++ b/src/components/admin/admin-numeric-stepper.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { Minus, Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type AdminNumericStepperProps = { + id?: string; + value: string; + onValueChange: (value: string) => void; + min?: number; + max?: number; + step?: number; + /** 整数模式(授信额度等);默认 false 支持小数(比例 %) */ + integer?: boolean; + disabled?: boolean; + /** 失焦时超出 max 是否保留原输入(供保存前校验报错),默认 clamp */ + preserveOverMaxOnBlur?: boolean; + className?: string; + suffix?: string; +}; + +function parseNumeric(value: string, integer: boolean): number { + const parsed = integer ? Number.parseInt(value, 10) : Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function formatNumeric(value: number, integer: boolean, step: number): string { + if (integer) { + return String(Math.trunc(value)); + } + + const decimals = step < 1 ? 2 : step < 0.1 ? 3 : 1; + const fixed = value.toFixed(decimals); + return fixed.replace(/\.?0+$/, "") || "0"; +} + +function clamp(value: number, min?: number, max?: number): number { + let next = value; + if (min !== undefined) { + next = Math.max(min, next); + } + if (max !== undefined) { + next = Math.min(max, next); + } + return next; +} + +function isPartialInput(text: string, integer: boolean): boolean { + const trimmed = text.trim(); + if (trimmed === "" || trimmed === "-" || trimmed === ".") { + return true; + } + if (!integer && trimmed.endsWith(".")) { + return true; + } + return false; +} + +export function AdminNumericStepper({ + id, + value, + onValueChange, + min = 0, + max, + step = 1, + integer = false, + disabled = false, + preserveOverMaxOnBlur = false, + className, + suffix, +}: AdminNumericStepperProps): React.ReactElement { + const current = parseNumeric(value, integer); + const atMin = min !== undefined && current <= min; + const atMax = max !== undefined && current >= max; + const outOfRange = + !disabled && + Number.isFinite(current) && + ((min !== undefined && current < min) || (max !== undefined && current > max)); + + const applyDelta = (delta: number) => { + const next = clamp(current + delta, min, max); + onValueChange(formatNumeric(next, integer, step)); + }; + + const commitInput = (raw: string) => { + if (isPartialInput(raw, integer)) { + onValueChange(formatNumeric(min ?? 0, integer, step)); + return; + } + + const parsed = parseNumeric(raw, integer); + if (!Number.isFinite(parsed)) { + onValueChange(formatNumeric(min ?? 0, integer, step)); + return; + } + + if ( + preserveOverMaxOnBlur && + max !== undefined && + parsed > max + ) { + return; + } + + onValueChange(formatNumeric(clamp(parsed, min, max), integer, step)); + }; + + return ( +
+ +
+ { + commitInput(e.target.value); + }} + onChange={(e) => { + const next = e.target.value; + if (integer && next !== "" && !/^-?\d*$/.test(next)) { + return; + } + if (!integer && next !== "" && !/^-?\d*\.?\d*$/.test(next)) { + return; + } + onValueChange(next); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } + }} + /> + {suffix ? ( + {suffix} + ) : null} +
+ +
+ ); +} diff --git a/src/components/admin/login-form.tsx b/src/components/admin/login-form.tsx index 058bb71..0ff51bb 100644 --- a/src/components/admin/login-form.tsx +++ b/src/components/admin/login-form.tsx @@ -17,6 +17,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getAdminCaptcha, postAdminLogin } from "@/api"; import { verifyStoredAdminSession } from "@/lib/admin-session-verify"; +import { + validateAdminLoginAccount, + validateAdminPassword, +} from "@/lib/admin-input-validation"; import { readToken } from "@/stores/admin-token"; import { authModuleMeta } from "@/modules/auth/meta"; import { useAdminSessionStore } from "@/stores/admin-session"; @@ -101,6 +105,24 @@ export function LoginForm() { return; } + const accountIssue = validateAdminLoginAccount(account); + if (accountIssue === "invalid_charset") { + toast.error( + t("accountInvalidCharset", { + defaultValue: "登录账号只能使用字母、数字、点(.)、下划线和连字符", + }), + ); + return; + } + + const passwordIssue = validateAdminPassword(password); + if (passwordIssue === "too_short") { + toast.error( + t("passwordMinLength", { defaultValue: "密码至少需要 8 个字符" }), + ); + return; + } + setSubmitting(true); try { const result = await postAdminLogin({ diff --git a/src/components/admin/player-credit-limit-field.tsx b/src/components/admin/player-credit-limit-field.tsx new file mode 100644 index 0000000..3fb7fae --- /dev/null +++ b/src/components/admin/player-credit-limit-field.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper"; +import { Label } from "@/components/ui/label"; +import { isNumericStepperOutOfRange } from "@/lib/agent-profile-caps"; +import { formatCredit } from "@/modules/agents/agent-line-sidebar"; + +export type PlayerCreditLimitFieldProps = { + id: string; + value: string; + onValueChange: (value: string) => void; + parentAvailableCredit: number | null; + /** 编辑已有玩家时传入当前授信,用于计算可上调上限 */ + baselineCreditLimit?: number; +}; + +export function PlayerCreditLimitField({ + id, + value, + onValueChange, + parentAvailableCredit, + baselineCreditLimit = 0, +}: PlayerCreditLimitFieldProps): React.ReactElement { + const { t } = useTranslation(["agents"]); + + const maxCredit = useMemo(() => { + if (parentAvailableCredit === null) { + return undefined; + } + + return baselineCreditLimit + parentAvailableCredit; + }, [baselineCreditLimit, parentAvailableCredit]); + + return ( +
+ + + {parentAvailableCredit !== null ? ( +

+ {t("playersPanel.availableToGrant", { + defaultValue: "代理剩余可下发:{{amount}}", + amount: formatCredit(parentAvailableCredit), + })} +

+ ) : null} + {parentAvailableCredit !== null && + isNumericStepperOutOfRange(value, { + min: 0, + max: maxCredit, + integer: true, + }) ? ( +

+ {t("playersPanel.creditLimitExceeded", { + defaultValue: "授信额度不能超过当前代理可下发额度", + })} +

+ ) : null} +
+ ); +} diff --git a/src/lib/admin-input-validation.ts b/src/lib/admin-input-validation.ts new file mode 100644 index 0000000..700cd93 --- /dev/null +++ b/src/lib/admin-input-validation.ts @@ -0,0 +1,42 @@ +/** 后台/彩票端登录账号:字母、数字、点、下划线、连字符。 */ +export const ADMIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/; + +export const ADMIN_PASSWORD_MIN_LENGTH = 8; +export const NATIVE_PLAYER_PASSWORD_MIN_LENGTH = 6; + +export type AccountValidationIssue = "empty" | "invalid_charset"; +export type PasswordValidationIssue = "empty" | "too_short"; + +export function validateAdminLoginAccount(value: string): AccountValidationIssue | null { + const trimmed = value.trim(); + if (trimmed === "") { + return "empty"; + } + if (!ADMIN_ACCOUNT_PATTERN.test(trimmed)) { + return "invalid_charset"; + } + + return null; +} + +export function validateAdminPassword( + value: string, + minLength: number = ADMIN_PASSWORD_MIN_LENGTH, +): PasswordValidationIssue | null { + if (value === "") { + return "empty"; + } + if (value.length < minLength) { + return "too_short"; + } + + return null; +} + +export function validateNativePlayerUsername(value: string): AccountValidationIssue | null { + return validateAdminLoginAccount(value); +} + +export function validateNativePlayerPassword(value: string): PasswordValidationIssue | null { + return validateAdminPassword(value, NATIVE_PLAYER_PASSWORD_MIN_LENGTH); +} diff --git a/src/lib/agent-profile-caps.ts b/src/lib/agent-profile-caps.ts new file mode 100644 index 0000000..24138cf --- /dev/null +++ b/src/lib/agent-profile-caps.ts @@ -0,0 +1,205 @@ +import type { AgentParentCaps } from "@/types/api/admin-agent"; + +/** 是否为站点一级代理(根节点)。 */ +export function isLineRootAgentNode( + node: { is_root?: boolean; depth?: number } | null | undefined, +): boolean { + return node != null && (node.is_root === true || node.depth === 0); +} + +/** 一级代理占成/授信/回水仅超管可改;下级代理仍由上级维护。 */ +export function isRootProfileEditableByActor( + node: { is_root?: boolean; depth?: number } | null | undefined, + isSuperAdmin: boolean, +): boolean { + return isSuperAdmin || !isLineRootAgentNode(node); +} + +/** 百分比字段硬顶(0–100)。 */ +export const AGENT_PERCENT_HARD_MAX = 100; + +/** 有上级时:回水上限不得超过上级回水上限与 100% 的较小值。 */ +export function maxRebatePercentFromParent(parentCaps: AgentParentCaps | null): number { + if (parentCaps === null) { + return AGENT_PERCENT_HARD_MAX; + } + + const parentLimit = Number(parentCaps.rebate_limit); + if (!Number.isFinite(parentLimit) || parentLimit < 0) { + return AGENT_PERCENT_HARD_MAX; + } + + return Math.min(AGENT_PERCENT_HARD_MAX, parentLimit); +} + +/** 默认玩家回水不得超过本节点回水上限与上级顶。 */ +export function maxDefaultRebatePercent( + rebateLimitText: string, + parentCaps: AgentParentCaps | null, +): number { + const rebateCap = maxRebatePercentFromParent(parentCaps); + const ownLimit = Number.parseFloat(rebateLimitText); + if (!Number.isFinite(ownLimit) || ownLimit < 0) { + return rebateCap; + } + + return Math.min(rebateCap, ownLimit); +} + +/** + * 授信额度上限:已有 baseline + 上级当前可下发(编辑下级时)。 + * 无上级(根代理)时不设顶,仅由业务校验 ≥ 已下发。 + */ +export function maxCreditLimitFromParent( + parentCaps: AgentParentCaps | null, + baselineCreditLimit: number, +): number | undefined { + if (parentCaps === null) { + return undefined; + } + + const baseline = Number.isFinite(baselineCreditLimit) ? Math.max(0, baselineCreditLimit) : 0; + const parentAvailable = Number(parentCaps.available_credit); + const extra = Number.isFinite(parentAvailable) ? Math.max(0, parentAvailable) : 0; + + return baseline + extra; +} + +/** 相对占成(占上级 %)换算后的实际占成,不得超过上级总占成。 */ +export function actualShareRateFromRelative( + relativePercent: number, + parentCaps: AgentParentCaps | null, +): number | null { + if (parentCaps === null) { + return null; + } + + const parentRate = Number(parentCaps.total_share_rate); + if (!Number.isFinite(parentRate) || parentRate < 0) { + return null; + } + + const relative = Number.isFinite(relativePercent) ? relativePercent : 0; + return Number(((parentRate * relative) / 100).toFixed(2)); +} + +export type AgentProfileValidationIssue = + | { code: "share_range" } + | { code: "share_exceeds_parent"; max: number } + | { code: "credit_invalid" } + | { code: "credit_exceeds_parent"; max: number } + | { code: "credit_below_allocated"; min: number } + | { code: "rebate_limit_range" } + | { code: "default_rebate_range" } + | { code: "default_exceeds_limit"; max: number } + | { code: "rebate_exceeds_parent"; max: number }; + +export type AgentProfileScalarValidationInput = { + shareRateText: string; + creditLimitText: string; + rebateLimitText: string; + defaultRebateText: string; + parentCaps: AgentParentCaps | null; + baselineCreditLimit: number; + minCreditLimit: number; + usesRelativeShare: boolean; + validateCredit: boolean; +}; + +function parsePercentField(value: string): number | null { + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number.parseFloat(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +/** 保存前校验占成/授信/回水是否在上级与已下发约束内。 */ +export function validateAgentProfileScalars( + input: AgentProfileScalarValidationInput, +): AgentProfileValidationIssue | null { + const shareRate = Number.parseFloat(input.shareRateText); + const creditLimit = Number.parseInt(input.creditLimitText, 10); + const rebateLimit = parsePercentField(input.rebateLimitText); + const defaultRebate = parsePercentField(input.defaultRebateText); + + if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > AGENT_PERCENT_HARD_MAX) { + return { code: "share_range" }; + } + + if (input.usesRelativeShare && input.parentCaps) { + const actualShare = actualShareRateFromRelative(shareRate, input.parentCaps); + const parentShare = Number(input.parentCaps.total_share_rate); + if ( + actualShare !== null && + Number.isFinite(parentShare) && + actualShare > parentShare + 1e-9 + ) { + return { code: "share_exceeds_parent", max: parentShare }; + } + } else if (!input.usesRelativeShare && shareRate > AGENT_PERCENT_HARD_MAX) { + return { code: "share_range" }; + } + + if (input.validateCredit) { + if (Number.isNaN(creditLimit) || creditLimit < 0 || !Number.isInteger(creditLimit)) { + return { code: "credit_invalid" }; + } + + const maxCredit = maxCreditLimitFromParent(input.parentCaps, input.baselineCreditLimit); + if (maxCredit !== undefined && creditLimit > maxCredit) { + return { code: "credit_exceeds_parent", max: maxCredit }; + } + + if (creditLimit < input.minCreditLimit) { + return { code: "credit_below_allocated", min: input.minCreditLimit }; + } + } + + if (rebateLimit === null || rebateLimit < 0 || rebateLimit > AGENT_PERCENT_HARD_MAX) { + return { code: "rebate_limit_range" }; + } + + if (defaultRebate === null || defaultRebate < 0 || defaultRebate > AGENT_PERCENT_HARD_MAX) { + return { code: "default_rebate_range" }; + } + + const parentRebateMax = maxRebatePercentFromParent(input.parentCaps); + if (rebateLimit > parentRebateMax) { + return { code: "rebate_exceeds_parent", max: parentRebateMax }; + } + + const defaultCap = maxDefaultRebatePercent(input.rebateLimitText, input.parentCaps); + if (defaultRebate > defaultCap) { + return { code: "default_exceeds_limit", max: defaultCap }; + } + + return null; +} + +export function isNumericStepperOutOfRange( + value: string, + options: { min?: number; max?: number; integer?: boolean }, +): boolean { + const trimmed = value.trim(); + if (trimmed === "") { + return false; + } + + const parsed = options.integer + ? Number.parseInt(trimmed, 10) + : Number.parseFloat(trimmed); + if (!Number.isFinite(parsed)) { + return true; + } + + if (options.min !== undefined && parsed < options.min) { + return true; + } + if (options.max !== undefined && parsed > options.max) { + return true; + } + + return false; +} diff --git a/src/modules/agents/agent-line-detail-panel.tsx b/src/modules/agents/agent-line-detail-panel.tsx index 4f1cc58..663f6d0 100644 --- a/src/modules/agents/agent-line-detail-panel.tsx +++ b/src/modules/agents/agent-line-detail-panel.tsx @@ -1,7 +1,6 @@ "use client"; -import type { ComponentType } from "react"; -import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react"; +import { Pencil, Plus, Trash2, Network } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav"; @@ -22,9 +21,10 @@ import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agen import { formatCredit } from "@/modules/agents/agent-line-sidebar"; import { Button } from "@/components/ui/button"; import { percentValueToUi } from "@/lib/admin-rate-percent"; +import { isLineRootAgentNode } from "@/lib/agent-profile-caps"; import { resolveRoleStatusTone } from "@/lib/admin-status-tone"; import { cn } from "@/lib/utils"; -import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent"; +import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent"; function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null { if ( @@ -254,12 +254,6 @@ export function AgentLineDetailPanel({ profile={profile} profileLoading={profileLoading} profileReadOnly={profileReadOnly} - canViewDownlineTab={canViewDownlineTab} - canViewPlayersTab={canViewPlayersTab} - playersTabHint={playersTabHint} - childCount={childAgents.length} - onGoToDownline={() => onDetailTabChange("downline")} - onGoToPlayers={() => onDetailTabChange("players")} /> ) : null} @@ -273,9 +267,14 @@ export function AgentLineDetailPanel({

{profileReadOnly - ? t("lineUi.profileReadOnlyHint", { - defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。", - }) + ? isLineRootAgentNode(node) + ? t("lineUi.lineRootProfileReadOnlyHint", { + defaultValue: + "一级代理的占成、站点授信总额与回水由平台超管配置;您可向直属下级代理与玩家下放额度。", + }) + : t("lineUi.profileReadOnlyHint", { + defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。", + }) : t("lineUi.profileTabHint", { defaultValue: "占成、授信、回水与风控标签在此维护;登录名、密码与启停状态请用「编辑代理」。", @@ -321,7 +320,7 @@ export function AgentLineDetailPanel({ @@ -335,22 +334,10 @@ function OverviewTab({ profile, profileLoading, profileReadOnly, - canViewDownlineTab, - canViewPlayersTab, - playersTabHint, - childCount, - onGoToDownline, - onGoToPlayers, }: { profile: AgentProfileRow | null; profileLoading: boolean; profileReadOnly: boolean; - canViewDownlineTab: boolean; - canViewPlayersTab: boolean; - playersTabHint?: string | null; - childCount: number; - onGoToDownline: () => void; - onGoToPlayers: () => void; }): React.ReactElement { const { t } = useTranslation(["agents", "common"]); @@ -361,197 +348,119 @@ function OverviewTab({ profile?.parent_caps?.total_share_rate, ); + const yesLabel = t("common:states.yes", { defaultValue: "是" }); + const noLabel = t("common:states.no", { defaultValue: "否" }); + return (

- {profileReadOnly ? ( -
- - - -
- ) : ( -
- - - - -
- )} - - {!profileReadOnly && !profileLoading && profile ? ( -
- - - 0 - ? profile.risk_tags!.join(", ") - : t("common:states.none", { defaultValue: "无" }) - } - /> -
- ) : null} - {profileReadOnly ? (

{t("lineUi.selfAgentOverviewHint", { defaultValue: - "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。", + "以下为上级为您分配的占成与授信;如需调整请联系上级代理或平台。", })}

) : null} - {canViewDownlineTab || canViewPlayersTab || playersTabHint ? ( -
- {canViewDownlineTab ? ( - + + + + +
+ + {!profileLoading && profile ? ( + <> +
+ - ) : null} - {canViewPlayersTab ? ( - - ) : null} - {!canViewPlayersTab && playersTabHint ? ( - - -
- -
-
-

- {t("lineUi.tabPlayers", { defaultValue: "直属玩家" })} -

-

{playersTabHint}

-
-
-
- ) : null} -
+ 0 + ? profile.risk_tags!.join(", ") + : t("common:states.none", { defaultValue: "无" }) + } + /> +
+ +
+ + + +
+ ) : null} ); } -function OverviewLinkCard({ - icon: Icon, - title, - summary, - description, - actionLabel, - onAction, +function CapabilityMetric({ + label, + enabled, + yesLabel, + noLabel, }: { - icon: ComponentType<{ className?: string }>; - title: string; - summary: string; - description: string; - actionLabel: string; - onAction: () => void; + label: string; + enabled: boolean; + yesLabel: string; + noLabel: string; }): React.ReactElement { return ( - - -
-
- -
- -
- -
-

{title}

-

5 ? "text-xl" : "text-2xl tabular-nums" - )}> - {summary} -

-

- {description} -

-
-
-
+
+

{label}

+

+ {enabled ? yesLabel : noLabel} +

+
); } diff --git a/src/modules/agents/agent-line-provision-wizard.tsx b/src/modules/agents/agent-line-provision-wizard.tsx index 1864021..1f34162 100644 --- a/src/modules/agents/agent-line-provision-wizard.tsx +++ b/src/modules/agents/agent-line-provision-wizard.tsx @@ -21,6 +21,10 @@ import { import { Switch } from "@/components/ui/switch"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { adminSiteCodeLabel } from "@/lib/admin-select-display"; +import { + validateAdminLoginAccount, + validateAdminPassword, +} from "@/lib/admin-input-validation"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site"; import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line"; @@ -90,11 +94,21 @@ export function AgentLineProvisionWizard({ toast.error(t("agents:usernameRequired", { defaultValue: "请填写登录名" })); return; } + const usernameIssue = validateAdminLoginAccount(form.username); + if (usernameIssue === "invalid_charset") { + toast.error( + t("agents:usernameInvalidCharset", { + defaultValue: "登录名只能使用字母、数字、点(.)、下划线和连字符", + }), + ); + return; + } if (!form.password.trim()) { toast.error(t("agents:passwordRequired", { defaultValue: "请填写密码" })); return; } - if (form.password.trim().length < 8) { + const passwordIssue = validateAdminPassword(form.password); + if (passwordIssue === "too_short") { toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" })); return; } diff --git a/src/modules/agents/agent-profile-fields.tsx b/src/modules/agents/agent-profile-fields.tsx index 2466a80..4f07156 100644 --- a/src/modules/agents/agent-profile-fields.tsx +++ b/src/modules/agents/agent-profile-fields.tsx @@ -18,6 +18,17 @@ 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, + isNumericStepperOutOfRange, + maxCreditLimitFromParent, + maxDefaultRebatePercent, + maxRebatePercentFromParent, +} from "@/lib/agent-profile-caps"; + export type AgentProfileFieldsProps = { disabled?: boolean; loading?: boolean; @@ -43,6 +54,12 @@ export type AgentProfileFieldsProps = { onRiskTagsChange: (value: string) => void; idPrefix?: string; currencyCode?: string; + /** 打开表单时的授信 baseline,用于计算相对上级的上调空间 */ + baselineCreditLimit?: number; + /** 已下发额度下限(编辑时不可低于 allocated) */ + minCreditLimit?: number; + /** 一级代理占成/授信/回水仅超管可改(false 时整表只读) */ + profileScalarsEditable?: boolean; /** card:用于代理线路详情 Tab 内的卡片表单 */ variant?: "default" | "card"; }; @@ -72,12 +89,22 @@ export function AgentProfileFields({ 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); + return (
{(parentCaps || availableCredit !== null) && !loading ? ( @@ -113,122 +140,243 @@ export function AgentProfileFields({

) : null} -
+ {!profileScalarsEditable && !loading ? ( +

+ {t("profile.lineRootScalarsReadOnlyHint", { + defaultValue: + "一级代理的占成、站点授信总额与回水由平台超管配置;如需调整请联系平台管理员。", + })} +

+ ) : null} + +
-
-
-
-
-
-
-
- - - +
+
+ {showReadOnlyDisplay ? ( + <> + + + + + ) : ( + <> + + + + + )}
{!isCard ? (

@@ -243,6 +391,61 @@ export function AgentProfileFields({ ); } +function ReadOnlyScalar({ + id, + value, + suffix, + className, +}: { + id?: string; + value: string; + suffix?: string; + className?: string; +}): React.ReactElement { + return ( +

+ + {value} + {suffix ? {suffix} : null} + +
+ ); +} + +function ReadOnlySwitchRow({ + label, + checked, + isLast = false, +}: { + label: string; + checked: boolean; + isLast?: boolean; +}): React.ReactElement { + const { t } = useTranslation(["agents", "common"]); + + return ( +
+ {label} + + {checked + ? t("common:status.enabled", { defaultValue: "已开启" }) + : t("common:status.disabled", { defaultValue: "已关闭" })} + +
+ ); +} + function SwitchRow({ checked, onCheckedChange, @@ -261,7 +464,12 @@ function SwitchRow({ "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" )}> - +
); diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx index 516b20f..158f09e 100644 --- a/src/modules/agents/agents-console.tsx +++ b/src/modules/agents/agents-console.tsx @@ -40,6 +40,16 @@ import { percentValueToUi, parsePercentUi, } from "@/lib/admin-rate-percent"; +import { + isLineRootAgentNode, + isRootProfileEditableByActor, + validateAgentProfileScalars, + type AgentProfileValidationIssue, +} from "@/lib/agent-profile-caps"; +import { + validateAdminLoginAccount, + validateAdminPassword, +} from "@/lib/admin-input-validation"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_AGENT_MANAGE, @@ -139,6 +149,8 @@ export function AgentsConsole(): React.ReactElement { const [profileLoaded, setProfileLoaded] = useState(true); const [profileParentCaps, setProfileParentCaps] = useState(null); const [profileAvailableCredit, setProfileAvailableCredit] = useState(null); + const [profileBaselineCreditLimit, setProfileBaselineCreditLimit] = useState(0); + const [profileMinCreditLimit, setProfileMinCreditLimit] = useState(0); const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false); /** 登录账号是否可向子代理下放「允许创建下级」 */ @@ -161,6 +173,8 @@ export function AgentsConsole(): React.ReactElement { setProfileRiskTags(""); setProfileParentCaps(null); setProfileAvailableCredit(null); + setProfileBaselineCreditLimit(0); + setProfileMinCreditLimit(0); }; const applyProfileRowToForm = (row: AgentProfileRow) => { @@ -179,13 +193,18 @@ export function AgentsConsole(): React.ReactElement { setProfileCanCreatePlayer(row.can_create_player !== false); setProfileParentCaps(row.parent_caps ?? null); setProfileAvailableCredit(row.available_credit ?? null); + setProfileBaselineCreditLimit(row.credit_limit ?? 0); + setProfileMinCreditLimit(row.allocated_credit ?? 0); setProfileRiskTags((row.risk_tags ?? []).join(", ")); }; - const profilePayload = () => { + const profilePayload = (creditNode: AgentNodeRow | null = selectedNode) => { const shareRate = Number.parseFloat(profileShareRate) || 0; + const creditEditable = isRootProfileEditableByActor(creditNode, isSuperAdmin); const base = { - credit_limit: Number.parseInt(profileCreditLimit, 10) || 0, + ...(creditEditable + ? { credit_limit: Number.parseInt(profileCreditLimit, 10) || 0 } + : {}), rebate_limit: Number.parseFloat(profileRebateLimit) || 0, default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0, can_grant_extra_rebate: profileExtraRebate, @@ -199,40 +218,69 @@ export function AgentsConsole(): React.ReactElement { return { ...base, total_share_rate: shareRate }; }; - const validateProfileFields = (): string | null => { - const shareRate = Number.parseFloat(profileShareRate); - const creditLimit = Number.parseInt(profileCreditLimit, 10); - const rebateLimit = parsePercentUi(profileRebateLimit); - const defaultRebate = parsePercentUi(profileDefaultRebate); - - if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) { - return t("profile.validation.shareRange", { - defaultValue: "占成比例须在 0–100 之间", - }); + const profileValidationMessage = (issue: AgentProfileValidationIssue): string => { + switch (issue.code) { + case "share_range": + return t("profile.validation.shareRange", { + defaultValue: "占成比例须在 0–100 之间", + }); + case "share_exceeds_parent": + return t("profile.validation.shareExceedsParent", { + defaultValue: "实际占成不能超过上级(最高 {{max}}%)", + max: issue.max, + }); + case "credit_invalid": + return t("profile.validation.creditInvalid", { + defaultValue: "授信额度须为不小于 0 的整数", + }); + case "credit_exceeds_parent": + return t("profile.validation.creditExceedsParentWithMax", { + defaultValue: "授信额度不能超过 {{max}}", + max: issue.max, + }); + case "credit_below_allocated": + return t("profile.validation.creditBelowAllocated", { + defaultValue: "授信额度不能低于已下发给下级/玩家的总额(当前至少 {{min}})", + min: issue.min, + }); + case "rebate_limit_range": + return t("profile.validation.rebateLimitRange", { + defaultValue: "回水上限须在 0–100% 之间", + }); + case "default_rebate_range": + return t("profile.validation.defaultRebateRange", { + defaultValue: "默认玩家回水须在 0–100% 之间", + }); + case "default_exceeds_limit": + return t("profile.validation.defaultExceedsLimitWithMax", { + defaultValue: "默认玩家回水不能超过 {{max}}%", + max: issue.max, + }); + case "rebate_exceeds_parent": + return t("profile.validation.rebateExceedsParent", { + defaultValue: "回水上限不能超过上级(最高 {{max}}%)", + max: issue.max, + }); + default: + return t("profile.validation.invalid", { defaultValue: "配置数值不合法" }); } + }; - if (Number.isNaN(creditLimit) || creditLimit < 0) { - return t("profile.validation.creditInvalid", { - defaultValue: "授信额度不能为负数", - }); - } + const validateProfileFields = (creditNode: AgentNodeRow | null = selectedNode): string | null => { + const issue = validateAgentProfileScalars({ + shareRateText: profileShareRate, + creditLimitText: profileCreditLimit, + rebateLimitText: profileRebateLimit, + defaultRebateText: profileDefaultRebate, + parentCaps: profileParentCaps, + baselineCreditLimit: profileBaselineCreditLimit, + minCreditLimit: profileMinCreditLimit, + usesRelativeShare: profileParentCaps !== null, + validateCredit: isRootProfileEditableByActor(creditNode, isSuperAdmin), + }); - if (rebateLimit === null || rebateLimit < 0 || rebateLimit > 100) { - return t("profile.validation.rebateLimitRange", { - defaultValue: "回水上限须在 0–100% 之间", - }); - } - - if (defaultRebate === null || defaultRebate < 0 || defaultRebate > 100) { - return t("profile.validation.defaultRebateRange", { - defaultValue: "默认玩家回水须在 0–100% 之间", - }); - } - - if (rebateLimit > 0 && defaultRebate > rebateLimit) { - return t("profile.validation.defaultExceedsLimit", { - defaultValue: "默认玩家回水不能超过回水上限", - }); + if (issue !== null) { + return profileValidationMessage(issue); } return null; @@ -282,11 +330,29 @@ export function AgentsConsole(): React.ReactElement { [flatNodes, selectedNodeId], ); + const lineRootProfileLocked = useMemo( + () => isLineRootAgentNode(selectedNode) && !isSuperAdmin, + [isSuperAdmin, selectedNode], + ); + const isOwnAgentNode = boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id; + const canViewProfileTab = + canManageProfile && selectedNode !== null && !isOwnAgentNode; + const canEditSelectedProfile = - canManageProfile && selectedNode !== null && (isSuperAdmin || !isOwnAgentNode); + canViewProfileTab && isRootProfileEditableByActor(selectedNode, isSuperAdmin); + + const editingDialogNode = useMemo( + () => + editingNodeId !== null + ? (flatNodes.find((node) => node.id === editingNodeId) ?? null) + : null, + [editingNodeId, flatNodes], + ); + + const dialogProfileEditable = isRootProfileEditableByActor(editingDialogNode, isSuperAdmin); const selectedChildAgents = useMemo(() => { if (selectedNode === null) { @@ -390,24 +456,30 @@ export function AgentsConsole(): React.ReactElement { .catch(() => setRootProfile(null)); }, [rootNode?.id]); - /** 仅上级/平台维护下级占成授信;代理查看自己时不展示配置 Tab */ - const canShowProfileTab = canEditSelectedProfile; + /** 代理看自己,或站点方看一级代理:占成授信 Tab 只读 */ + const profileReadOnly = isOwnAgentNode || lineRootProfileLocked; + + const isSiteAdmin = isSiteAdminOperator(profile); const canShowDownlineTab = useMemo( () => selectedNode !== null && !selectedProfileLoading && - selectedProfile?.can_create_child_agent === true, - [selectedNode, selectedProfile, selectedProfileLoading], + (isSiteAdmin || + isSuperAdmin || + selectedProfile?.can_create_child_agent === true), + [isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading], ); const canShowPlayersTab = useMemo( () => selectedNode !== null && !selectedProfileLoading && - selectedProfile?.can_create_player === true && - hasUsersManagePermission, - [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading], + hasUsersManagePermission && + (isSiteAdmin || + isSuperAdmin || + selectedProfile?.can_create_player === true), + [hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading], ); const playersTabHint = useMemo(() => { @@ -431,8 +503,12 @@ export function AgentsConsole(): React.ReactElement { }, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]); const canCreateChildOnSelected = useMemo( - () => canManageNode && selectedProfile?.can_create_child_agent === true, - [canManageNode, selectedProfile?.can_create_child_agent], + () => + canManageNode && + (isSiteAdmin || + isSuperAdmin || + selectedProfile?.can_create_child_agent === true), + [canManageNode, isSiteAdmin, isSuperAdmin, selectedProfile?.can_create_child_agent], ); useEffect(() => { @@ -440,7 +516,7 @@ export function AgentsConsole(): React.ReactElement { return; } - if (detailTab === "profile" && !canShowProfileTab) { + if (detailTab === "profile" && !canViewProfileTab) { setDetailTab("overview"); } else if (detailTab === "downline" && !canShowDownlineTab) { setDetailTab(canShowPlayersTab ? "players" : "overview"); @@ -450,7 +526,7 @@ export function AgentsConsole(): React.ReactElement { }, [ canShowDownlineTab, canShowPlayersTab, - canShowProfileTab, + canViewProfileTab, detailTab, selectedNode, selectedProfileLoading, @@ -618,12 +694,12 @@ export function AgentsConsole(): React.ReactElement { }; const inlineProfileFields = useMemo(() => { - if (!canShowProfileTab) { + if (!canViewProfileTab) { return null; } return { - disabled: !canEditSelectedProfile, + disabled: profileReadOnly || !canEditSelectedProfile, loading: selectedProfileLoading, parentCaps: profileParentCaps, availableCredit: profileAvailableCredit, @@ -645,18 +721,25 @@ export function AgentsConsole(): React.ReactElement { onCanCreateChildChange: setProfileCanCreateChild, riskTags: profileRiskTags, onRiskTagsChange: setProfileRiskTags, + baselineCreditLimit: profileBaselineCreditLimit, + minCreditLimit: profileMinCreditLimit, + profileScalarsEditable: isRootProfileEditableByActor(selectedNode, isSuperAdmin), }; }, [ canCreateChildAgent, canEditSelectedProfile, - canShowProfileTab, + canViewProfileTab, isSuperAdmin, + profileReadOnly, + selectedNode, profileAvailableCredit, profileCanCreateChild, profileCanCreatePlayer, profileCreditLimit, profileDefaultRebate, profileExtraRebate, + profileBaselineCreditLimit, + profileMinCreditLimit, profileParentCaps, profileRebateLimit, profileRiskTags, @@ -684,6 +767,16 @@ export function AgentsConsole(): React.ReactElement { return; } + const usernameIssue = validateAdminLoginAccount(nodeUsername); + if (usernameIssue === "invalid_charset") { + toast.error( + t("usernameInvalidCharset", { + defaultValue: "登录名只能使用字母、数字、点(.)、下划线和连字符", + }), + ); + return; + } + if (nodeDialogMode === "create") { if (targetParentId === null) { return; @@ -692,13 +785,17 @@ export function AgentsConsole(): React.ReactElement { toast.error(t("passwordRequired", { defaultValue: "请填写密码" })); return; } - if (nodePassword.trim().length < 8) { + const passwordIssue = validateAdminPassword(nodePassword); + if (passwordIssue === "too_short") { + toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" })); + return; + } + } else if (nodePassword.trim()) { + const passwordIssue = validateAdminPassword(nodePassword); + if (passwordIssue === "too_short") { toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" })); return; } - } else if (nodePassword.trim() && nodePassword.trim().length < 8) { - toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" })); - return; } else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) { toast.error( t("bindAccountPasswordRequired", { @@ -711,7 +808,9 @@ export function AgentsConsole(): React.ReactElement { const includeProfileInDialog = canManageProfile && (nodeDialogMode === "create" || - (editingNodeId !== null && boundAgent?.id !== editingNodeId)); + (editingNodeId !== null && + boundAgent?.id !== editingNodeId && + dialogProfileEditable)); if (includeProfileInDialog) { if (nodeDialogMode === "edit" && !profileLoaded) { @@ -723,7 +822,7 @@ export function AgentsConsole(): React.ReactElement { return; } - const profileError = validateProfileFields(); + const profileError = validateProfileFields(editingDialogNode); if (profileError !== null) { toast.error(profileError); return; @@ -739,7 +838,7 @@ export function AgentsConsole(): React.ReactElement { username: nodeUsername.trim(), password: nodePassword, status: nodeStatus, - ...(canManageProfile ? profilePayload() : {}), + ...(canManageProfile ? profilePayload(null) : {}), }); toast.success(t("createSuccess", { name: nodeName.trim() })); } else if (nodeDialogMode === "edit" && editingNodeId !== null) { @@ -752,7 +851,7 @@ export function AgentsConsole(): React.ReactElement { status: nodeStatus, }); if (includeProfileInDialog) { - await putAgentNodeProfile(editingNodeId, profilePayload()); + await putAgentNodeProfile(editingNodeId, profilePayload(editingDialogNode)); } toast.success(t("updateSuccess", { name: nodeName.trim() })); } @@ -905,9 +1004,9 @@ export function AgentsConsole(): React.ReactElement { } detailTab={detailTab} onDetailTabChange={setDetailTab} - canViewProfileTab={canShowProfileTab} + canViewProfileTab={canViewProfileTab} canEditProfileTab={canEditSelectedProfile} - profileReadOnly={isOwnAgentNode} + profileReadOnly={profileReadOnly} canViewDownlineTab={canShowDownlineTab} canViewPlayersTab={canShowPlayersTab} playersTabHint={playersTabHint} @@ -916,6 +1015,7 @@ export function AgentsConsole(): React.ReactElement { canCreateChildAgent={canCreateChildAgent} canCreatePlayerAction={ isSuperAdmin || + isSiteAdmin || (selectedProfile?.can_create_player === true && adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE])) } @@ -1024,12 +1124,15 @@ export function AgentsConsole(): React.ReactElement { {canManageProfile && (nodeDialogMode === "create" || - (editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? ( + (editingNodeId !== null && + boundAgent?.id !== editingNodeId && + dialogProfileEditable)) ? (

{t("profile.section", { defaultValue: "占成与授信配置" })}

diff --git a/src/modules/agents/agents-players-panel.tsx b/src/modules/agents/agents-players-panel.tsx index da55a5a..2a9544c 100644 --- a/src/modules/agents/agents-players-panel.tsx +++ b/src/modules/agents/agents-players-panel.tsx @@ -20,7 +20,8 @@ import { postAdminPlayer, putAdminPlayer, } from "@/api/admin-player"; -import { formatCredit } from "@/modules/agents/agent-line-sidebar"; +import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper"; +import { PlayerCreditLimitField } from "@/components/admin/player-credit-limit-field"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; @@ -55,12 +56,18 @@ import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; -import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display"; +import { playerBalanceCells } from "@/lib/admin-player-display"; import { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money"; import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; +import { AGENT_PERCENT_HARD_MAX } from "@/lib/agent-profile-caps"; +import { + validateNativePlayerPassword, + validateNativePlayerUsername, +} from "@/lib/admin-input-validation"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_USERS_MANAGE } from "@/lib/admin-prd"; +import { isSiteAdminOperator } from "@/lib/admin-session-variants"; import { resolveRoleStatusTone } from "@/lib/admin-status-tone"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -86,15 +93,6 @@ function playerStatusLabel( return String(status); } -function creditAdjustModeLabel( - mode: "increase" | "decrease", - t: (key: string, opts?: { defaultValue?: string }) => string, -): string { - return mode === "increase" - ? t("playersPanel.creditIncrease", { defaultValue: "增加授信" }) - : t("playersPanel.creditDecrease", { defaultValue: "减少授信" }); -} - function resolvePlayerRebateRate(row: AdminPlayerRow): number | null { if (row.rebate_rate != null) { return row.rebate_rate; @@ -171,6 +169,7 @@ export function AgentsPlayersPanel({ const profile = useAdminProfile(); const boundAgent = profile?.agent ?? null; const isSuperAdmin = profile?.is_super_admin === true; + const isSiteAdmin = isSiteAdminOperator(profile); const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction(); const profileAllowsCreate = @@ -206,6 +205,15 @@ export function AgentsPlayersPanel({ const [creditLimit, setCreditLimit] = useState(""); const [rebateRate, setRebateRate] = useState(""); const [parentAvailableCredit, setParentAvailableCredit] = useState(null); + const [agentRebateLimitPercent, setAgentRebateLimitPercent] = useState(null); + + const maxPlayerRebatePercent = useMemo(() => { + if (agentRebateLimitPercent === null || !Number.isFinite(agentRebateLimitPercent)) { + return AGENT_PERCENT_HARD_MAX; + } + return Math.min(AGENT_PERCENT_HARD_MAX, Math.max(0, agentRebateLimitPercent)); + }, [agentRebateLimitPercent]); + const [editDialogOpen, setEditDialogOpen] = useState(false); const [editSaving, setEditSaving] = useState(false); const [editingPlayer, setEditingPlayer] = useState>["items"][number] | null>(null); @@ -213,9 +221,7 @@ export function AgentsPlayersPanel({ const [editNickname, setEditNickname] = useState(""); const [editDefaultCurrency, setEditDefaultCurrency] = useState(""); const [editStatus, setEditStatus] = useState(0); - const [editCreditBase, setEditCreditBase] = useState(0); - const [editCreditAdjustMode, setEditCreditAdjustMode] = useState<"increase" | "decrease">("increase"); - const [editCreditDelta, setEditCreditDelta] = useState(""); + const [editCreditLimit, setEditCreditLimit] = useState(""); const [editRebateRate, setEditRebateRate] = useState(""); const [editRiskTags, setEditRiskTags] = useState(""); const [editDetailLoading, setEditDetailLoading] = useState(false); @@ -273,9 +279,19 @@ export function AgentsPlayersPanel({ toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" })); return; } - if (password.trim().length < 8) { + const usernameIssue = validateNativePlayerUsername(username); + if (usernameIssue === "invalid_charset") { toast.error( - t("playersPanel.passwordMinLength", { defaultValue: "初始密码至少 8 位" }), + t("playersPanel.usernameInvalidCharset", { + defaultValue: "登录账号只能使用字母、数字、点(.)、下划线和连字符", + }), + ); + return; + } + const passwordIssue = validateNativePlayerPassword(password); + if (passwordIssue === "too_short") { + toast.error( + t("playersPanel.passwordMinLength", { defaultValue: "初始密码至少 6 位" }), ); return; } @@ -314,6 +330,16 @@ export function AgentsPlayersPanel({ return; } + if (parsedRebateRate !== null && parsedRebateRate > maxPlayerRebatePercent) { + toast.error( + t("playersPanel.rebateRateExceedsAgent", { + defaultValue: "回水比例不能超过代理回水上限 {{max}}%", + max: maxPlayerRebatePercent, + }), + ); + return; + } + setSaving(true); try { await postAdminPlayer({ @@ -321,7 +347,9 @@ export function AgentsPlayersPanel({ username: username.trim(), password: password, nickname: nickname.trim() || null, - ...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}), + ...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null) + ? { agent_node_id: effectiveAgentId } + : {}), credit_limit: parsedCreditLimit, ...(parsedRebateRate !== null ? { rebate_rate: parsedRebateRate } @@ -356,10 +384,18 @@ export function AgentsPlayersPanel({ setRebateRate(""); if (effectiveAgentId !== null) { void getAgentNodeProfile(effectiveAgentId) - .then((p) => setParentAvailableCredit(p.available_credit ?? null)) - .catch(() => setParentAvailableCredit(null)); + .then((p) => { + setParentAvailableCredit(p.available_credit ?? null); + const rebate = Number(p.rebate_limit); + setAgentRebateLimitPercent(Number.isFinite(rebate) ? rebate : null); + }) + .catch(() => { + setParentAvailableCredit(null); + setAgentRebateLimitPercent(null); + }); } else { setParentAvailableCredit(null); + setAgentRebateLimitPercent(null); } } @@ -379,9 +415,7 @@ export function AgentsPlayersPanel({ setEditNickname(form.nickname); setEditDefaultCurrency(form.currency); setEditStatus(form.status); - setEditCreditBase(form.creditLimit); - setEditCreditAdjustMode("increase"); - setEditCreditDelta(""); + setEditCreditLimit(String(form.creditLimit)); setEditRebateRate(form.rebateRate); setEditRiskTags(form.riskTags); }; @@ -391,6 +425,18 @@ export function AgentsPlayersPanel({ applyEditForm(row); setEditDialogOpen(true); setEditDetailLoading(true); + if (effectiveAgentId !== null) { + void getAgentNodeProfile(effectiveAgentId) + .then((p) => { + setParentAvailableCredit(p.available_credit ?? null); + const rebate = Number(p.rebate_limit); + setAgentRebateLimitPercent(Number.isFinite(rebate) ? rebate : null); + }) + .catch(() => { + setParentAvailableCredit(null); + setAgentRebateLimitPercent(null); + }); + } void getAdminPlayer(row.id) .then((full) => { setEditingPlayer(full); @@ -418,6 +464,15 @@ export function AgentsPlayersPanel({ const body: Parameters[1] = {}; if (editUsername.trim() !== "" && editUsername.trim() !== (editingPlayer.username ?? "")) { + const usernameIssue = validateNativePlayerUsername(editUsername); + if (usernameIssue === "invalid_charset") { + toast.error( + t("playersPanel.usernameInvalidCharset", { + defaultValue: "登录账号只能使用字母、数字、点(.)、下划线和连字符", + }), + ); + return; + } body.username = editUsername.trim(); } if (editNickname.trim() !== (editingPlayer.nickname ?? "")) { @@ -430,17 +485,56 @@ export function AgentsPlayersPanel({ if (editStatus !== editingPlayer.status) { body.status = editStatus; } - const creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10); - if (!Number.isNaN(creditDelta) && creditDelta > 0) { - const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta; - const nextCredit = Math.max(0, (editingPlayer.credit_limit ?? 0) + signedDelta); - if (nextCredit !== (editingPlayer.credit_limit ?? 0)) { - body.credit_limit = nextCredit; - } + const baselineCredit = editingPlayer.credit_limit ?? 0; + const parsedCreditLimit = + editCreditLimit.trim() === "" ? baselineCredit : Number.parseInt(editCreditLimit, 10); + if ( + Number.isNaN(parsedCreditLimit) || + parsedCreditLimit < 0 || + !Number.isInteger(parsedCreditLimit) + ) { + toast.error( + t("playersPanel.creditLimitInvalid", { defaultValue: "授信额度必须为不小于 0 的整数" }), + ); + return; + } + if ( + parentAvailableCredit !== null && + parsedCreditLimit > baselineCredit + parentAvailableCredit + ) { + toast.error( + t("playersPanel.creditLimitExceeded", { + defaultValue: "授信额度不能超过当前代理可下发额度", + }), + ); + return; + } + if (parsedCreditLimit !== baselineCredit) { + body.credit_limit = parsedCreditLimit; } const prevRebate = resolvePlayerRebateRate(editingPlayer); const nextPercent = parsePercentUi(editRebateRate); const nextRebate = nextPercent === null ? null : nextPercent; + if ( + nextRebate !== null && + (nextRebate < 0 || nextRebate > AGENT_PERCENT_HARD_MAX) + ) { + toast.error( + t("playersPanel.rebateRateInvalid", { + defaultValue: "回水比例须在 0–100% 之间", + }), + ); + return; + } + if (nextRebate !== null && nextRebate > maxPlayerRebatePercent) { + toast.error( + t("playersPanel.rebateRateExceedsAgent", { + defaultValue: "回水比例不能超过代理回水上限 {{max}}%", + max: maxPlayerRebatePercent, + }), + ); + return; + } if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) { body.rebate_rate = nextRebate; } @@ -505,13 +599,6 @@ export function AgentsPlayersPanel({ ); const billingCurrency = selectedBill?.currency_code ?? billingPlayer?.default_currency ?? "NPR"; - const projectedCreditLimit = useMemo(() => { - const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10); - if (Number.isNaN(delta) || delta <= 0) { - return editCreditBase; - } - return Math.max(0, editCreditBase + (editCreditAdjustMode === "increase" ? delta : -delta)); - }, [editCreditAdjustMode, editCreditBase, editCreditDelta]); function resetBillingForm(): void { setPayAmount(""); @@ -957,44 +1044,33 @@ export function AgentsPlayersPanel({
-
- - setCreditLimit(e.target.value)} - className="bg-background/50 transition-colors focus:bg-background" - /> - {parentAvailableCredit !== null ? ( -

- {t("playersPanel.availableToGrant", { - defaultValue: "代理剩余可下发:{{amount}}", - amount: formatCredit(parentAvailableCredit), - })} -

- ) : null} -
+
- setRebateRate(e.target.value)} - className="bg-background/50 transition-colors focus:bg-background" + onValueChange={setRebateRate} + min={0} + max={maxPlayerRebatePercent} + step={0.5} + suffix="%" + preserveOverMaxOnBlur />

- {t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })} + {agentRebateLimitPercent !== null + ? t("playersPanel.rebateAgentCapHint", { + defaultValue: "不得超过代理回水上限 {{max}}%", + max: maxPlayerRebatePercent, + }) + : t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}

@@ -1160,14 +1236,14 @@ export function AgentsPlayersPanel({ - + {t("players:editDialogTitle", { defaultValue: "编辑玩家" })} {editDetailLoading ? : null} -
+
-
-
-
+
- -
-
- - {t("playersPanel.currentCredit", { defaultValue: "当前授信" })} - - - {formatPlayerCreditAmount(editCreditBase, editDefaultCurrency || "NPR")} - -
-
-
- - -
-
- - setEditCreditDelta(e.target.value)} - placeholder="0" - /> -
-
-

- {t("playersPanel.creditProjected", { - defaultValue: "调整后授信:{{amount}}", - amount: formatPlayerCreditAmount(projectedCreditLimit, editDefaultCurrency || "NPR"), - })} -

-
-
-
-
-
-
- + diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index 2879898..b2c8a80 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -44,7 +44,7 @@ import { Label } from "@/components/ui/label"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd"; -import { useAdminProfile } from "@/stores/admin-session"; +import { isSiteAdminOperator } from "@/lib/admin-session-variants"; import { Select, SelectContent, @@ -62,6 +62,7 @@ import { } from "@/components/ui/table"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; +import { useAdminProfile } from "@/stores/admin-session"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { playerBalanceCells } from "@/lib/admin-player-display"; import { ADMIN_SELECT_FILTER_ALL, adminSiteSelectLabel } from "@/lib/admin-select-display"; @@ -101,6 +102,7 @@ export function PlayersConsole(): React.ReactElement { const exportLabels = useExportLabels("players"); const profile = useAdminProfile(); const isSuperAdmin = profile?.is_super_admin === true; + const isSiteAdmin = isSiteAdminOperator(profile); const boundAgent = profile?.agent ?? null; const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions(); const router = useRouter(); @@ -109,6 +111,7 @@ export function PlayersConsole(): React.ReactElement { const keywordFromUrl = (searchParams.get("keyword") ?? "").trim(); useAdminCurrencyCatalog(); const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]); + const canPickAgentOnCreate = isSuperAdmin || isSiteAdmin; const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); @@ -212,10 +215,13 @@ export function PlayersConsole(): React.ReactElement { setAccountMode("create"); setEditingAccountId(null); const agentSite = boundAgent?.site_code?.trim() ?? ""; + const siteAdminSite = profile?.site?.code?.trim() ?? ""; const defaultSite = agentSite !== "" ? agentSite - : siteOptions[0]?.code ?? ""; + : siteAdminSite !== "" + ? siteAdminSite + : siteOptions[0]?.code ?? ""; setFormSiteCode(defaultSite); setFormAgentNodeId(boundAgent?.id); setFormSitePlayerId(""); @@ -228,7 +234,7 @@ export function PlayersConsole(): React.ReactElement { } useAsyncEffect(() => { - if (!accountOpen || accountMode !== "create" || !isSuperAdmin) { + if (!accountOpen || accountMode !== "create" || !canPickAgentOnCreate) { return; } const siteCode = formSiteCode.trim(); @@ -271,7 +277,7 @@ export function PlayersConsole(): React.ReactElement { return () => { cancelled = true; }; - }, [accountOpen, accountMode, formSiteCode, isSuperAdmin, siteOptions]); + }, [accountOpen, accountMode, canPickAgentOnCreate, formSiteCode, siteOptions]); function openEditAccount(row: AdminPlayerRow): void { setAccountMode("edit"); @@ -295,7 +301,7 @@ export function PlayersConsole(): React.ReactElement { async function submitAccount(): Promise { if (accountMode === "create") { - if (!isSuperAdmin && !boundAgent) { + if (!isSuperAdmin && !boundAgent && !isSiteAdmin) { toast.error(t("createAgentRequired")); return; } @@ -307,7 +313,7 @@ export function PlayersConsole(): React.ReactElement { toast.error(t("sitePlayerIdRequired")); return; } - if (isSuperAdmin && (formAgentNodeId === undefined || formAgentNodeId <= 0)) { + if (canPickAgentOnCreate && (formAgentNodeId === undefined || formAgentNodeId <= 0)) { toast.error(t("createAgentRequired")); return; } @@ -320,7 +326,7 @@ export function PlayersConsole(): React.ReactElement { nickname: formNickname.trim() || null, default_currency: formDefaultCurrency, status: formStatus, - ...(isSuperAdmin && formAgentNodeId + ...(canPickAgentOnCreate && formAgentNodeId ? { agent_node_id: formAgentNodeId } : {}), }); @@ -738,7 +744,7 @@ export function PlayersConsole(): React.ReactElement { code: boundAgent.code, })}

- ) : isSuperAdmin ? ( + ) : canPickAgentOnCreate ? (