feat(agents, validation): enhance agent profile and player management with input validation
Added input validation for admin login and player creation forms, ensuring usernames and passwords meet specified criteria. Introduced new components for numeric input handling in agent profile fields, improving user experience. Updated agent line detail and provision wizard to reflect these changes, enhancing overall data integrity and user interaction.
This commit is contained in:
@@ -29,7 +29,13 @@ This version has breaking changes — APIs, conventions, and file structure may
|
|||||||
|
|
||||||
新增涉及玩家资金的页面时,先读 `src/lib/admin-player-display.ts`。
|
新增涉及玩家资金的页面时,先读 `src/lib/admin-player-display.ts`。
|
||||||
|
|
||||||
|
## Learned User Preferences
|
||||||
|
|
||||||
|
- 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]`。
|
||||||
|
|
||||||
## Learned Workspace Facts
|
## Learned Workspace Facts
|
||||||
|
|
||||||
- 无接入站时依赖站点的页面展示 `<AdminNoIntegrationSiteState />`;仅 `profile.is_super_admin` 显示创建入口。
|
- 无接入站时依赖站点的页面展示 `<AdminNoIntegrationSiteState />`;仅 `profile.is_super_admin` 显示创建入口。
|
||||||
- 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。
|
- 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。
|
||||||
|
- 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作。
|
||||||
|
- 站点管理员在代理下创建玩家须传 `agent_node_id`(与超管同逻辑),勿默认挂根代理。
|
||||||
|
|||||||
180
src/components/admin/admin-numeric-stepper.tsx
Normal file
180
src/components/admin/admin-numeric-stepper.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-stretch overflow-hidden rounded-md border bg-background/50 shadow-xs",
|
||||||
|
outOfRange ? "border-destructive/80 ring-1 ring-destructive/25" : "border-input",
|
||||||
|
disabled && "pointer-events-none opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-auto shrink-0 rounded-none border-r border-input/80 px-3 hover:bg-muted/60"
|
||||||
|
disabled={disabled || atMin}
|
||||||
|
aria-label="减少"
|
||||||
|
onClick={() => applyDelta(-step)}
|
||||||
|
>
|
||||||
|
<Minus className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-1 px-2">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
inputMode={integer ? "numeric" : "decimal"}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-invalid={outOfRange || undefined}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full min-w-0 border-0 bg-transparent text-center text-sm font-medium tabular-nums outline-none",
|
||||||
|
"focus:ring-0",
|
||||||
|
)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
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 ? (
|
||||||
|
<span className="shrink-0 text-sm text-muted-foreground">{suffix}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="h-auto shrink-0 rounded-none border-l border-input/80 px-3 hover:bg-muted/60"
|
||||||
|
disabled={disabled || atMax}
|
||||||
|
aria-label="增加"
|
||||||
|
onClick={() => applyDelta(step)}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
||||||
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
||||||
|
import {
|
||||||
|
validateAdminLoginAccount,
|
||||||
|
validateAdminPassword,
|
||||||
|
} from "@/lib/admin-input-validation";
|
||||||
import { readToken } from "@/stores/admin-token";
|
import { readToken } from "@/stores/admin-token";
|
||||||
import { authModuleMeta } from "@/modules/auth/meta";
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
@@ -101,6 +105,24 @@ export function LoginForm() {
|
|||||||
return;
|
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);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await postAdminLogin({
|
const result = await postAdminLogin({
|
||||||
|
|||||||
74
src/components/admin/player-credit-limit-field.tsx
Normal file
74
src/components/admin/player-credit-limit-field.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={id} className="text-muted-foreground">
|
||||||
|
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||||
|
</Label>
|
||||||
|
<AdminNumericStepper
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
min={0}
|
||||||
|
max={maxCredit}
|
||||||
|
step={1000}
|
||||||
|
integer
|
||||||
|
preserveOverMaxOnBlur
|
||||||
|
/>
|
||||||
|
{parentAvailableCredit !== null ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
|
{t("playersPanel.availableToGrant", {
|
||||||
|
defaultValue: "代理剩余可下发:{{amount}}",
|
||||||
|
amount: formatCredit(parentAvailableCredit),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{parentAvailableCredit !== null &&
|
||||||
|
isNumericStepperOutOfRange(value, {
|
||||||
|
min: 0,
|
||||||
|
max: maxCredit,
|
||||||
|
integer: true,
|
||||||
|
}) ? (
|
||||||
|
<p className="text-[11px] text-destructive">
|
||||||
|
{t("playersPanel.creditLimitExceeded", {
|
||||||
|
defaultValue: "授信额度不能超过当前代理可下发额度",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/lib/admin-input-validation.ts
Normal file
42
src/lib/admin-input-validation.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
205
src/lib/agent-profile-caps.ts
Normal file
205
src/lib/agent-profile-caps.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ComponentType } from "react";
|
import { Pencil, Plus, Trash2, Network } from "lucide-react";
|
||||||
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
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 { formatCredit } from "@/modules/agents/agent-line-sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
||||||
|
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
|
||||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
|
||||||
if (
|
if (
|
||||||
@@ -254,12 +254,6 @@ export function AgentLineDetailPanel({
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
profileLoading={profileLoading}
|
profileLoading={profileLoading}
|
||||||
profileReadOnly={profileReadOnly}
|
profileReadOnly={profileReadOnly}
|
||||||
canViewDownlineTab={canViewDownlineTab}
|
|
||||||
canViewPlayersTab={canViewPlayersTab}
|
|
||||||
playersTabHint={playersTabHint}
|
|
||||||
childCount={childAgents.length}
|
|
||||||
onGoToDownline={() => onDetailTabChange("downline")}
|
|
||||||
onGoToPlayers={() => onDetailTabChange("players")}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -273,7 +267,12 @@ export function AgentLineDetailPanel({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm font-normal text-muted-foreground">
|
<p className="text-sm font-normal text-muted-foreground">
|
||||||
{profileReadOnly
|
{profileReadOnly
|
||||||
? t("lineUi.profileReadOnlyHint", {
|
? isLineRootAgentNode(node)
|
||||||
|
? t("lineUi.lineRootProfileReadOnlyHint", {
|
||||||
|
defaultValue:
|
||||||
|
"一级代理的占成、站点授信总额与回水由平台超管配置;您可向直属下级代理与玩家下放额度。",
|
||||||
|
})
|
||||||
|
: t("lineUi.profileReadOnlyHint", {
|
||||||
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
||||||
})
|
})
|
||||||
: t("lineUi.profileTabHint", {
|
: t("lineUi.profileTabHint", {
|
||||||
@@ -321,7 +320,7 @@ export function AgentLineDetailPanel({
|
|||||||
<AgentsPlayersPanel
|
<AgentsPlayersPanel
|
||||||
siteCode={siteCode}
|
siteCode={siteCode}
|
||||||
agentNodeId={node.id}
|
agentNodeId={node.id}
|
||||||
allowCreatePlayer={profile?.can_create_player === true}
|
allowCreatePlayer={canCreatePlayerAction}
|
||||||
embedded
|
embedded
|
||||||
createRequestKey={playerCreateRequestKey}
|
createRequestKey={playerCreateRequestKey}
|
||||||
/>
|
/>
|
||||||
@@ -335,22 +334,10 @@ function OverviewTab({
|
|||||||
profile,
|
profile,
|
||||||
profileLoading,
|
profileLoading,
|
||||||
profileReadOnly,
|
profileReadOnly,
|
||||||
canViewDownlineTab,
|
|
||||||
canViewPlayersTab,
|
|
||||||
playersTabHint,
|
|
||||||
childCount,
|
|
||||||
onGoToDownline,
|
|
||||||
onGoToPlayers,
|
|
||||||
}: {
|
}: {
|
||||||
profile: AgentProfileRow | null;
|
profile: AgentProfileRow | null;
|
||||||
profileLoading: boolean;
|
profileLoading: boolean;
|
||||||
profileReadOnly: boolean;
|
profileReadOnly: boolean;
|
||||||
canViewDownlineTab: boolean;
|
|
||||||
canViewPlayersTab: boolean;
|
|
||||||
playersTabHint?: string | null;
|
|
||||||
childCount: number;
|
|
||||||
onGoToDownline: () => void;
|
|
||||||
onGoToPlayers: () => void;
|
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
|
||||||
@@ -361,25 +348,20 @@ function OverviewTab({
|
|||||||
profile?.parent_caps?.total_share_rate,
|
profile?.parent_caps?.total_share_rate,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const yesLabel = t("common:states.yes", { defaultValue: "是" });
|
||||||
|
const noLabel = t("common:states.no", { defaultValue: "否" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
{profileReadOnly ? (
|
{profileReadOnly ? (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||||
<MetricCard
|
{t("lineUi.selfAgentOverviewHint", {
|
||||||
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
|
defaultValue:
|
||||||
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
|
"以下为上级为您分配的占成与授信;如需调整请联系上级代理或平台。",
|
||||||
/>
|
})}
|
||||||
<MetricCard
|
</p>
|
||||||
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
) : null}
|
||||||
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
|
||||||
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
|
|
||||||
highlight
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
||||||
@@ -390,11 +372,6 @@ function OverviewTab({
|
|||||||
defaultValue: "占上级 {{rate}}%",
|
defaultValue: "占上级 {{rate}}%",
|
||||||
rate: parentRelativeShare,
|
rate: parentRelativeShare,
|
||||||
})
|
})
|
||||||
: rebateCap !== null
|
|
||||||
? t("lineUi.shareRebateCap", {
|
|
||||||
defaultValue: "回水上限 {{rate}}%",
|
|
||||||
rate: rebateCap,
|
|
||||||
})
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
accent
|
accent
|
||||||
@@ -413,13 +390,13 @@ function OverviewTab({
|
|||||||
highlight
|
highlight
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!profileReadOnly && !profileLoading && profile ? (
|
{!profileLoading && profile ? (
|
||||||
|
<>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||||
value={`${percentValueToUi(profile.rebate_limit ?? 0)}%`}
|
value={`${rebateCap ?? "0"}%`}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||||
@@ -434,124 +411,56 @@ function OverviewTab({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{profileReadOnly ? (
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
|
<CapabilityMetric
|
||||||
{t("lineUi.selfAgentOverviewHint", {
|
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||||
defaultValue:
|
enabled={profile.can_grant_extra_rebate === true}
|
||||||
"以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
yesLabel={yesLabel}
|
||||||
})}
|
noLabel={noLabel}
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canViewDownlineTab || canViewPlayersTab || playersTabHint ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{canViewDownlineTab ? (
|
|
||||||
<OverviewLinkCard
|
|
||||||
icon={Network}
|
|
||||||
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
|
|
||||||
summary={t("lineUi.overviewDownlineCount", {
|
|
||||||
defaultValue: "{{count}} 个",
|
|
||||||
count: childCount,
|
|
||||||
})}
|
|
||||||
description={t("lineUi.overviewDownlineHint", {
|
|
||||||
defaultValue: "直属下级 {{count}} 个,可在对应 Tab 管理下级代理。",
|
|
||||||
count: childCount,
|
|
||||||
})}
|
|
||||||
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
|
|
||||||
onAction={onGoToDownline}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
<CapabilityMetric
|
||||||
{canViewPlayersTab ? (
|
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||||
<OverviewLinkCard
|
enabled={profile.can_create_player !== false}
|
||||||
icon={Users}
|
yesLabel={yesLabel}
|
||||||
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
noLabel={noLabel}
|
||||||
summary={t("lineUi.overviewPlayersSummary", {
|
/>
|
||||||
defaultValue: "玩家管理",
|
<CapabilityMetric
|
||||||
})}
|
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||||
description={t("lineUi.overviewPlayersHint", {
|
enabled={profile.can_create_child_agent === true}
|
||||||
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
yesLabel={yesLabel}
|
||||||
})}
|
noLabel={noLabel}
|
||||||
actionLabel={t("lineUi.viewPlayers", { defaultValue: "查看直属玩家" })}
|
|
||||||
onAction={onGoToPlayers}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
{!canViewPlayersTab && playersTabHint ? (
|
|
||||||
<Card className="border-border/70 shadow-sm">
|
|
||||||
<CardContent className="flex items-start gap-3 pt-5">
|
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
|
||||||
<Users className="size-5" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{playersTabHint}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewLinkCard({
|
function CapabilityMetric({
|
||||||
icon: Icon,
|
label,
|
||||||
title,
|
enabled,
|
||||||
summary,
|
yesLabel,
|
||||||
description,
|
noLabel,
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
}: {
|
}: {
|
||||||
icon: ComponentType<{ className?: string }>;
|
label: string;
|
||||||
title: string;
|
enabled: boolean;
|
||||||
summary: string;
|
yesLabel: string;
|
||||||
description: string;
|
noLabel: string;
|
||||||
actionLabel: string;
|
|
||||||
onAction: () => void;
|
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Card
|
<div className="rounded-xl border border-border/70 bg-card px-4 py-4 shadow-sm">
|
||||||
className="group relative cursor-pointer overflow-hidden border-border/70 shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
onClick={onAction}
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1.5 text-lg font-semibold",
|
||||||
|
enabled ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="flex flex-col p-5">
|
{enabled ? yesLabel : noLabel}
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
|
||||||
<Icon className="size-5.5" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0 text-primary -mr-2"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAction();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{actionLabel}
|
|
||||||
<ChevronRight className="ml-0.5 size-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
|
||||||
<p className={cn(
|
|
||||||
"mt-1 font-semibold tracking-tight text-foreground",
|
|
||||||
summary.length > 5 ? "text-xl" : "text-2xl tabular-nums"
|
|
||||||
)}>
|
|
||||||
{summary}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||||
|
import {
|
||||||
|
validateAdminLoginAccount,
|
||||||
|
validateAdminPassword,
|
||||||
|
} from "@/lib/admin-input-validation";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
||||||
import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line";
|
import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line";
|
||||||
@@ -90,11 +94,21 @@ export function AgentLineProvisionWizard({
|
|||||||
toast.error(t("agents:usernameRequired", { defaultValue: "请填写登录名" }));
|
toast.error(t("agents:usernameRequired", { defaultValue: "请填写登录名" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const usernameIssue = validateAdminLoginAccount(form.username);
|
||||||
|
if (usernameIssue === "invalid_charset") {
|
||||||
|
toast.error(
|
||||||
|
t("agents:usernameInvalidCharset", {
|
||||||
|
defaultValue: "登录名只能使用字母、数字、点(.)、下划线和连字符",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!form.password.trim()) {
|
if (!form.password.trim()) {
|
||||||
toast.error(t("agents:passwordRequired", { defaultValue: "请填写密码" }));
|
toast.error(t("agents:passwordRequired", { defaultValue: "请填写密码" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (form.password.trim().length < 8) {
|
const passwordIssue = validateAdminPassword(form.password);
|
||||||
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ import type { AgentParentCaps } from "@/types/api/admin-agent";
|
|||||||
|
|
||||||
import { Info } from "lucide-react";
|
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 = {
|
export type AgentProfileFieldsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -43,6 +54,12 @@ export type AgentProfileFieldsProps = {
|
|||||||
onRiskTagsChange: (value: string) => void;
|
onRiskTagsChange: (value: string) => void;
|
||||||
idPrefix?: string;
|
idPrefix?: string;
|
||||||
currencyCode?: string;
|
currencyCode?: string;
|
||||||
|
/** 打开表单时的授信 baseline,用于计算相对上级的上调空间 */
|
||||||
|
baselineCreditLimit?: number;
|
||||||
|
/** 已下发额度下限(编辑时不可低于 allocated) */
|
||||||
|
minCreditLimit?: number;
|
||||||
|
/** 一级代理占成/授信/回水仅超管可改(false 时整表只读) */
|
||||||
|
profileScalarsEditable?: boolean;
|
||||||
/** card:用于代理线路详情 Tab 内的卡片表单 */
|
/** card:用于代理线路详情 Tab 内的卡片表单 */
|
||||||
variant?: "default" | "card";
|
variant?: "default" | "card";
|
||||||
};
|
};
|
||||||
@@ -72,12 +89,22 @@ export function AgentProfileFields({
|
|||||||
onRiskTagsChange,
|
onRiskTagsChange,
|
||||||
idPrefix = "agent-profile",
|
idPrefix = "agent-profile",
|
||||||
currencyCode = "NPR",
|
currencyCode = "NPR",
|
||||||
|
baselineCreditLimit = 0,
|
||||||
|
minCreditLimit = 0,
|
||||||
|
profileScalarsEditable = true,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
}: AgentProfileFieldsProps): React.ReactElement {
|
}: AgentProfileFieldsProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const fieldDisabled = disabled || loading;
|
const fieldDisabled = disabled || loading;
|
||||||
|
const showReadOnlyDisplay = !loading && (!profileScalarsEditable || fieldDisabled);
|
||||||
const isCard = variant === "card";
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{(parentCaps || availableCredit !== null) && !loading ? (
|
{(parentCaps || availableCredit !== null) && !loading ? (
|
||||||
@@ -113,86 +140,191 @@ export function AgentProfileFields({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
{!profileScalarsEditable && !loading ? (
|
||||||
className={cn(
|
<p className="rounded-lg border border-amber-200/80 bg-amber-50 px-3 py-2.5 text-sm text-amber-950 dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
|
||||||
"grid gap-x-6 gap-y-5 sm:grid-cols-2",
|
{t("profile.lineRootScalarsReadOnlyHint", {
|
||||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
defaultValue:
|
||||||
)}
|
"一级代理的占成、站点授信总额与回水由平台超管配置;如需调整请联系平台管理员。",
|
||||||
>
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-share-rate`} className="text-muted-foreground">
|
<Label
|
||||||
|
htmlFor={`${idPrefix}-share-rate`}
|
||||||
|
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
{parentCaps
|
{parentCaps
|
||||||
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
||||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
{showReadOnlyDisplay ? (
|
||||||
|
<ReadOnlyScalar id={`${idPrefix}-share-rate`} value={shareRate} suffix="%" />
|
||||||
|
) : (
|
||||||
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-share-rate`}
|
id={`${idPrefix}-share-rate`}
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
|
||||||
value={shareRate}
|
value={shareRate}
|
||||||
onChange={(e) => onShareRateChange(e.target.value)}
|
onValueChange={onShareRateChange}
|
||||||
|
min={0}
|
||||||
|
max={maxSharePercent}
|
||||||
|
step={0.5}
|
||||||
|
suffix="%"
|
||||||
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
{parentCaps && shareRate ? (
|
)}
|
||||||
|
{parentCaps ? (
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.relativeShareCapHint", {
|
||||||
|
defaultValue: "占上级比例最高 100%(上级总占成 {{parent}}%)",
|
||||||
|
parent: parentCaps.total_share_rate,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.totalShareCapHint", { defaultValue: "占成比例最高 100%" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{parentCaps && shareRate && actualShare !== null ? (
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.actualShareRate", {
|
{t("profile.actualShareRate", {
|
||||||
defaultValue: "实际占成 {{rate}}%",
|
defaultValue: "实际占成 {{rate}}%",
|
||||||
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
|
rate: actualShare,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-credit-limit`} className="text-muted-foreground">
|
<Label
|
||||||
|
htmlFor={`${idPrefix}-credit-limit`}
|
||||||
|
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
{showReadOnlyDisplay ? (
|
||||||
|
<ReadOnlyScalar id={`${idPrefix}-credit-limit`} value={creditLimit} />
|
||||||
|
) : (
|
||||||
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-credit-limit`}
|
id={`${idPrefix}-credit-limit`}
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
|
||||||
value={creditLimit}
|
value={creditLimit}
|
||||||
onChange={(e) => onCreditLimitChange(e.target.value)}
|
onValueChange={onCreditLimitChange}
|
||||||
|
min={minCreditLimit}
|
||||||
|
max={maxCreditLimit}
|
||||||
|
step={1000}
|
||||||
|
integer
|
||||||
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{parentCaps && maxCreditLimit !== undefined && profileScalarsEditable ? (
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.creditParentCapHint", {
|
||||||
|
defaultValue: "最高 {{max}}(上级可再下发 {{available}})",
|
||||||
|
max: formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode),
|
||||||
|
available: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{profileScalarsEditable &&
|
||||||
|
isNumericStepperOutOfRange(creditLimit, {
|
||||||
|
min: minCreditLimit,
|
||||||
|
max: maxCreditLimit,
|
||||||
|
integer: true,
|
||||||
|
}) ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("profile.validation.creditExceedsParentWithMax", {
|
||||||
|
defaultValue: "授信额度不能超过 {{max}}",
|
||||||
|
max:
|
||||||
|
maxCreditLimit !== undefined
|
||||||
|
? formatAdminCreditMajorDecimal(maxCreditLimit, currencyCode)
|
||||||
|
: creditLimit,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{minCreditLimit > 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.creditAllocatedFloorHint", {
|
||||||
|
defaultValue: "不可低于已下发 {{amount}}",
|
||||||
|
amount: formatAdminCreditMajorDecimal(minCreditLimit, currencyCode),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-rebate-limit`} className="text-muted-foreground">
|
<Label
|
||||||
|
htmlFor={`${idPrefix}-rebate-limit`}
|
||||||
|
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
{showReadOnlyDisplay ? (
|
||||||
|
<ReadOnlyScalar id={`${idPrefix}-rebate-limit`} value={rebateLimit} suffix="%" />
|
||||||
|
) : (
|
||||||
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-rebate-limit`}
|
id={`${idPrefix}-rebate-limit`}
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
|
||||||
value={rebateLimit}
|
value={rebateLimit}
|
||||||
onChange={(e) => onRebateLimitChange(e.target.value)}
|
onValueChange={onRebateLimitChange}
|
||||||
placeholder="50"
|
min={0}
|
||||||
|
max={maxRebatePercent}
|
||||||
|
step={0.5}
|
||||||
|
suffix="%"
|
||||||
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{parentCaps ? (
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.rebateParentCapHint", {
|
||||||
|
defaultValue: "不得超过上级回水上限 {{max}}%",
|
||||||
|
max: maxRebatePercent,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.rebateHardCapHint", { defaultValue: "回水上限最高 100%" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-default-rebate`} className="text-muted-foreground">
|
<Label
|
||||||
|
htmlFor={`${idPrefix}-default-rebate`}
|
||||||
|
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
{showReadOnlyDisplay ? (
|
||||||
|
<ReadOnlyScalar id={`${idPrefix}-default-rebate`} value={defaultRebate} suffix="%" />
|
||||||
|
) : (
|
||||||
|
<AdminNumericStepper
|
||||||
id={`${idPrefix}-default-rebate`}
|
id={`${idPrefix}-default-rebate`}
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
|
||||||
value={defaultRebate}
|
value={defaultRebate}
|
||||||
onChange={(e) => onDefaultRebateChange(e.target.value)}
|
onValueChange={onDefaultRebateChange}
|
||||||
placeholder="50"
|
min={0}
|
||||||
|
max={maxDefaultRebate}
|
||||||
|
step={0.5}
|
||||||
|
suffix="%"
|
||||||
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.defaultRebateCapHint", {
|
||||||
|
defaultValue: "不得超过本节点回水上限 {{max}}%",
|
||||||
|
max: maxDefaultRebate,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<div className="space-y-2 sm:col-span-2">
|
||||||
<Label htmlFor={`${idPrefix}-risk-tags`} className="text-muted-foreground">
|
<Label
|
||||||
|
htmlFor={`${idPrefix}-risk-tags`}
|
||||||
|
className={showReadOnlyDisplay ? "text-foreground/85" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
{t("profile.riskTags", { defaultValue: "风控标签" })}
|
{t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||||
</Label>
|
</Label>
|
||||||
|
{showReadOnlyDisplay ? (
|
||||||
|
<ReadOnlyScalar
|
||||||
|
id={`${idPrefix}-risk-tags`}
|
||||||
|
value={riskTags.trim() === "" ? "—" : riskTags}
|
||||||
|
className="justify-start font-normal"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={`${idPrefix}-risk-tags`}
|
id={`${idPrefix}-risk-tags`}
|
||||||
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
@@ -202,16 +334,30 @@ export function AgentProfileFields({
|
|||||||
defaultValue: "逗号分隔,如 overdue, high_turnover",
|
defaultValue: "逗号分隔,如 overdue, high_turnover",
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="pt-2">
|
||||||
className={cn(
|
<div className="rounded-xl border border-border/80 bg-muted/20 overflow-hidden shadow-sm">
|
||||||
"pt-2",
|
{showReadOnlyDisplay ? (
|
||||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
<>
|
||||||
)}
|
<ReadOnlySwitchRow
|
||||||
>
|
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||||
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
checked={extraRebate}
|
||||||
|
/>
|
||||||
|
<ReadOnlySwitchRow
|
||||||
|
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||||
|
checked={canCreatePlayer}
|
||||||
|
/>
|
||||||
|
<ReadOnlySwitchRow
|
||||||
|
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||||
|
checked={canCreateChild}
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
checked={extraRebate}
|
checked={extraRebate}
|
||||||
onCheckedChange={onExtraRebateChange}
|
onCheckedChange={onExtraRebateChange}
|
||||||
@@ -229,6 +375,8 @@ export function AgentProfileFields({
|
|||||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isCard ? (
|
{!isCard ? (
|
||||||
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
|
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
|
||||||
@@ -243,6 +391,61 @@ export function AgentProfileFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReadOnlyScalar({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
suffix,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
suffix?: string;
|
||||||
|
className?: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 text-sm font-semibold tabular-nums text-foreground shadow-xs",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{value}
|
||||||
|
{suffix ? <span className="ml-0.5 font-medium text-foreground/80">{suffix}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlySwitchRow({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
isLast = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-4 bg-background/60 px-4 py-3.5",
|
||||||
|
!isLast && "border-b border-border/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium text-foreground">{label}</span>
|
||||||
|
<AdminStatusBadge tone={checked ? "success" : "neutral"}>
|
||||||
|
{checked
|
||||||
|
? t("common:status.enabled", { defaultValue: "已开启" })
|
||||||
|
: t("common:status.disabled", { defaultValue: "已关闭" })}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SwitchRow({
|
function SwitchRow({
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
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",
|
"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"
|
!isLast && "border-b border-border/50"
|
||||||
)}>
|
)}>
|
||||||
<Label className={cn("font-medium cursor-pointer", disabled && "opacity-50")} onClick={() => !disabled && onCheckedChange(!checked)}>{label}</Label>
|
<Label
|
||||||
|
className={cn("font-medium", disabled ? "cursor-default text-muted-foreground" : "cursor-pointer")}
|
||||||
|
onClick={() => !disabled && onCheckedChange(!checked)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ import {
|
|||||||
percentValueToUi,
|
percentValueToUi,
|
||||||
parsePercentUi,
|
parsePercentUi,
|
||||||
} from "@/lib/admin-rate-percent";
|
} 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import {
|
import {
|
||||||
PRD_AGENT_MANAGE,
|
PRD_AGENT_MANAGE,
|
||||||
@@ -139,6 +149,8 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const [profileLoaded, setProfileLoaded] = useState(true);
|
const [profileLoaded, setProfileLoaded] = useState(true);
|
||||||
const [profileParentCaps, setProfileParentCaps] = useState<AgentParentCaps | null>(null);
|
const [profileParentCaps, setProfileParentCaps] = useState<AgentParentCaps | null>(null);
|
||||||
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
||||||
|
const [profileBaselineCreditLimit, setProfileBaselineCreditLimit] = useState(0);
|
||||||
|
const [profileMinCreditLimit, setProfileMinCreditLimit] = useState(0);
|
||||||
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
||||||
|
|
||||||
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
||||||
@@ -161,6 +173,8 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
setProfileRiskTags("");
|
setProfileRiskTags("");
|
||||||
setProfileParentCaps(null);
|
setProfileParentCaps(null);
|
||||||
setProfileAvailableCredit(null);
|
setProfileAvailableCredit(null);
|
||||||
|
setProfileBaselineCreditLimit(0);
|
||||||
|
setProfileMinCreditLimit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyProfileRowToForm = (row: AgentProfileRow) => {
|
const applyProfileRowToForm = (row: AgentProfileRow) => {
|
||||||
@@ -179,13 +193,18 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
setProfileCanCreatePlayer(row.can_create_player !== false);
|
setProfileCanCreatePlayer(row.can_create_player !== false);
|
||||||
setProfileParentCaps(row.parent_caps ?? null);
|
setProfileParentCaps(row.parent_caps ?? null);
|
||||||
setProfileAvailableCredit(row.available_credit ?? null);
|
setProfileAvailableCredit(row.available_credit ?? null);
|
||||||
|
setProfileBaselineCreditLimit(row.credit_limit ?? 0);
|
||||||
|
setProfileMinCreditLimit(row.allocated_credit ?? 0);
|
||||||
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
||||||
};
|
};
|
||||||
|
|
||||||
const profilePayload = () => {
|
const profilePayload = (creditNode: AgentNodeRow | null = selectedNode) => {
|
||||||
const shareRate = Number.parseFloat(profileShareRate) || 0;
|
const shareRate = Number.parseFloat(profileShareRate) || 0;
|
||||||
|
const creditEditable = isRootProfileEditableByActor(creditNode, isSuperAdmin);
|
||||||
const base = {
|
const base = {
|
||||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
...(creditEditable
|
||||||
|
? { credit_limit: Number.parseInt(profileCreditLimit, 10) || 0 }
|
||||||
|
: {}),
|
||||||
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
||||||
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
||||||
can_grant_extra_rebate: profileExtraRebate,
|
can_grant_extra_rebate: profileExtraRebate,
|
||||||
@@ -199,40 +218,69 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
return { ...base, total_share_rate: shareRate };
|
return { ...base, total_share_rate: shareRate };
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateProfileFields = (): string | null => {
|
const profileValidationMessage = (issue: AgentProfileValidationIssue): string => {
|
||||||
const shareRate = Number.parseFloat(profileShareRate);
|
switch (issue.code) {
|
||||||
const creditLimit = Number.parseInt(profileCreditLimit, 10);
|
case "share_range":
|
||||||
const rebateLimit = parsePercentUi(profileRebateLimit);
|
|
||||||
const defaultRebate = parsePercentUi(profileDefaultRebate);
|
|
||||||
|
|
||||||
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
|
|
||||||
return t("profile.validation.shareRange", {
|
return t("profile.validation.shareRange", {
|
||||||
defaultValue: "占成比例须在 0–100 之间",
|
defaultValue: "占成比例须在 0–100 之间",
|
||||||
});
|
});
|
||||||
}
|
case "share_exceeds_parent":
|
||||||
|
return t("profile.validation.shareExceedsParent", {
|
||||||
if (Number.isNaN(creditLimit) || creditLimit < 0) {
|
defaultValue: "实际占成不能超过上级(最高 {{max}}%)",
|
||||||
return t("profile.validation.creditInvalid", {
|
max: issue.max,
|
||||||
defaultValue: "授信额度不能为负数",
|
|
||||||
});
|
});
|
||||||
}
|
case "credit_invalid":
|
||||||
|
return t("profile.validation.creditInvalid", {
|
||||||
if (rebateLimit === null || rebateLimit < 0 || rebateLimit > 100) {
|
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", {
|
return t("profile.validation.rebateLimitRange", {
|
||||||
defaultValue: "回水上限须在 0–100% 之间",
|
defaultValue: "回水上限须在 0–100% 之间",
|
||||||
});
|
});
|
||||||
}
|
case "default_rebate_range":
|
||||||
|
|
||||||
if (defaultRebate === null || defaultRebate < 0 || defaultRebate > 100) {
|
|
||||||
return t("profile.validation.defaultRebateRange", {
|
return t("profile.validation.defaultRebateRange", {
|
||||||
defaultValue: "默认玩家回水须在 0–100% 之间",
|
defaultValue: "默认玩家回水须在 0–100% 之间",
|
||||||
});
|
});
|
||||||
}
|
case "default_exceeds_limit":
|
||||||
|
return t("profile.validation.defaultExceedsLimitWithMax", {
|
||||||
if (rebateLimit > 0 && defaultRebate > rebateLimit) {
|
defaultValue: "默认玩家回水不能超过 {{max}}%",
|
||||||
return t("profile.validation.defaultExceedsLimit", {
|
max: issue.max,
|
||||||
defaultValue: "默认玩家回水不能超过回水上限",
|
|
||||||
});
|
});
|
||||||
|
case "rebate_exceeds_parent":
|
||||||
|
return t("profile.validation.rebateExceedsParent", {
|
||||||
|
defaultValue: "回水上限不能超过上级(最高 {{max}}%)",
|
||||||
|
max: issue.max,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return t("profile.validation.invalid", { 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 (issue !== null) {
|
||||||
|
return profileValidationMessage(issue);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -282,11 +330,29 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
[flatNodes, selectedNodeId],
|
[flatNodes, selectedNodeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const lineRootProfileLocked = useMemo(
|
||||||
|
() => isLineRootAgentNode(selectedNode) && !isSuperAdmin,
|
||||||
|
[isSuperAdmin, selectedNode],
|
||||||
|
);
|
||||||
|
|
||||||
const isOwnAgentNode =
|
const isOwnAgentNode =
|
||||||
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
||||||
|
|
||||||
|
const canViewProfileTab =
|
||||||
|
canManageProfile && selectedNode !== null && !isOwnAgentNode;
|
||||||
|
|
||||||
const canEditSelectedProfile =
|
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(() => {
|
const selectedChildAgents = useMemo(() => {
|
||||||
if (selectedNode === null) {
|
if (selectedNode === null) {
|
||||||
@@ -390,24 +456,30 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
.catch(() => setRootProfile(null));
|
.catch(() => setRootProfile(null));
|
||||||
}, [rootNode?.id]);
|
}, [rootNode?.id]);
|
||||||
|
|
||||||
/** 仅上级/平台维护下级占成授信;代理查看自己时不展示配置 Tab */
|
/** 代理看自己,或站点方看一级代理:占成授信 Tab 只读 */
|
||||||
const canShowProfileTab = canEditSelectedProfile;
|
const profileReadOnly = isOwnAgentNode || lineRootProfileLocked;
|
||||||
|
|
||||||
|
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||||
|
|
||||||
const canShowDownlineTab = useMemo(
|
const canShowDownlineTab = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedNode !== null &&
|
selectedNode !== null &&
|
||||||
!selectedProfileLoading &&
|
!selectedProfileLoading &&
|
||||||
selectedProfile?.can_create_child_agent === true,
|
(isSiteAdmin ||
|
||||||
[selectedNode, selectedProfile, selectedProfileLoading],
|
isSuperAdmin ||
|
||||||
|
selectedProfile?.can_create_child_agent === true),
|
||||||
|
[isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canShowPlayersTab = useMemo(
|
const canShowPlayersTab = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedNode !== null &&
|
selectedNode !== null &&
|
||||||
!selectedProfileLoading &&
|
!selectedProfileLoading &&
|
||||||
selectedProfile?.can_create_player === true &&
|
hasUsersManagePermission &&
|
||||||
hasUsersManagePermission,
|
(isSiteAdmin ||
|
||||||
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
|
isSuperAdmin ||
|
||||||
|
selectedProfile?.can_create_player === true),
|
||||||
|
[hasUsersManagePermission, isSiteAdmin, isSuperAdmin, selectedNode, selectedProfile, selectedProfileLoading],
|
||||||
);
|
);
|
||||||
|
|
||||||
const playersTabHint = useMemo(() => {
|
const playersTabHint = useMemo(() => {
|
||||||
@@ -431,8 +503,12 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
|
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
|
||||||
|
|
||||||
const canCreateChildOnSelected = useMemo(
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -440,7 +516,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailTab === "profile" && !canShowProfileTab) {
|
if (detailTab === "profile" && !canViewProfileTab) {
|
||||||
setDetailTab("overview");
|
setDetailTab("overview");
|
||||||
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
||||||
setDetailTab(canShowPlayersTab ? "players" : "overview");
|
setDetailTab(canShowPlayersTab ? "players" : "overview");
|
||||||
@@ -450,7 +526,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}, [
|
}, [
|
||||||
canShowDownlineTab,
|
canShowDownlineTab,
|
||||||
canShowPlayersTab,
|
canShowPlayersTab,
|
||||||
canShowProfileTab,
|
canViewProfileTab,
|
||||||
detailTab,
|
detailTab,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
selectedProfileLoading,
|
selectedProfileLoading,
|
||||||
@@ -618,12 +694,12 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inlineProfileFields = useMemo(() => {
|
const inlineProfileFields = useMemo(() => {
|
||||||
if (!canShowProfileTab) {
|
if (!canViewProfileTab) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disabled: !canEditSelectedProfile,
|
disabled: profileReadOnly || !canEditSelectedProfile,
|
||||||
loading: selectedProfileLoading,
|
loading: selectedProfileLoading,
|
||||||
parentCaps: profileParentCaps,
|
parentCaps: profileParentCaps,
|
||||||
availableCredit: profileAvailableCredit,
|
availableCredit: profileAvailableCredit,
|
||||||
@@ -645,18 +721,25 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
onCanCreateChildChange: setProfileCanCreateChild,
|
onCanCreateChildChange: setProfileCanCreateChild,
|
||||||
riskTags: profileRiskTags,
|
riskTags: profileRiskTags,
|
||||||
onRiskTagsChange: setProfileRiskTags,
|
onRiskTagsChange: setProfileRiskTags,
|
||||||
|
baselineCreditLimit: profileBaselineCreditLimit,
|
||||||
|
minCreditLimit: profileMinCreditLimit,
|
||||||
|
profileScalarsEditable: isRootProfileEditableByActor(selectedNode, isSuperAdmin),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
canCreateChildAgent,
|
canCreateChildAgent,
|
||||||
canEditSelectedProfile,
|
canEditSelectedProfile,
|
||||||
canShowProfileTab,
|
canViewProfileTab,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
|
profileReadOnly,
|
||||||
|
selectedNode,
|
||||||
profileAvailableCredit,
|
profileAvailableCredit,
|
||||||
profileCanCreateChild,
|
profileCanCreateChild,
|
||||||
profileCanCreatePlayer,
|
profileCanCreatePlayer,
|
||||||
profileCreditLimit,
|
profileCreditLimit,
|
||||||
profileDefaultRebate,
|
profileDefaultRebate,
|
||||||
profileExtraRebate,
|
profileExtraRebate,
|
||||||
|
profileBaselineCreditLimit,
|
||||||
|
profileMinCreditLimit,
|
||||||
profileParentCaps,
|
profileParentCaps,
|
||||||
profileRebateLimit,
|
profileRebateLimit,
|
||||||
profileRiskTags,
|
profileRiskTags,
|
||||||
@@ -684,6 +767,16 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usernameIssue = validateAdminLoginAccount(nodeUsername);
|
||||||
|
if (usernameIssue === "invalid_charset") {
|
||||||
|
toast.error(
|
||||||
|
t("usernameInvalidCharset", {
|
||||||
|
defaultValue: "登录名只能使用字母、数字、点(.)、下划线和连字符",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (nodeDialogMode === "create") {
|
if (nodeDialogMode === "create") {
|
||||||
if (targetParentId === null) {
|
if (targetParentId === null) {
|
||||||
return;
|
return;
|
||||||
@@ -692,13 +785,17 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
toast.error(t("passwordRequired", { defaultValue: "请填写密码" }));
|
toast.error(t("passwordRequired", { defaultValue: "请填写密码" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nodePassword.trim().length < 8) {
|
const passwordIssue = validateAdminPassword(nodePassword);
|
||||||
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (nodePassword.trim() && nodePassword.trim().length < 8) {
|
} else if (nodePassword.trim()) {
|
||||||
|
const passwordIssue = validateAdminPassword(nodePassword);
|
||||||
|
if (passwordIssue === "too_short") {
|
||||||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
|
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("bindAccountPasswordRequired", {
|
t("bindAccountPasswordRequired", {
|
||||||
@@ -711,7 +808,9 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const includeProfileInDialog =
|
const includeProfileInDialog =
|
||||||
canManageProfile &&
|
canManageProfile &&
|
||||||
(nodeDialogMode === "create" ||
|
(nodeDialogMode === "create" ||
|
||||||
(editingNodeId !== null && boundAgent?.id !== editingNodeId));
|
(editingNodeId !== null &&
|
||||||
|
boundAgent?.id !== editingNodeId &&
|
||||||
|
dialogProfileEditable));
|
||||||
|
|
||||||
if (includeProfileInDialog) {
|
if (includeProfileInDialog) {
|
||||||
if (nodeDialogMode === "edit" && !profileLoaded) {
|
if (nodeDialogMode === "edit" && !profileLoaded) {
|
||||||
@@ -723,7 +822,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileError = validateProfileFields();
|
const profileError = validateProfileFields(editingDialogNode);
|
||||||
if (profileError !== null) {
|
if (profileError !== null) {
|
||||||
toast.error(profileError);
|
toast.error(profileError);
|
||||||
return;
|
return;
|
||||||
@@ -739,7 +838,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
username: nodeUsername.trim(),
|
username: nodeUsername.trim(),
|
||||||
password: nodePassword,
|
password: nodePassword,
|
||||||
status: nodeStatus,
|
status: nodeStatus,
|
||||||
...(canManageProfile ? profilePayload() : {}),
|
...(canManageProfile ? profilePayload(null) : {}),
|
||||||
});
|
});
|
||||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||||
@@ -752,7 +851,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
status: nodeStatus,
|
status: nodeStatus,
|
||||||
});
|
});
|
||||||
if (includeProfileInDialog) {
|
if (includeProfileInDialog) {
|
||||||
await putAgentNodeProfile(editingNodeId, profilePayload());
|
await putAgentNodeProfile(editingNodeId, profilePayload(editingDialogNode));
|
||||||
}
|
}
|
||||||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||||||
}
|
}
|
||||||
@@ -905,9 +1004,9 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
detailTab={detailTab}
|
detailTab={detailTab}
|
||||||
onDetailTabChange={setDetailTab}
|
onDetailTabChange={setDetailTab}
|
||||||
canViewProfileTab={canShowProfileTab}
|
canViewProfileTab={canViewProfileTab}
|
||||||
canEditProfileTab={canEditSelectedProfile}
|
canEditProfileTab={canEditSelectedProfile}
|
||||||
profileReadOnly={isOwnAgentNode}
|
profileReadOnly={profileReadOnly}
|
||||||
canViewDownlineTab={canShowDownlineTab}
|
canViewDownlineTab={canShowDownlineTab}
|
||||||
canViewPlayersTab={canShowPlayersTab}
|
canViewPlayersTab={canShowPlayersTab}
|
||||||
playersTabHint={playersTabHint}
|
playersTabHint={playersTabHint}
|
||||||
@@ -916,6 +1015,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
canCreateChildAgent={canCreateChildAgent}
|
canCreateChildAgent={canCreateChildAgent}
|
||||||
canCreatePlayerAction={
|
canCreatePlayerAction={
|
||||||
isSuperAdmin ||
|
isSuperAdmin ||
|
||||||
|
isSiteAdmin ||
|
||||||
(selectedProfile?.can_create_player === true &&
|
(selectedProfile?.can_create_player === true &&
|
||||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
|
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
|
||||||
}
|
}
|
||||||
@@ -1024,12 +1124,15 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
|
|
||||||
{canManageProfile &&
|
{canManageProfile &&
|
||||||
(nodeDialogMode === "create" ||
|
(nodeDialogMode === "create" ||
|
||||||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
|
(editingNodeId !== null &&
|
||||||
|
boundAgent?.id !== editingNodeId &&
|
||||||
|
dialogProfileEditable)) ? (
|
||||||
<div className="space-y-4 border-t border-border/60 pt-5 mt-1">
|
<div className="space-y-4 border-t border-border/60 pt-5 mt-1">
|
||||||
<h3 className="text-sm font-semibold tracking-tight">
|
<h3 className="text-sm font-semibold tracking-tight">
|
||||||
{t("profile.section", { defaultValue: "占成与授信配置" })}
|
{t("profile.section", { defaultValue: "占成与授信配置" })}
|
||||||
</h3>
|
</h3>
|
||||||
<AgentProfileFields
|
<AgentProfileFields
|
||||||
|
disabled={nodeDialogMode === "edit" && !dialogProfileEditable}
|
||||||
loading={profileLoading}
|
loading={profileLoading}
|
||||||
parentCaps={profileParentCaps}
|
parentCaps={profileParentCaps}
|
||||||
availableCredit={profileAvailableCredit}
|
availableCredit={profileAvailableCredit}
|
||||||
@@ -1051,6 +1154,9 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
onCanCreateChildChange={setProfileCanCreateChild}
|
onCanCreateChildChange={setProfileCanCreateChild}
|
||||||
riskTags={profileRiskTags}
|
riskTags={profileRiskTags}
|
||||||
onRiskTagsChange={setProfileRiskTags}
|
onRiskTagsChange={setProfileRiskTags}
|
||||||
|
baselineCreditLimit={nodeDialogMode === "create" ? 0 : profileBaselineCreditLimit}
|
||||||
|
minCreditLimit={nodeDialogMode === "create" ? 0 : profileMinCreditLimit}
|
||||||
|
profileScalarsEditable={dialogProfileEditable}
|
||||||
idPrefix="dialog-agent-profile"
|
idPrefix="dialog-agent-profile"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
postAdminPlayer,
|
postAdminPlayer,
|
||||||
putAdminPlayer,
|
putAdminPlayer,
|
||||||
} from "@/api/admin-player";
|
} 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 { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
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 { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money";
|
||||||
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
|
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
|
||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||||
|
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
|
||||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -86,15 +93,6 @@ function playerStatusLabel(
|
|||||||
return String(status);
|
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 {
|
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
|
||||||
if (row.rebate_rate != null) {
|
if (row.rebate_rate != null) {
|
||||||
return row.rebate_rate;
|
return row.rebate_rate;
|
||||||
@@ -171,6 +169,7 @@ export function AgentsPlayersPanel({
|
|||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const boundAgent = profile?.agent ?? null;
|
const boundAgent = profile?.agent ?? null;
|
||||||
const isSuperAdmin = profile?.is_super_admin === true;
|
const isSuperAdmin = profile?.is_super_admin === true;
|
||||||
|
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
|
|
||||||
const profileAllowsCreate =
|
const profileAllowsCreate =
|
||||||
@@ -206,6 +205,15 @@ export function AgentsPlayersPanel({
|
|||||||
const [creditLimit, setCreditLimit] = useState("");
|
const [creditLimit, setCreditLimit] = useState("");
|
||||||
const [rebateRate, setRebateRate] = useState("");
|
const [rebateRate, setRebateRate] = useState("");
|
||||||
const [parentAvailableCredit, setParentAvailableCredit] = useState<number | null>(null);
|
const [parentAvailableCredit, setParentAvailableCredit] = useState<number | null>(null);
|
||||||
|
const [agentRebateLimitPercent, setAgentRebateLimitPercent] = useState<number | null>(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 [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [editSaving, setEditSaving] = useState(false);
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
const [editingPlayer, setEditingPlayer] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"][number] | null>(null);
|
const [editingPlayer, setEditingPlayer] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"][number] | null>(null);
|
||||||
@@ -213,9 +221,7 @@ export function AgentsPlayersPanel({
|
|||||||
const [editNickname, setEditNickname] = useState("");
|
const [editNickname, setEditNickname] = useState("");
|
||||||
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
|
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
|
||||||
const [editStatus, setEditStatus] = useState(0);
|
const [editStatus, setEditStatus] = useState(0);
|
||||||
const [editCreditBase, setEditCreditBase] = useState(0);
|
const [editCreditLimit, setEditCreditLimit] = useState("");
|
||||||
const [editCreditAdjustMode, setEditCreditAdjustMode] = useState<"increase" | "decrease">("increase");
|
|
||||||
const [editCreditDelta, setEditCreditDelta] = useState("");
|
|
||||||
const [editRebateRate, setEditRebateRate] = useState("");
|
const [editRebateRate, setEditRebateRate] = useState("");
|
||||||
const [editRiskTags, setEditRiskTags] = useState("");
|
const [editRiskTags, setEditRiskTags] = useState("");
|
||||||
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
const [editDetailLoading, setEditDetailLoading] = useState(false);
|
||||||
@@ -273,9 +279,19 @@ export function AgentsPlayersPanel({
|
|||||||
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.trim().length < 8) {
|
const usernameIssue = validateNativePlayerUsername(username);
|
||||||
|
if (usernameIssue === "invalid_charset") {
|
||||||
toast.error(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -314,6 +330,16 @@ export function AgentsPlayersPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedRebateRate !== null && parsedRebateRate > maxPlayerRebatePercent) {
|
||||||
|
toast.error(
|
||||||
|
t("playersPanel.rebateRateExceedsAgent", {
|
||||||
|
defaultValue: "回水比例不能超过代理回水上限 {{max}}%",
|
||||||
|
max: maxPlayerRebatePercent,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await postAdminPlayer({
|
await postAdminPlayer({
|
||||||
@@ -321,7 +347,9 @@ export function AgentsPlayersPanel({
|
|||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password: password,
|
password: password,
|
||||||
nickname: nickname.trim() || null,
|
nickname: nickname.trim() || null,
|
||||||
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
|
...(effectiveAgentId != null && (isSuperAdmin || isSiteAdmin || boundAgent !== null)
|
||||||
|
? { agent_node_id: effectiveAgentId }
|
||||||
|
: {}),
|
||||||
credit_limit: parsedCreditLimit,
|
credit_limit: parsedCreditLimit,
|
||||||
...(parsedRebateRate !== null
|
...(parsedRebateRate !== null
|
||||||
? { rebate_rate: parsedRebateRate }
|
? { rebate_rate: parsedRebateRate }
|
||||||
@@ -356,10 +384,18 @@ export function AgentsPlayersPanel({
|
|||||||
setRebateRate("");
|
setRebateRate("");
|
||||||
if (effectiveAgentId !== null) {
|
if (effectiveAgentId !== null) {
|
||||||
void getAgentNodeProfile(effectiveAgentId)
|
void getAgentNodeProfile(effectiveAgentId)
|
||||||
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
|
.then((p) => {
|
||||||
.catch(() => setParentAvailableCredit(null));
|
setParentAvailableCredit(p.available_credit ?? null);
|
||||||
|
const rebate = Number(p.rebate_limit);
|
||||||
|
setAgentRebateLimitPercent(Number.isFinite(rebate) ? rebate : null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setParentAvailableCredit(null);
|
||||||
|
setAgentRebateLimitPercent(null);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setParentAvailableCredit(null);
|
setParentAvailableCredit(null);
|
||||||
|
setAgentRebateLimitPercent(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,9 +415,7 @@ export function AgentsPlayersPanel({
|
|||||||
setEditNickname(form.nickname);
|
setEditNickname(form.nickname);
|
||||||
setEditDefaultCurrency(form.currency);
|
setEditDefaultCurrency(form.currency);
|
||||||
setEditStatus(form.status);
|
setEditStatus(form.status);
|
||||||
setEditCreditBase(form.creditLimit);
|
setEditCreditLimit(String(form.creditLimit));
|
||||||
setEditCreditAdjustMode("increase");
|
|
||||||
setEditCreditDelta("");
|
|
||||||
setEditRebateRate(form.rebateRate);
|
setEditRebateRate(form.rebateRate);
|
||||||
setEditRiskTags(form.riskTags);
|
setEditRiskTags(form.riskTags);
|
||||||
};
|
};
|
||||||
@@ -391,6 +425,18 @@ export function AgentsPlayersPanel({
|
|||||||
applyEditForm(row);
|
applyEditForm(row);
|
||||||
setEditDialogOpen(true);
|
setEditDialogOpen(true);
|
||||||
setEditDetailLoading(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)
|
void getAdminPlayer(row.id)
|
||||||
.then((full) => {
|
.then((full) => {
|
||||||
setEditingPlayer(full);
|
setEditingPlayer(full);
|
||||||
@@ -418,6 +464,15 @@ export function AgentsPlayersPanel({
|
|||||||
|
|
||||||
const body: Parameters<typeof putAdminPlayer>[1] = {};
|
const body: Parameters<typeof putAdminPlayer>[1] = {};
|
||||||
if (editUsername.trim() !== "" && editUsername.trim() !== (editingPlayer.username ?? "")) {
|
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();
|
body.username = editUsername.trim();
|
||||||
}
|
}
|
||||||
if (editNickname.trim() !== (editingPlayer.nickname ?? "")) {
|
if (editNickname.trim() !== (editingPlayer.nickname ?? "")) {
|
||||||
@@ -430,17 +485,56 @@ export function AgentsPlayersPanel({
|
|||||||
if (editStatus !== editingPlayer.status) {
|
if (editStatus !== editingPlayer.status) {
|
||||||
body.status = editStatus;
|
body.status = editStatus;
|
||||||
}
|
}
|
||||||
const creditDelta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
|
const baselineCredit = editingPlayer.credit_limit ?? 0;
|
||||||
if (!Number.isNaN(creditDelta) && creditDelta > 0) {
|
const parsedCreditLimit =
|
||||||
const signedDelta = editCreditAdjustMode === "increase" ? creditDelta : -creditDelta;
|
editCreditLimit.trim() === "" ? baselineCredit : Number.parseInt(editCreditLimit, 10);
|
||||||
const nextCredit = Math.max(0, (editingPlayer.credit_limit ?? 0) + signedDelta);
|
if (
|
||||||
if (nextCredit !== (editingPlayer.credit_limit ?? 0)) {
|
Number.isNaN(parsedCreditLimit) ||
|
||||||
body.credit_limit = nextCredit;
|
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 prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||||
const nextPercent = parsePercentUi(editRebateRate);
|
const nextPercent = parsePercentUi(editRebateRate);
|
||||||
const nextRebate = nextPercent === null ? null : nextPercent;
|
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)) {
|
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
|
||||||
body.rebate_rate = nextRebate;
|
body.rebate_rate = nextRebate;
|
||||||
}
|
}
|
||||||
@@ -505,13 +599,6 @@ export function AgentsPlayersPanel({
|
|||||||
);
|
);
|
||||||
const billingCurrency =
|
const billingCurrency =
|
||||||
selectedBill?.currency_code ?? billingPlayer?.default_currency ?? "NPR";
|
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 {
|
function resetBillingForm(): void {
|
||||||
setPayAmount("");
|
setPayAmount("");
|
||||||
@@ -957,44 +1044,33 @@ export function AgentsPlayersPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<PlayerCreditLimitField
|
||||||
<Label htmlFor="agent-player-credit" className="text-muted-foreground">
|
|
||||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-credit"
|
id="agent-player-credit"
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={creditLimit}
|
value={creditLimit}
|
||||||
onChange={(e) => setCreditLimit(e.target.value)}
|
onValueChange={setCreditLimit}
|
||||||
className="bg-background/50 transition-colors focus:bg-background"
|
parentAvailableCredit={parentAvailableCredit}
|
||||||
/>
|
/>
|
||||||
{parentAvailableCredit !== null ? (
|
|
||||||
<p className="text-[11px] text-muted-foreground/80">
|
|
||||||
{t("playersPanel.availableToGrant", {
|
|
||||||
defaultValue: "代理剩余可下发:{{amount}}",
|
|
||||||
amount: formatCredit(parentAvailableCredit),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
|
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
|
||||||
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<AdminNumericStepper
|
||||||
id="agent-player-rebate"
|
id="agent-player-rebate"
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
value={rebateRate}
|
value={rebateRate}
|
||||||
placeholder="0"
|
onValueChange={setRebateRate}
|
||||||
onChange={(e) => setRebateRate(e.target.value)}
|
min={0}
|
||||||
className="bg-background/50 transition-colors focus:bg-background"
|
max={maxPlayerRebatePercent}
|
||||||
|
step={0.5}
|
||||||
|
suffix="%"
|
||||||
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground/80">
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
|
{agentRebateLimitPercent !== null
|
||||||
|
? t("playersPanel.rebateAgentCapHint", {
|
||||||
|
defaultValue: "不得超过代理回水上限 {{max}}%",
|
||||||
|
max: maxPlayerRebatePercent,
|
||||||
|
})
|
||||||
|
: t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1160,14 +1236,14 @@ export function AgentsPlayersPanel({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
|
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
|
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
|
||||||
<div className={editDetailLoading ? "hidden" : "space-y-3"}>
|
<div className={editDetailLoading ? "hidden" : "grid gap-5 py-2"}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-username">
|
<Label htmlFor="agent-player-edit-username" className="text-muted-foreground">
|
||||||
{t("players:username", { defaultValue: "用户名" })}
|
{t("players:username", { defaultValue: "用户名" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1175,10 +1251,11 @@ export function AgentsPlayersPanel({
|
|||||||
value={editUsername}
|
value={editUsername}
|
||||||
onChange={(e) => setEditUsername(e.target.value)}
|
onChange={(e) => setEditUsername(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-nickname">
|
<Label htmlFor="agent-player-edit-nickname" className="text-muted-foreground">
|
||||||
{t("players:nickname", { defaultValue: "昵称" })}
|
{t("players:nickname", { defaultValue: "昵称" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1186,10 +1263,11 @@ export function AgentsPlayersPanel({
|
|||||||
value={editNickname}
|
value={editNickname}
|
||||||
onChange={(e) => setEditNickname(e.target.value)}
|
onChange={(e) => setEditNickname(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-currency">
|
<Label htmlFor="agent-player-edit-currency" className="text-muted-foreground">
|
||||||
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
|
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1197,87 +1275,41 @@ export function AgentsPlayersPanel({
|
|||||||
value={editDefaultCurrency}
|
value={editDefaultCurrency}
|
||||||
onChange={(e) => setEditDefaultCurrency(e.target.value)}
|
onChange={(e) => setEditDefaultCurrency(e.target.value)}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<PlayerCreditLimitField
|
||||||
<Label htmlFor="agent-player-edit-credit">
|
id="agent-player-edit-credit"
|
||||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
value={editCreditLimit}
|
||||||
</Label>
|
onValueChange={setEditCreditLimit}
|
||||||
<div className="rounded-xl border border-border/70 bg-muted/20 p-3">
|
parentAvailableCredit={parentAvailableCredit}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
baselineCreditLimit={editingPlayer?.credit_limit ?? 0}
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("playersPanel.currentCredit", { defaultValue: "当前授信" })}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{formatPlayerCreditAmount(editCreditBase, editDefaultCurrency || "NPR")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="agent-player-edit-credit-mode">
|
|
||||||
{t("playersPanel.creditAdjustType", { defaultValue: "调整方式" })}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={editCreditAdjustMode}
|
|
||||||
onValueChange={(value) => setEditCreditAdjustMode(value as "increase" | "decrease")}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="agent-player-edit-credit-mode">
|
|
||||||
<SelectValue>
|
|
||||||
{creditAdjustModeLabel(editCreditAdjustMode, t)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="increase">
|
|
||||||
{creditAdjustModeLabel("increase", t)}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="decrease">
|
|
||||||
{creditAdjustModeLabel("decrease", t)}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="agent-player-edit-credit-delta">
|
|
||||||
{t("playersPanel.creditAdjustAmount", { defaultValue: "调整额度" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-edit-credit-delta"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={editCreditDelta}
|
|
||||||
onChange={(e) => setEditCreditDelta(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-xs text-muted-foreground">
|
|
||||||
{t("playersPanel.creditProjected", {
|
|
||||||
defaultValue: "调整后授信:{{amount}}",
|
|
||||||
amount: formatPlayerCreditAmount(projectedCreditLimit, editDefaultCurrency || "NPR"),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-rebate">
|
<Label htmlFor="agent-player-edit-rebate" className="text-muted-foreground">
|
||||||
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<AdminNumericStepper
|
||||||
id="agent-player-edit-rebate"
|
id="agent-player-edit-rebate"
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
value={editRebateRate}
|
value={editRebateRate}
|
||||||
onChange={(e) => setEditRebateRate(e.target.value)}
|
onValueChange={setEditRebateRate}
|
||||||
placeholder="0"
|
min={0}
|
||||||
|
max={maxPlayerRebatePercent}
|
||||||
|
step={0.5}
|
||||||
|
suffix="%"
|
||||||
|
preserveOverMaxOnBlur
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
|
{agentRebateLimitPercent !== null
|
||||||
|
? t("playersPanel.rebateAgentCapHint", {
|
||||||
|
defaultValue: "不得超过代理回水上限 {{max}}%",
|
||||||
|
max: maxPlayerRebatePercent,
|
||||||
|
})
|
||||||
|
: t("playersPanel.rebateRateStepHint", { defaultValue: "使用 +/- 调整回水比例" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-risk-tags">
|
<Label htmlFor="agent-player-edit-risk-tags" className="text-muted-foreground">
|
||||||
{t("playersPanel.riskTags", { defaultValue: "风控标签" })}
|
{t("playersPanel.riskTags", { defaultValue: "风控标签" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1287,10 +1319,11 @@ export function AgentsPlayersPanel({
|
|||||||
placeholder={t("playersPanel.riskTagsPlaceholder", {
|
placeholder={t("playersPanel.riskTagsPlaceholder", {
|
||||||
defaultValue: "逗号分隔",
|
defaultValue: "逗号分隔",
|
||||||
})}
|
})}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-player-edit-status">
|
<Label htmlFor="agent-player-edit-status" className="text-muted-foreground">
|
||||||
{t("players:status", { defaultValue: "状态" })}
|
{t("players:status", { defaultValue: "状态" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={String(editStatus)} onValueChange={(value) => setEditStatus(Number(value))}>
|
<Select value={String(editStatus)} onValueChange={(value) => setEditStatus(Number(value))}>
|
||||||
@@ -1311,7 +1344,7 @@ export function AgentsPlayersPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="mt-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||||
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||||
import { ADMIN_SELECT_FILTER_ALL, adminSiteSelectLabel } from "@/lib/admin-select-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 exportLabels = useExportLabels("players");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const isSuperAdmin = profile?.is_super_admin === true;
|
const isSuperAdmin = profile?.is_super_admin === true;
|
||||||
|
const isSiteAdmin = isSiteAdminOperator(profile);
|
||||||
const boundAgent = profile?.agent ?? null;
|
const boundAgent = profile?.agent ?? null;
|
||||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -109,6 +111,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
const keywordFromUrl = (searchParams.get("keyword") ?? "").trim();
|
const keywordFromUrl = (searchParams.get("keyword") ?? "").trim();
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||||
|
const canPickAgentOnCreate = isSuperAdmin || isSiteAdmin;
|
||||||
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(10);
|
const [perPage, setPerPage] = useState(10);
|
||||||
@@ -212,9 +215,12 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
setAccountMode("create");
|
setAccountMode("create");
|
||||||
setEditingAccountId(null);
|
setEditingAccountId(null);
|
||||||
const agentSite = boundAgent?.site_code?.trim() ?? "";
|
const agentSite = boundAgent?.site_code?.trim() ?? "";
|
||||||
|
const siteAdminSite = profile?.site?.code?.trim() ?? "";
|
||||||
const defaultSite =
|
const defaultSite =
|
||||||
agentSite !== ""
|
agentSite !== ""
|
||||||
? agentSite
|
? agentSite
|
||||||
|
: siteAdminSite !== ""
|
||||||
|
? siteAdminSite
|
||||||
: siteOptions[0]?.code ?? "";
|
: siteOptions[0]?.code ?? "";
|
||||||
setFormSiteCode(defaultSite);
|
setFormSiteCode(defaultSite);
|
||||||
setFormAgentNodeId(boundAgent?.id);
|
setFormAgentNodeId(boundAgent?.id);
|
||||||
@@ -228,7 +234,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (!accountOpen || accountMode !== "create" || !isSuperAdmin) {
|
if (!accountOpen || accountMode !== "create" || !canPickAgentOnCreate) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const siteCode = formSiteCode.trim();
|
const siteCode = formSiteCode.trim();
|
||||||
@@ -271,7 +277,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [accountOpen, accountMode, formSiteCode, isSuperAdmin, siteOptions]);
|
}, [accountOpen, accountMode, canPickAgentOnCreate, formSiteCode, siteOptions]);
|
||||||
|
|
||||||
function openEditAccount(row: AdminPlayerRow): void {
|
function openEditAccount(row: AdminPlayerRow): void {
|
||||||
setAccountMode("edit");
|
setAccountMode("edit");
|
||||||
@@ -295,7 +301,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
|
|
||||||
async function submitAccount(): Promise<void> {
|
async function submitAccount(): Promise<void> {
|
||||||
if (accountMode === "create") {
|
if (accountMode === "create") {
|
||||||
if (!isSuperAdmin && !boundAgent) {
|
if (!isSuperAdmin && !boundAgent && !isSiteAdmin) {
|
||||||
toast.error(t("createAgentRequired"));
|
toast.error(t("createAgentRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -307,7 +313,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
toast.error(t("sitePlayerIdRequired"));
|
toast.error(t("sitePlayerIdRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isSuperAdmin && (formAgentNodeId === undefined || formAgentNodeId <= 0)) {
|
if (canPickAgentOnCreate && (formAgentNodeId === undefined || formAgentNodeId <= 0)) {
|
||||||
toast.error(t("createAgentRequired"));
|
toast.error(t("createAgentRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -320,7 +326,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
nickname: formNickname.trim() || null,
|
nickname: formNickname.trim() || null,
|
||||||
default_currency: formDefaultCurrency,
|
default_currency: formDefaultCurrency,
|
||||||
status: formStatus,
|
status: formStatus,
|
||||||
...(isSuperAdmin && formAgentNodeId
|
...(canPickAgentOnCreate && formAgentNodeId
|
||||||
? { agent_node_id: formAgentNodeId }
|
? { agent_node_id: formAgentNodeId }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
@@ -738,7 +744,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
code: boundAgent.code,
|
code: boundAgent.code,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : isSuperAdmin ? (
|
) : canPickAgentOnCreate ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="player-agent-node">{t("createAgentNode")}</Label>
|
<Label htmlFor="player-agent-node">{t("createAgentNode")}</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
REPORT_UI_TO_JOB_TYPE,
|
REPORT_UI_TO_JOB_TYPE,
|
||||||
type ReportUiKey,
|
type ReportUiKey,
|
||||||
} from "@/lib/report-export-map";
|
} from "@/lib/report-export-map";
|
||||||
import { ReportJobsPanel } from "@/modules/reports/report-jobs-panel";
|
|
||||||
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
||||||
import { getAdminUsers } from "@/api/admin-users";
|
import { getAdminUsers } from "@/api/admin-users";
|
||||||
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||||
@@ -662,7 +661,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(20);
|
const [perPage, setPerPage] = useState(20);
|
||||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||||
const [jobRefreshToken, setJobRefreshToken] = useState(0);
|
|
||||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||||
const playOptions = useCachedPlayTypeOptions();
|
const playOptions = useCachedPlayTypeOptions();
|
||||||
const tRef = useTranslationRef(["reports", "common"]);
|
const tRef = useTranslationRef(["reports", "common"]);
|
||||||
@@ -1180,7 +1178,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
export_format: format === "excel" ? "xlsx" : "csv",
|
export_format: format === "excel" ? "xlsx" : "csv",
|
||||||
parameters,
|
parameters,
|
||||||
});
|
});
|
||||||
setJobRefreshToken((n) => n + 1);
|
|
||||||
const { blob, filename } = await downloadAdminReportJob(job.id);
|
const { blob, filename } = await downloadAdminReportJob(job.id);
|
||||||
const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
|
const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
|
||||||
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
|
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
|
||||||
@@ -1789,12 +1786,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
|||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ReportJobsPanel
|
|
||||||
canExport={canExportReports}
|
|
||||||
refreshToken={jobRefreshToken}
|
|
||||||
reportType={REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const files = [
|
const files = [
|
||||||
"reports/report-jobs-panel.tsx",
|
|
||||||
"risk/risk-pools-console.tsx",
|
"risk/risk-pools-console.tsx",
|
||||||
"risk/risk-index-console.tsx",
|
"risk/risk-index-console.tsx",
|
||||||
"admin-roles/admin-roles-console.tsx",
|
"admin-roles/admin-roles-console.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user