refactor: 更新管理端页面元数据,统一国际化支持,移除冗余代码

This commit is contained in:
2026-05-21 17:27:52 +08:00
parent 26feed3c4f
commit e8a5507411
77 changed files with 1669 additions and 732 deletions

View File

@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAdminProfile } from "@/stores/admin-session";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
type HubCard = {
href: string;
titleKey: string;
descKey: string;
requiredAny: readonly string[];
};
const HUB_CARDS: HubCard[] = [
{
href: "/admin/rules/plays",
titleKey: "hub.playsTitle",
descKey: "hub.playsDesc",
requiredAny: ["prd.play_switch.manage", "prd.odds.manage"],
},
{
href: "/admin/rules/odds",
titleKey: "hub.oddsTitle",
descKey: "hub.oddsDesc",
requiredAny: ["prd.odds.manage", "prd.rebate.manage", "prd.rebate.view"],
},
{
href: "/admin/jackpot",
titleKey: "hub.jackpotTitle",
descKey: "hub.jackpotDesc",
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
},
{
href: "/admin/risk/cap",
titleKey: "hub.riskCapTitle",
descKey: "hub.riskCapDesc",
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
},
];
export function ConfigHubScreen() {
const { t } = useTranslation("config");
const profile = useAdminProfile();
const visible = HUB_CARDS.filter((card) =>
adminHasAnyPermission(profile?.permissions, card.requiredAny),
);
return (
<ModuleScaffold>
<div className="mb-6 max-w-2xl">
<h1 className="text-lg font-semibold tracking-tight">{t("hub.title")}</h1>
<p className="mt-2 text-sm text-muted-foreground">{t("hub.description")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{visible.map((card) => (
<Link key={card.href} href={card.href} className="group block">
<Card className="h-full transition-colors hover:border-primary/40 hover:bg-muted/20">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-base">
{t(card.titleKey)}
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
</CardTitle>
<CardDescription>{t(card.descKey)}</CardDescription>
</CardHeader>
<CardContent />
</Card>
</Link>
))}
</div>
</ModuleScaffold>
);
}

View File

@@ -158,9 +158,11 @@ export function ConfigVersionSwitcher({
<SheetTitle className="text-base font-semibold tracking-tight text-foreground">
{resolvedSheetTitle}
</SheetTitle>
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground">
{resolvedSheetDescription}
</SheetDescription>
{resolvedSheetDescription ? (
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground">
{resolvedSheetDescription}
</SheetDescription>
) : null}
</SheetHeader>
</div>
<div className="border-b border-border/60 bg-card px-4 py-3">

View File

@@ -61,7 +61,12 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
return types.filter((t) => t.dimension === dim);
}
export function OddsConfigDocScreen() {
type OddsConfigDocScreenProps = {
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
embedded?: boolean;
};
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
@@ -395,10 +400,7 @@ export function OddsConfigDocScreen() {
{ id: "d2", label: "2D" },
];
return (
<ConfigDocPage
title={t("nav.items.odds", { ns: "config" })}
filters={
const filtersBlock = (
<div className="space-y-4 rounded-xl border border-border/60 bg-card p-4">
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
{catTabs.map((tab) => (
@@ -427,8 +429,9 @@ export function OddsConfigDocScreen() {
)}
</ConfigChipGroup>
</div>
}
toolbar={
);
const toolbarBlock = (
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
@@ -437,7 +440,7 @@ export function OddsConfigDocScreen() {
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("odds.sheetDescription", { ns: "config" })}
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
@@ -456,29 +459,31 @@ export function OddsConfigDocScreen() {
/>
}
/>
}
context={
detail ? (
<ConfigContextBanner emphasis={!isDraft}>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
) : null
}
>
);
const contextBlock =
embedded || !detail ? null : (
<ConfigContextBanner emphasis={!isDraft}>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
);
const mainBlock = (
<>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? (
@@ -489,7 +494,7 @@ export function OddsConfigDocScreen() {
<div className="grid min-h-[420px] gap-4 max-w-md">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
<div key={scope} className="grid gap-1">
@@ -517,13 +522,15 @@ export function OddsConfigDocScreen() {
{row.odds_value}
</ConfigReadonlyValue>
)}
<span className="text-sm text-muted-foreground tabular-nums">
{t("odds.multiplier", {
ns: "config",
value: oddsMultiplierLabel(row.odds_value),
currency: row.currency_code,
})}
</span>
{!embedded ? (
<span className="text-sm text-muted-foreground tabular-nums">
{t("odds.multiplier", {
ns: "config",
value: oddsMultiplierLabel(row.odds_value),
currency: row.currency_code,
})}
</span>
) : null}
</div>
) : (
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
@@ -547,11 +554,17 @@ export function OddsConfigDocScreen() {
{rebatePercentUi}
</ConfigReadonlyValue>
)}
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
{!embedded ? (
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div>
</div>
) : null}
</>
);
const dialogs = (
<>
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
@@ -612,6 +625,30 @@ export function OddsConfigDocScreen() {
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
if (embedded) {
return (
<div className="space-y-6">
{filtersBlock}
{toolbarBlock}
{contextBlock}
{mainBlock}
{dialogs}
</div>
);
}
return (
<ConfigDocPage
title={t("nav.items.odds", { ns: "config" })}
filters={filtersBlock}
toolbar={toolbarBlock}
context={contextBlock}
>
{mainBlock}
{dialogs}
</ConfigDocPage>
);
}

View File

@@ -48,7 +48,11 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
return hit ? rateToPercentUi(String(hit.rebate_rate)) : "0";
}
export function RebateConfigDocScreen() {
type RebateConfigDocScreenProps = {
embedded?: boolean;
};
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
const { t } = useTranslation(["config", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
@@ -266,60 +270,59 @@ export function RebateConfigDocScreen() {
}
}
return (
<ConfigDocPage
title={t("nav.items.rebate", { ns: "config" })}
toolbar={
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={listRows}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
/>
}
actions={
<ConfigVersionActions
isDraft={isDraft}
loadingList={loading}
loadingDetail={loadingDetail}
saving={saving}
publishLabel={t("rebate.publishLabel", { ns: "config" })}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
/>
}
const toolbarBlock = embedded ? null : (
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={listRows}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
/>
}
context={
detail ? (
<ConfigContextBanner emphasis={!isDraft}>
{t("rebate.editingVersion", {
ns: "config",
version: detail.version_no,
status:
detail.status === "draft"
? t("versionStatus.draft", { ns: "config" })
: detail.status === "active"
? t("versionStatus.active", { ns: "config" })
: t("versionStatus.archived", { ns: "config" }),
})}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
) : null
actions={
<ConfigVersionActions
isDraft={isDraft}
loadingList={loading}
loadingDetail={loadingDetail}
saving={saving}
publishLabel={t("rebate.publishLabel", { ns: "config" })}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
/>
}
>
/>
);
const contextBlock =
embedded || !detail ? null : (
<ConfigContextBanner emphasis={!isDraft}>
{t("rebate.editingVersion", {
ns: "config",
version: detail.version_no,
status:
detail.status === "draft"
? t("versionStatus.draft", { ns: "config" })
: detail.status === "active"
? t("versionStatus.active", { ns: "config" })
: t("versionStatus.archived", { ns: "config" }),
})}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
);
const fieldsBlock = (
<>
<div className="grid gap-5 sm:grid-cols-3">
<div className="grid gap-2">
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
@@ -386,16 +389,37 @@ export function RebateConfigDocScreen() {
</div>
</div>
{!embedded ? (
<div className="grid gap-1 text-sm">
<span className="text-muted-foreground">{t("rebate.effectiveTime", { ns: "config" })}</span>
<span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span>
</div>
) : null}
{loading || loadingDetail ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
</>
);
if (embedded) {
return (
<div className="space-y-6">
{contextBlock}
{fieldsBlock}
</div>
);
}
return (
<ConfigDocPage
title={t("nav.items.rebate", { ns: "config" })}
toolbar={toolbarBlock}
context={contextBlock}
>
{fieldsBlock}
</ConfigDocPage>
);
}