feat: 增加管理端多语言与风控/报表/奖池操作能力

This commit is contained in:
2026-05-18 15:08:34 +08:00
parent afffa4e508
commit 49a4caf01e
31 changed files with 918 additions and 115 deletions

View File

@@ -81,6 +81,8 @@ export function OddsConfigDocScreen() {
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
const [activeCompareRows, setActiveCompareRows] = useState<OddsItemRow[]>([]);
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
@@ -281,6 +283,24 @@ export function OddsConfigDocScreen() {
}
}
async function requestPublishConfirm() {
if (!detail || !isDraft) {
return;
}
const active = list.find((x) => x.status === "active");
if (active && active.id !== detail.id) {
try {
const d = await getOddsVersion(active.id);
setActiveCompareRows(d.items);
} catch {
setActiveCompareRows([]);
}
} else {
setActiveCompareRows([]);
}
setPublishConfirmOpen(true);
}
async function handleNewDraft() {
setSaving(true);
try {
@@ -343,6 +363,25 @@ export function OddsConfigDocScreen() {
setRollbackOpen(true);
}
const publishDiffRows = useMemo(() => {
if (!detail) {
return [];
}
const selectedPlay = resolvedPlayCode;
return PRIZE_SCOPE_ORDER.map((scope) => {
const next = draftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
return {
scope,
label: PRIZE_SCOPE_LABELS[scope],
oldValue: old?.odds_value ?? null,
newValue: next?.odds_value ?? null,
};
});
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "d4", label: "4D" },
@@ -421,7 +460,7 @@ export function OddsConfigDocScreen() {
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
onPublish={() => void requestPublishConfirm()}
/>
</div>
</div>
@@ -532,6 +571,48 @@ export function OddsConfigDocScreen() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border">
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
</div>
{publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
<span>{row.label}</span>
<span className="text-right font-mono tabular-nums">
{row.oldValue === null ? "—" : row.oldValue}
</span>
<span className="text-right font-mono tabular-nums">{row.newValue ?? "—"}</span>
</div>
))}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPublishConfirmOpen(false)}>
</Button>
<Button
type="button"
disabled={saving}
onClick={() => {
setPublishConfirmOpen(false);
void handlePublish();
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -63,6 +63,50 @@ type PlayConfigSaveItemPayload = {
extra_config_json: unknown;
};
type PlayBatchSwitchGroup = {
key: string;
label: string;
match: (row: PlayConfigItemRow) => boolean;
};
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
{
key: "d2",
label: "2D 全局",
match: (row) => row.dimension === 2,
},
{
key: "d3",
label: "3D 全局",
match: (row) => row.dimension === 3,
},
{
key: "d4",
label: "4D 全局",
match: (row) => row.dimension === 4,
},
{
key: "big-small",
label: "Big / Small",
match: (row) => row.play_code === "big" || row.play_code === "small",
},
{
key: "position",
label: "位置类玩法",
match: (row) => row.category === "position",
},
{
key: "box",
label: "包号类玩法",
match: (row) => row.category === "box",
},
{
key: "jackpot",
label: "Jackpot",
match: (row) => row.category === "jackpot" || row.play_code.includes("jackpot"),
},
];
/** 版本草稿保存 payload直接按当前草稿快照落库。 */
function buildPlayConfigSavePayload(
draftRows: PlayConfigItemRow[],
@@ -217,6 +261,27 @@ export function PlayConfigDocScreen() {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
setDraftRows((prev) =>
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
);
}
const batchSwitchStates = useMemo(
() =>
PLAY_BATCH_SWITCH_GROUPS.map((group) => {
const rows = draftRows.filter(group.match);
const enabledCount = rows.filter((row) => row.is_enabled).length;
return {
...group,
total: rows.length,
enabledCount,
allEnabled: rows.length > 0 && enabledCount === rows.length,
};
}),
[draftRows],
);
async function handleSaveDraft() {
if (!detail || !isDraft) {
return;
@@ -360,6 +425,48 @@ export function PlayConfigDocScreen() {
</p>
) : null}
{detail ? (
<div className="rounded-xl border bg-muted/20 p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
稿
</p>
</div>
{!isDraft ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
稿
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{batchSwitchStates.map((group) => (
<div
key={group.key}
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
>
<div className="min-w-[92px]">
<p className="text-sm font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">
{group.total > 0 ? `${group.enabledCount}/${group.total} 启用` : "暂无玩法"}
</p>
</div>
<Button
type="button"
size="sm"
variant={group.allEnabled ? "secondary" : "outline"}
disabled={!isDraft || saving || group.total === 0}
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
>
{group.allEnabled ? "关闭" : "开启"}
</Button>
</div>
))}
</div>
</div>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail ? (

View File

@@ -55,6 +55,20 @@ function newRow(): DraftRiskRow {
};
}
function isDefaultRiskRow(row: DraftRiskRow): boolean {
return row.cap_type === "default";
}
function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
return {
clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
draw_id: null,
normalized_number: "0000",
cap_amount: amount,
cap_type: "default",
};
}
export function RiskCapDocScreen() {
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -93,12 +107,12 @@ export function RiskCapDocScreen() {
}, [refreshList]);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
if (rows.length === 0) {
const defaultRow = rows.find(isDefaultRiskRow);
if (!defaultRow) {
setDefaultCapStr("");
return;
}
const amounts = [...new Set(rows.map((r) => r.cap_amount))];
setDefaultCapStr(amounts.length === 1 ? String(amounts[0]) : "");
setDefaultCapStr(String(defaultRow.cap_amount));
}
const loadDetail = useCallback(async (id: number) => {
@@ -177,6 +191,13 @@ export function RiskCapDocScreen() {
return;
}
for (const r of draftRows) {
if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) {
toast.error("默认封顶金额必须大于 0");
return;
}
continue;
}
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
return;
@@ -265,13 +286,16 @@ export function RiskCapDocScreen() {
}
}
function applyDefaultCapToAll() {
function applyDefaultCap() {
const n = Number.parseInt(defaultCapStr, 10);
if (!Number.isFinite(n) || n < 0) {
if (!Number.isFinite(n) || n <= 0) {
toast.error("请输入有效的封顶金额");
return;
}
setDraftRows((prev) => prev.map((r) => ({ ...r, cap_amount: n })));
setDraftRows((prev) => {
const next = prev.filter((row) => !isDefaultRiskRow(row));
return [defaultRiskRowFromAmount(n), ...next];
});
setSyncOpen(false);
toast.message("已写入本地草稿,记得保存草稿");
}
@@ -279,11 +303,16 @@ export function RiskCapDocScreen() {
const occFiltered = useMemo(() => {
const q = occSearch.trim();
if (!q) {
return draftRows;
return draftRows.filter((row) => !isDefaultRiskRow(row));
}
return draftRows.filter((r) => r.normalized_number.includes(q));
return draftRows.filter((r) => !isDefaultRiskRow(r) && r.normalized_number.includes(q));
}, [draftRows, occSearch]);
const specialRows = useMemo(
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
[draftRows],
);
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
@@ -346,7 +375,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<h3 className="text-sm font-medium"></h3>
<p className="text-sm text-muted-foreground">
稿<strong></strong>
使
</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
@@ -391,7 +420,7 @@ export function RiskCapDocScreen() {
</div>
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
) : draftRows.length === 0 ? (
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="overflow-x-auto rounded-md border">
@@ -407,7 +436,7 @@ export function RiskCapDocScreen() {
</TableRow>
</TableHeader>
<TableBody>
{draftRows.map((r, idx) => (
{specialRows.map(({ row: r, index: idx }) => (
<TableRow key={r.clientKey}>
<TableCell>
{isDraft ? (
@@ -453,7 +482,7 @@ export function RiskCapDocScreen() {
type="button"
variant="ghost"
className="text-destructive"
disabled={saving || draftRows.length <= 1}
disabled={saving}
onClick={() => removeRow(idx)}
>
@@ -535,14 +564,14 @@ export function RiskCapDocScreen() {
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{defaultCapStr || "(空)"}稿稿
{defaultCapStr || "(空)"}稿稿
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
</Button>
<Button type="button" onClick={applyDefaultCapToAll}>
<Button type="button" onClick={applyDefaultCap}>
</Button>
</DialogFooter>