feat: 增加管理端多语言与风控/报表/奖池操作能力
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user