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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "代理管理" })}
|
||||
|
||||
Reference in New Issue
Block a user