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}
+
+
-
-
+
{t("profile.creditLimit", { defaultValue: "授信额度" })}
- onCreditLimitChange(e.target.value)}
- />
+ {showReadOnlyDisplay ? (
+
+ ) : (
+
+ )}
+ {parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
+
+ {t("profile.creditParentCapHint", {
+ defaultValue: "最高 {{max}}(上级可再下发 {{available}})",
+ max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
+ available: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
+ })}
+
+ ) : null}
+ {profileScalarsEditable &&
+ isNumericStepperOutOfRange(creditLimit, {
+ min: minCreditLimit,
+ max: maxCreditLimit,
+ integer: true,
+ }) ? (
+
+ {t("profile.validation.creditExceedsParentWithMax", {
+ defaultValue: "授信额度不能超过 {{max}}",
+ max:
+ maxCreditLimit !== undefined
+ ? formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode)
+ : creditLimit,
+ })}
+
+ ) : null}
+ {minCreditLimit > 0 ? (
+
+ {t("profile.creditAllocatedFloorHint", {
+ defaultValue: "不可低于已下发 {{amount}}",
+ amount: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
+ })}
+
+ ) : null}
-
+
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
- onRebateLimitChange(e.target.value)}
- placeholder="50"
- />
+ {showReadOnlyDisplay ? (
+
+ ) : (
+
+ )}
+ {parentCaps ? (
+
+ {t("profile.rebateParentCapHint", {
+ defaultValue: "不得超过上级回水上限 {{max}}%",
+ max: maxRebatePercent,
+ })}
+
+ ) : (
+
+ {t("profile.rebateHardCapHint", { defaultValue: "回水上限最高 100%" })}
+
+ )}
-
+
{t("profile.riskTags", { defaultValue: "风控标签" })}
- onRiskTagsChange(e.target.value)}
- placeholder={t("profile.riskTagsPlaceholder", {
- defaultValue: "逗号分隔,如 overdue, high_turnover",
- })}
- />
+ {showReadOnlyDisplay ? (
+
+ ) : (
+ onRiskTagsChange(e.target.value)}
+ placeholder={t("profile.riskTagsPlaceholder", {
+ defaultValue: "逗号分隔,如 overdue, high_turnover",
+ })}
+ />
+ )}
-
-
-
-
-
+
+
+ {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"
)}>
-
!disabled && onCheckedChange(!checked)}>{label}
+
!disabled && onCheckedChange(!checked)}
+ >
+ {label}
+
);
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({
-
-
- {t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
-
-
setCreditLimit(e.target.value)}
- className="bg-background/50 transition-colors focus:bg-background"
- />
- {parentAvailableCredit !== null ? (
-
- {t("playersPanel.availableToGrant", {
- defaultValue: "代理剩余可下发:{{amount}}",
- amount: formatCredit(parentAvailableCredit),
- })}
-
- ) : null}
-
+
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
-
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({