feat(ui): enhance table and admin components with improved layout and status display

- Updated global CSS to center-align table headers and cells, ensuring a consistent layout.
- Modified admin table components to replace switches with status badges for better clarity.
- Enhanced internationalization support by adding new strings for version actions and validation messages in multiple locales.
- Refactored configuration document screens to include version selection and improved user feedback on status changes.
This commit is contained in:
2026-05-26 11:13:16 +08:00
parent 05fa0cbeec
commit 4080f0b601
38 changed files with 788 additions and 608 deletions

View File

@@ -16,8 +16,11 @@ import {
} from "@/api/admin-config";
import { Button } from "@/components/ui/button";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import {
Dialog,
DialogContent,
@@ -34,6 +37,7 @@ import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -77,16 +81,25 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
type OddsConfigDocScreenProps = {
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
embedded?: boolean;
/** 与回水分区共用版本选择(合并页) */
versionId?: string;
onVersionIdChange?: (id: string) => void;
};
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
export function OddsConfigDocScreen({
embedded = false,
versionId: controlledVersionId,
onVersionIdChange,
}: OddsConfigDocScreenProps) {
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [internalSelectedId, setInternalSelectedId] = useState("");
const selectedId = controlledVersionId ?? internalSelectedId;
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loadingTypes, setLoadingTypes] = useState(true);
@@ -417,38 +430,42 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
];
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) => (
<div className={cn("space-y-3", embedded ? "border-t border-border/50 px-3 py-3 sm:px-4" : "rounded-xl border border-border/60 bg-card p-4")}>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
{catTabs.map((tab) => (
<ConfigChip
key={tab.id}
active={catTab === tab.id}
onClick={() => setCatTab(tab.id)}
>
{tab.label}
</ConfigChip>
))}
</ConfigChipGroup>
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
{filteredTypes.length === 0 ? (
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
) : (
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
{filteredTypes.map((type) => (
<ConfigChip
key={tab.id}
active={catTab === tab.id}
onClick={() => setCatTab(tab.id)}
key={type.play_code}
active={resolvedPlayCode === type.play_code}
onClick={() => setPlayCode(type.play_code)}
className="shrink-0"
>
{tab.label}
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</ConfigChip>
))}
</ConfigChipGroup>
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
{filteredTypes.length === 0 ? (
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
) : (
filteredTypes.map((type) => (
<ConfigChip
key={type.play_code}
active={resolvedPlayCode === type.play_code}
onClick={() => setPlayCode(type.play_code)}
>
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</ConfigChip>
))
)}
</ConfigChipGroup>
</div>
</div>
)}
</ConfigChipGroup>
</div>
);
const toolbarBlock = (
<ConfigDocToolbar
className={embedded ? "rounded-none border-0 shadow-none" : undefined}
switcher={
<ConfigVersionSwitcher
versions={list}
@@ -475,57 +492,64 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
onPublish={() => void requestPublishConfirm()}
/>
}
footer={
!detail ? null : (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("odds.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : activeHead ? (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
) : null}
</ConfigVersionToolbarMeta>
)
}
/>
);
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 ? (
<div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
</div>
<p className="py-8 text-center text-sm text-muted-foreground">
{t("odds.loadingDetails", { ns: "config" })}
</p>
) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[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">
<Label className="flex items-baseline gap-2">
{prizeScopeLabel(scope, t)}
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2">
{canEditDraft ? (
<div
className={cn(
embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined,
)}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
<div key={scope} className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
onChange={(e) =>
@@ -535,46 +559,39 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
}
/>
) : (
<ConfigReadonlyValue mono className="max-w-[200px]">
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)}
{!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>
)}
</div>
);
})}
<div className="grid gap-1 pt-2 border-t">
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="max-w-[200px]">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
{!embedded ? (
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
)
) : (
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
);
})}
<div className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })}
</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
</div>
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div>
) : null}
</>
@@ -649,10 +666,11 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
if (embedded) {
return (
<div className="space-y-6">
{filtersBlock}
{toolbarBlock}
{contextBlock}
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
{toolbarBlock}
{filtersBlock}
</div>
{mainBlock}
{dialogs}
</div>
@@ -664,7 +682,6 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
title={t("nav.items.odds", { ns: "config" })}
filters={filtersBlock}
toolbar={toolbarBlock}
context={contextBlock}
>
{mainBlock}
{dialogs}