feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support

Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color
This commit is contained in:
2026-06-12 20:47:53 +08:00
parent 24fd7c10bd
commit 6ea0a6feec
48 changed files with 1573 additions and 629 deletions

View File

@@ -27,11 +27,14 @@ import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line
type AgentLineProvisionWizardProps = {
embedded?: boolean;
/** 预选接入站点(如代理线路页当前选中的站点) */
defaultSiteCode?: string;
onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>;
};
export function AgentLineProvisionWizard({
embedded = false,
defaultSiteCode,
onSuccess,
}: AgentLineProvisionWizardProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
@@ -40,7 +43,6 @@ export function AgentLineProvisionWizard({
const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]);
const [form, setForm] = useState({
site_code: "",
code: "",
name: "",
username: "",
password: "",
@@ -64,24 +66,22 @@ export function AgentLineProvisionWizard({
[sites],
);
useAsyncEffect(() => {
if (sitesLoading || form.site_code !== "" || !defaultSiteCode) {
return;
}
const normalized = defaultSiteCode.trim().toLowerCase();
if (unboundSites.some((row) => row.code.toLowerCase() === normalized)) {
setForm((f) => ({ ...f, site_code: normalized }));
}
}, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]);
async function onSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!form.site_code.trim()) {
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
return;
}
if (!form.code.trim()) {
toast.error(t("agents:lineProvision.codeRequired", { defaultValue: "请填写代理编码" }));
return;
}
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(form.code.trim())) {
toast.error(
t("agents:lineProvision.codePatternInvalid", {
defaultValue: "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
}),
);
return;
}
if (!form.name.trim()) {
toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" }));
return;
@@ -152,7 +152,6 @@ export function AgentLineProvisionWizard({
try {
const result = await postAdminAgentLine({
site_code: form.site_code.trim().toLowerCase(),
code: form.code.trim().toLowerCase(),
name: form.name.trim(),
username: form.username.trim(),
password: form.password,
@@ -165,7 +164,6 @@ export function AgentLineProvisionWizard({
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({
site_code: "",
code: "",
name: "",
username: "",
password: "",
@@ -200,7 +198,7 @@ export function AgentLineProvisionWizard({
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
{t("agents:lineProvision.description", {
defaultValue:
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。",
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。",
})}{" "}
<Link
href="/admin/config/integration-sites"
@@ -247,15 +245,6 @@ export function AgentLineProvisionWizard({
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
required
pattern="[a-z0-9][a-z0-9_-]*"
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label>
<Input

View File

@@ -17,9 +17,11 @@ import {
AgentLineDetailPanel,
type AgentDetailTab,
} from "@/modules/agents/agent-line-detail-panel";
import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-wizard";
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -47,6 +49,7 @@ import {
PRD_USERS_MANAGE,
} from "@/lib/admin-prd";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
@@ -102,7 +105,7 @@ export function AgentsConsole(): React.ReactElement {
boundAgent === null &&
(isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]));
const { sites: siteOptions } = useAdminSiteCodeOptions();
const { sites: siteOptions, loading: sitesLoading } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const [tree, setTree] = useState<AgentNodeRow[]>([]);
@@ -241,10 +244,17 @@ export function AgentsConsole(): React.ReactElement {
[flatNodes],
);
const visibleAgentRows = flatNodes;
const selectedSiteLabel = useMemo(
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
[adminSiteId, siteOptions],
);
const boundSite = profile?.site ?? null;
const selectedSiteLabel = useMemo(() => {
const fromOptions = siteOptions.find((site) => site.id === adminSiteId)?.name;
if (fromOptions) {
return fromOptions;
}
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.name;
}
return null;
}, [adminSiteId, boundSite, siteOptions]);
const activeSiteCode = useMemo(() => {
const fromAgent = boundAgent?.site_code?.trim();
if (fromAgent) {
@@ -254,8 +264,11 @@ export function AgentsConsole(): React.ReactElement {
if (fromSite) {
return fromSite;
}
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.code.trim();
}
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
}, [adminSiteId, boundAgent?.site_code, boundSite, flatNodes, siteOptions]);
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
@@ -319,11 +332,23 @@ export function AgentsConsole(): React.ReactElement {
setAdminSiteId(profile.agent.admin_site_id);
return;
}
if (profile?.site?.id) {
setAdminSiteId(profile.site.id);
return;
}
if (siteOptions.length > 0 && isSuperAdmin) {
setAdminSiteId(siteOptions[0]?.id ?? null);
}
}
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
}, [
adminSiteId,
canViewAgents,
isSuperAdmin,
profile?.agent?.admin_site_id,
profile?.site?.id,
setAdminSiteId,
siteOptions,
]);
useEffect(() => {
if (!Number.isInteger(selectedNodeIdFromUrl) || selectedNodeIdFromUrl <= 0) {
@@ -786,10 +811,60 @@ export function AgentsConsole(): React.ReactElement {
);
}
if (canViewAgents && loading && tree.length === 0) {
const hasSiteContext =
siteOptions.length > 0 ||
profile?.site != null ||
(profile?.accessible_sites?.length ?? 0) > 0;
if (canViewAgents && profile?.agent == null && !sitesLoading && !hasSiteContext) {
return <AdminNoIntegrationSiteState canCreate={isSuperAdmin} />;
}
if (canViewAgents && loading && tree.length === 0 && adminSiteId !== null) {
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
}
const showSiteAdminAwaitingRoot =
!loading &&
flatNodes.length === 0 &&
!canProvisionLine &&
isSiteAdminOperator(profile);
if (showSiteAdminAwaitingRoot) {
return (
<div className="rounded-2xl border border-border/70 bg-card px-5 py-10 text-center shadow-sm">
<p className="text-sm font-medium text-foreground">
{t("lineUi.awaitingRootAgentTitle", {
defaultValue: "本站尚未开通一级代理",
})}
</p>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
{t("lineUi.awaitingRootAgentHint", {
defaultValue:
"一级代理需由平台超级管理员在「开通一级代理」中创建。开通后您可在此管理下级代理、占成与授信。",
})}
</p>
</div>
);
}
const showProvisionEmpty =
!loading && flatNodes.length === 0 && canProvisionLine;
if (showProvisionEmpty) {
return (
<div className="flex min-h-[32rem] flex-col gap-0">
<AgentLineProvisionWizard
embedded
defaultSiteCode={activeSiteCode}
onSuccess={async () => {
await loadTree(adminSiteId);
}}
/>
</div>
);
}
return (
<div className="flex min-h-[32rem] flex-col gap-0">
<ConfirmDialog />
@@ -861,9 +936,13 @@ export function AgentsConsole(): React.ReactElement {
</div>
) : (
<div className="rounded-2xl border border-border/70 bg-card px-5 py-8 text-sm text-muted-foreground shadow-sm">
{t("lineUi.provisionOnlyHint", {
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
})}
{flatNodes.length === 0
? t("lineUi.noRootAgentHint", {
defaultValue: "该站点尚未开通一级代理,请联系平台管理员在「开通一级代理」中创建线路。",
})
: t("lineUi.provisionOnlyHint", {
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
})}
</div>
)}

View File

@@ -12,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import {
Select,
SelectContent,
@@ -44,7 +45,7 @@ function formatCredit(value: number | null | undefined): string {
return "-";
}
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(value);
return formatAdminCreditMajorDecimal(value);
}
function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {

View File

@@ -35,7 +35,7 @@ export function AgentsSubnav(): React.ReactElement {
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
useEffect(() => {
if (adminSiteId !== null || siteOptions.length === 0) {
if (adminSiteId !== null) {
return;
}
const boundSiteId = profile?.agent?.admin_site_id;
@@ -43,14 +43,26 @@ export function AgentsSubnav(): React.ReactElement {
setAdminSiteId(boundSiteId);
return;
}
setAdminSiteId(siteOptions[0]?.id ?? null);
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
if (profile?.site?.id) {
setAdminSiteId(profile.site.id);
return;
}
if (siteOptions.length > 0) {
setAdminSiteId(siteOptions[0]?.id ?? null);
}
}, [adminSiteId, profile?.agent?.admin_site_id, profile?.site?.id, setAdminSiteId, siteOptions]);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectSiteId = adminSiteId ?? profile?.site?.id ?? siteOptions[0]?.id ?? null;
const selectedSite = useMemo(() => {
const site = siteOptions.find((item) => item.id === selectSiteId);
return site ?? null;
}, [selectSiteId, siteOptions]);
if (site) {
return site;
}
if (profile?.site != null && profile.site.id === selectSiteId) {
return profile.site;
}
return null;
}, [profile?.site, selectSiteId, siteOptions]);
const filteredSites = useMemo(() => {
const normalized = deferredKeyword.trim().toLowerCase();
@@ -61,6 +73,16 @@ export function AgentsSubnav(): React.ReactElement {
return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized));
}, [deferredKeyword, siteOptions]);
const siteReadOnlyLabel =
pathname !== "/admin/agents/list" &&
!canSwitchSite &&
selectedSite != null ? (
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
<span className="min-w-0 truncate font-medium text-foreground">{selectedSite.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{selectedSite.code}</span>
</div>
) : null;
const siteSelector =
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -129,7 +151,7 @@ export function AgentsSubnav(): React.ReactElement {
) : null;
return (
<AdminSubnavBar trailing={siteSelector}>
<AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
<div className="pb-1">
<p className="text-sm font-medium text-foreground">
{t("title", { defaultValue: "代理管理" })}