feat: 扩展开奖与结算管理,支持手动操作、导出和版本展示

This commit is contained in:
2026-05-16 18:00:57 +08:00
parent 34f9175304
commit fae8c1ae01
21 changed files with 1148 additions and 410 deletions

View File

@@ -25,6 +25,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { cn } from "@/lib/utils";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -174,7 +176,13 @@ export function OddsConfigDocScreen() {
return filteredTypes[0].play_code;
}, [filteredTypes, playCode]);
const isDraft = detail?.status === "draft";
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const scopeRows = useMemo(() => {
const rows: Partial<Record<PrizeScopeCode, OddsItemRow>> = {};
@@ -375,6 +383,12 @@ export function OddsConfigDocScreen() {
key={t.play_code}
type="button"
variant={resolvedPlayCode === t.play_code ? "secondary" : "outline"}
className={cn(
"h-9 border-slate-300 px-5 text-[18px] font-medium",
resolvedPlayCode === t.play_code
? "border-slate-950 bg-slate-950 text-white shadow-sm hover:bg-slate-900"
: "bg-white text-slate-900 hover:border-slate-400 hover:bg-slate-50",
)}
onClick={() => setPlayCode(t.play_code)}
>
{t.display_name_zh ?? t.play_code}
@@ -384,53 +398,49 @@ export function OddsConfigDocScreen() {
</div>
</div>
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="赔率配置版本"
sheetDescription="选择版本在本页查看;非草稿版本可回滚为新建草稿。"
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
/>
<div className="rounded-xl border bg-muted/20 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="赔率配置版本"
sheetDescription="选择版本在本页查看;非草稿版本可回滚为新建草稿。"
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
className="lg:flex-1"
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
disabled={loadingList}
onClick={() => void refreshList()}
>
</Button>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
/>
</div>
</div>
{detail ? (
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm space-y-1">
<p>
<span className="text-muted-foreground"></span>v{detail.version_no} ·{" "}
{detail.status === "active" ? "生效中" : detail.status === "draft" ? "草稿" : "已归档"}
</p>
<p>
<span className="text-muted-foreground"></span>
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
</p>
<p className="text-sm text-muted-foreground">
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
{!isDraft ? (
<p className="text-amber-600 dark:text-amber-400">稿</p>
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</div>
</p>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
@@ -453,18 +463,24 @@ export function OddsConfigDocScreen() {
</Label>
{row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2">
<Input
type="number"
min={0}
className="h-9 font-mono tabular-nums max-w-[200px]"
disabled={!isDraft || saving}
value={row.odds_value}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-9 max-w-[200px] font-mono tabular-nums"
disabled={saving}
value={row.odds_value}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
) : (
<ConfigReadonlyValue mono className="max-w-[200px]">
{row.odds_value}
</ConfigReadonlyValue>
)}
<span className="text-sm text-muted-foreground tabular-nums">
×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
</span>
@@ -477,33 +493,25 @@ export function OddsConfigDocScreen() {
})}
<div className="grid gap-1 pt-2 border-t">
<Label>%</Label>
<Input
type="number"
step="0.01"
min={0}
className="h-9 font-mono tabular-nums max-w-[200px]"
disabled={!isDraft || saving}
value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
{isDraft ? (
<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>
)}
<p className="text-sm text-muted-foreground"> rebate_rate</p>
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
</Button>
<Button
type="button"
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
onClick={() => void handlePublish()}
disabled={!isDraft || saving || loadingDetail}
>
</Button>
</div>
</CardContent>
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -33,6 +33,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -96,7 +98,9 @@ export function PlayConfigDocScreen() {
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [creatingDraftId, setCreatingDraftId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const detailRequestSeq = useRef(0);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
@@ -108,6 +112,9 @@ export function PlayConfigDocScreen() {
try {
const d = await getAllConfigVersions(getPlayConfigVersions);
setList(d.items);
setCreatingDraftId((draftId) =>
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
setError(msg);
@@ -124,33 +131,58 @@ export function PlayConfigDocScreen() {
}, [refreshList]);
const loadDetail = useCallback(async (id: number) => {
const requestSeq = detailRequestSeq.current + 1;
detailRequestSeq.current = requestSeq;
setLoadingDetail(true);
setDetail(null);
setDraftRows([]);
try {
const d = await getPlayConfigVersion(id);
if (detailRequestSeq.current !== requestSeq) {
return;
}
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
if (detailRequestSeq.current !== requestSeq) {
return;
}
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
if (detailRequestSeq.current === requestSeq) {
setLoadingDetail(false);
}
}
}, []);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
if (list.length === 0) {
if (selectedId !== "") {
queueMicrotask(() => {
setSelectedId("");
setDetail(null);
setDraftRows([]);
});
}
return;
}
if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) {
return;
}
if (creatingDraftId !== null && selectedId === creatingDraftId) {
return;
}
queueMicrotask(() => {
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const active = list.find((x) => x.status === "active");
const pick = drafts[0] ?? active ?? list.sort((a, b) => b.id - a.id)[0];
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const pick = active ?? drafts[0] ?? [...list].sort((a, b) => b.id - a.id)[0];
if (pick) {
setSelectedId(String(pick.id));
}
});
}, [list, selectedId]);
}, [list, selectedId, creatingDraftId]);
useEffect(() => {
if (selectedId === "") {
@@ -165,7 +197,13 @@ export function PlayConfigDocScreen() {
});
}, [selectedId, loadDetail]);
const isDraft = detail?.status === "draft";
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const orderedRows = useMemo(
() =>
@@ -232,10 +270,11 @@ export function PlayConfigDocScreen() {
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
await refreshList();
setCreatingDraftId(String(d.id));
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
} finally {
@@ -291,25 +330,16 @@ export function PlayConfigDocScreen() {
className="lg:flex-1"
/>
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loadingList}>
</Button>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<Button type="button" onClick={() => void handleSaveDraft()} disabled={!isDraft || saving || loadingDetail}>
稿
</Button>
<Button
type="button"
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
onClick={() => void handlePublish()}
disabled={!isDraft || saving || loadingDetail}
>
</Button>
</div>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() => void handlePublish()}
/>
</div>
</div>
@@ -339,94 +369,128 @@ export function PlayConfigDocScreen() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="min-w-[120px] text-center"></TableHead>
<TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[140px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orderedRows.map((row) => (
<TableRow key={row.play_code}>
<TableCell className="font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center">
<Checkbox
checked={row.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${row.play_code}`}
/>
{isDraft ? (
<Checkbox
checked={row.is_enabled}
disabled={saving}
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${row.play_code}`}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{row.is_enabled ? "启用" : "停用"}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
className="h-8 text-sm"
value={row.display_name_zh ?? ""}
disabled={saving}
onChange={(e) => {
const next = e.target.value === "" ? null : e.target.value;
updateConfigRow(row.play_code, { display_name_zh: next });
}}
/>
{isDraft ? (
<Input
className="h-8 text-center text-sm"
value={row.display_name_zh ?? ""}
disabled={saving}
onChange={(e) => {
const next = e.target.value === "" ? null : e.target.value;
updateConfigRow(row.play_code, { display_name_zh: next });
}}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{row.display_name_zh ?? "—"}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="w-[96px]">
<Input
type="text"
inputMode="numeric"
className="h-8 w-16 font-mono tabular-nums text-center"
value={row.display_order}
disabled={saving}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n)) {
updateConfigRow(row.play_code, { display_order: n });
<TableCell className="w-[96px] text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-8 w-16 font-mono tabular-nums text-center"
value={row.display_order}
disabled={saving}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n)) {
updateConfigRow(row.play_code, { display_order: n });
}
}}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.display_order}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-8 text-center font-mono tabular-nums"
disabled={saving}
value={row.min_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
}}
/>
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.min_bet_amount}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
type="text"
inputMode="numeric"
className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving}
value={row.min_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
min_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
<TableCell className="text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="h-8 text-center font-mono tabular-nums"
disabled={saving}
value={row.max_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.max_bet_amount}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
type="text"
inputMode="numeric"
className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving}
value={row.max_bet_amount}
onChange={(e) =>
updateConfigRow(row.play_code, {
max_bet_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
</TableCell>
<TableCell>
<Button
type="button"
variant="outline"
disabled={!isDraft || saving}
onClick={() => openRuleEditor(row.play_code)}
>
</Button>
<TableCell className="text-center">
{isDraft ? (
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => openRuleEditor(row.play_code)}
>
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}

View File

@@ -13,11 +13,12 @@ import {
publishOddsVersion,
putOddsItems,
} from "@/api/admin-config";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -147,7 +148,13 @@ export function RebateConfigDocScreen() {
return m;
}, [types]);
const isDraft = detail?.status === "draft";
const selectedVersionSummary = useMemo(
() => listRows.find((x) => String(x.id) === selectedId) ?? null,
[listRows, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] {
const r2 = Number.parseFloat(p2);
@@ -261,82 +268,89 @@ export function RebateConfigDocScreen() {
<CardHeader className="space-y-1">
<CardTitle className="text-lg"> / </CardTitle>
</CardHeader>
<CardContent className="space-y-6 max-w-xl">
<ConfigVersionSwitcher
versions={listRows}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle="回水配置版本"
sheetDescription="回水写入赔率版本草稿;选择与赔率配置共用同一套版本"
onDeleteVersion={handleDeleteVersion}
/>
<CardContent className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
<ConfigVersionSwitcher
versions={listRows}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle="回水配置版本"
sheetDescription="回水写入赔率版本草稿;选择与赔率配置共用同一套版本。"
onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0"
/>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => void refreshList()} disabled={loading}>
</Button>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
稿
</Button>
<Button
type="button"
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
onClick={() => void handlePublish()}
disabled={!isDraft || saving || loadingDetail}
>
</Button>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loading}
loadingDetail={loadingDetail}
saving={saving}
publishLabel="发布生效"
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
/>
{detail ? (
<p className="text-sm text-muted-foreground">
v{detail.version_no} · {detail.status === "draft" ? "草稿" : detail.status === "active" ? "生效中" : "已归档"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</p>
) : null}
</div>
{detail ? (
<p className="text-sm text-muted-foreground">
v{detail.version_no} · {detail.status === "draft" ? "草稿" : detail.status === "active" ? "生效中" : "已归档"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label>2D %</Label>
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={!isDraft || saving}
value={p2}
onChange={(e) => setP2(e.target.value)}
/>
{isDraft ? (
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={saving}
value={p2}
onChange={(e) => setP2(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p2}</ConfigReadonlyValue>
)}
</div>
<div className="grid gap-2">
<Label>3D %</Label>
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={!isDraft || saving}
value={p3}
onChange={(e) => setP3(e.target.value)}
/>
{isDraft ? (
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={saving}
value={p3}
onChange={(e) => setP3(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p3}</ConfigReadonlyValue>
)}
</div>
<div className="grid gap-2">
<Label>4D %</Label>
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={!isDraft || saving}
value={p4}
onChange={(e) => setP4(e.target.value)}
/>
{isDraft ? (
<Input
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
disabled={saving}
value={p4}
onChange={(e) => setP4(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p4}</ConfigReadonlyValue>
)}
</div>
</div>

View File

@@ -33,6 +33,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -150,7 +152,13 @@ export function RiskCapDocScreen() {
});
}, [selectedId, loadDetail]);
const isDraft = detail?.status === "draft";
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const updateRow = (idx: number, patch: Partial<DraftRiskRow>) => {
setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
@@ -301,46 +309,40 @@ export function RiskCapDocScreen() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="风控封顶版本"
onDeleteVersion={handleDeleteVersion}
/>
<div className="flex flex-wrap items-center gap-3">
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="风控封顶版本"
onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0"
/>
<div className="flex flex-wrap items-end gap-4">
<Button type="button" variant="secondary" onClick={() => void refreshList()}>
</Button>
<Button type="button" onClick={() => void handleNewDraft()} disabled={saving}>
稿
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={!isDraft || saving || loadingDetail}>
稿
</Button>
<Button
type="button"
className="bg-emerald-600 text-white hover:bg-emerald-600/90"
onClick={() => void handlePublish()}
disabled={!isDraft || saving || loadingDetail}
>
</Button>
<ConfigVersionActions
isDraft={isDraft}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
onPublish={() => void handlePublish()}
/>
{detail ? (
<p className="text-sm text-muted-foreground">
{detail.effective_at ? formatDt(detail.effective_at) : "—"} · {detail.reason ?? "—"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</p>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{detail ? (
<p className="text-sm text-muted-foreground">
{detail.effective_at ? formatDt(detail.effective_at) : "—"} · {detail.reason ?? "—"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
) : null}
</p>
) : null}
<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">
@@ -349,33 +351,43 @@ export function RiskCapDocScreen() {
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap"></Label>
<Input
id="default-cap"
type="number"
min={0}
className="w-[220px] font-mono tabular-nums"
disabled={!isDraft || saving}
value={defaultCapStr}
onChange={(e) => setDefaultCapStr(e.target.value)}
/>
{isDraft ? (
<Input
id="default-cap"
type="number"
min={0}
className="w-[220px] font-mono tabular-nums"
disabled={saving}
value={defaultCapStr}
onChange={(e) => setDefaultCapStr(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="w-[220px]">
{defaultCapStr || "—"}
</ConfigReadonlyValue>
)}
</div>
<Button type="button" variant="secondary" disabled={!isDraft || saving} onClick={() => setSyncOpen(true)}>
</Button>
{isDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
</Button>
) : null}
</div>
</section>
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium"></h3>
<Button
type="button"
variant="outline"
disabled={!isDraft || saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
+
</Button>
{isDraft ? (
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
+
</Button>
) : null}
</div>
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
@@ -398,45 +410,57 @@ export function RiskCapDocScreen() {
{draftRows.map((r, idx) => (
<TableRow key={r.clientKey}>
<TableCell>
<Input
className="h-8 font-mono tabular-nums"
maxLength={4}
disabled={!isDraft || saving}
value={r.normalized_number}
onChange={(e) =>
updateRow(idx, {
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
/>
{isDraft ? (
<Input
className="h-8 font-mono tabular-nums"
maxLength={4}
disabled={saving}
value={r.normalized_number}
onChange={(e) =>
updateRow(idx, {
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
/>
) : (
<ConfigReadonlyValue mono>{r.normalized_number}</ConfigReadonlyValue>
)}
</TableCell>
<TableCell>
<Input
type="number"
min={0}
className="h-8 font-mono tabular-nums"
disabled={!isDraft || saving}
value={r.cap_amount}
onChange={(e) =>
updateRow(idx, {
cap_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
{isDraft ? (
<Input
type="number"
min={0}
className="h-8 font-mono tabular-nums"
disabled={saving}
value={r.cap_amount}
onChange={(e) =>
updateRow(idx, {
cap_amount: Number.parseInt(e.target.value, 10) || 0,
})
}
/>
) : (
<ConfigReadonlyValue mono>{r.cap_amount}</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-center text-muted-foreground text-sm"></TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
className="text-destructive"
disabled={!isDraft || saving || draftRows.length <= 1}
onClick={() => removeRow(idx)}
>
</Button>
{isDraft ? (
<Button
type="button"
variant="ghost"
className="text-destructive"
disabled={saving || draftRows.length <= 1}
onClick={() => removeRow(idx)}
>
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}