refactor: 更新管理端页面元数据,统一国际化支持,移除冗余代码
This commit is contained in:
77
src/modules/config/config-hub-screen.tsx
Normal file
77
src/modules/config/config-hub-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user