feat: 扩展开奖与结算管理,支持手动操作、导出和版本展示
This commit is contained in:
27
src/modules/config/config-readonly-value.tsx
Normal file
27
src/modules/config/config-readonly-value.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfigReadonlyValueProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
mono?: boolean;
|
||||
};
|
||||
|
||||
export function ConfigReadonlyValue({
|
||||
children,
|
||||
className,
|
||||
mono = false,
|
||||
}: ConfigReadonlyValueProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-h-8 w-full items-center rounded-md border border-transparent bg-slate-50 px-2.5 text-sm text-slate-800",
|
||||
mono && "font-mono tabular-nums",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children ?? "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
81
src/modules/config/config-version-actions.tsx
Normal file
81
src/modules/config/config-version-actions.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, RefreshCw, Rocket, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfigVersionActionsProps = {
|
||||
isDraft: boolean;
|
||||
loadingList?: boolean;
|
||||
loadingDetail?: boolean;
|
||||
saving?: boolean;
|
||||
publishLabel?: string;
|
||||
onRefresh: () => void;
|
||||
onNewDraft: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ConfigVersionActions({
|
||||
isDraft,
|
||||
loadingList = false,
|
||||
loadingDetail = false,
|
||||
saving = false,
|
||||
publishLabel = "启用为当前版本",
|
||||
onRefresh,
|
||||
onNewDraft,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
className,
|
||||
}: ConfigVersionActionsProps) {
|
||||
const draftActionBusy = saving || loadingDetail;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 lg:justify-end", className)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 border-slate-300 bg-white px-3 text-slate-700 hover:bg-slate-50 hover:text-slate-950"
|
||||
disabled={loadingList}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden />
|
||||
{loadingList ? "刷新中" : "刷新版本"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-9 bg-slate-950 px-3 text-white hover:bg-slate-800"
|
||||
disabled={saving}
|
||||
onClick={onNewDraft}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
新建草稿
|
||||
</Button>
|
||||
{isDraft ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 border-amber-300 bg-amber-50 px-3 text-amber-900 hover:bg-amber-100 hover:text-amber-950"
|
||||
disabled={draftActionBusy}
|
||||
onClick={onSaveDraft}
|
||||
>
|
||||
<Save className="size-4" aria-hidden />
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-9 bg-emerald-600 px-3 text-white hover:bg-emerald-700"
|
||||
disabled={draftActionBusy}
|
||||
onClick={onPublish}
|
||||
>
|
||||
<Rocket className="size-4" aria-hidden />
|
||||
{publishLabel}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -59,7 +59,6 @@ export function ConfigVersionSwitcher({
|
||||
selectedId,
|
||||
onSelectedIdChange,
|
||||
loading = false,
|
||||
label = "配置版本",
|
||||
sheetTitle = "切换配置版本",
|
||||
sheetDescription = "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
|
||||
className,
|
||||
@@ -128,42 +127,39 @@ export function ConfigVersionSwitcher({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex w-full flex-col gap-2 sm:w-auto sm:min-w-[280px]", className)}>
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{selectedVersion ? (
|
||||
<>
|
||||
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground">
|
||||
v{selectedVersion.version_no}
|
||||
</span>
|
||||
<ConfigStatusBadge status={selectedVersion.status} />
|
||||
<span className="text-sm text-muted-foreground">#{selectedVersion.id}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{loading ? "加载中…" : "未选择版本"}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={loading || sortedVersions.length === 0}
|
||||
onClick={() => setSheetOpen(true)}
|
||||
>
|
||||
切换版本
|
||||
</Button>
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{selectedVersion ? (
|
||||
<>
|
||||
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground">
|
||||
v{selectedVersion.version_no}
|
||||
</span>
|
||||
<ConfigStatusBadge status={selectedVersion.status} />
|
||||
<span className="text-sm text-muted-foreground">#{selectedVersion.id}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{loading ? "加载中…" : "未选择版本"}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={loading || sortedVersions.length === 0}
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="h-9 shrink-0 border-slate-300 bg-white px-3 text-slate-800 hover:bg-slate-50 hover:text-slate-950"
|
||||
>
|
||||
<Layers className="size-4" aria-hidden />
|
||||
切换版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col overflow-hidden border-l-0 bg-[#f6f7fb] p-0 shadow-[-24px_0_80px_rgba(15,23,42,0.22)] sm:max-w-[430px]"
|
||||
className="flex flex-col overflow-hidden border-l bg-slate-50 p-0 shadow-xl sm:max-w-[430px]"
|
||||
>
|
||||
<div className="relative overflow-hidden border-b border-slate-200/80 bg-white px-5 pb-4 pt-5">
|
||||
<div className="pointer-events-none absolute -right-10 -top-12 size-32 rounded-full bg-amber-200/45 blur-2xl" />
|
||||
<div className="pointer-events-none absolute right-14 top-7 size-16 rounded-full bg-emerald-200/50 blur-xl" />
|
||||
<SheetHeader className="relative space-y-2 text-left">
|
||||
<div className="border-b bg-white px-5 pb-4 pt-5">
|
||||
<SheetHeader className="space-y-2 text-left">
|
||||
<SheetTitle className="text-[17px] font-semibold tracking-tight text-slate-950">
|
||||
{sheetTitle}
|
||||
</SheetTitle>
|
||||
@@ -172,13 +168,10 @@ export function ConfigVersionSwitcher({
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</div>
|
||||
<div className="border-b border-slate-200/80 bg-white/80 px-4 py-3 backdrop-blur">
|
||||
<div className="border-b bg-white px-4 py-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{statusCounts.map((s) => (
|
||||
<div
|
||||
key={s.status}
|
||||
className="rounded-2xl border border-slate-200 bg-white px-3 py-2 shadow-[0_6px_18px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div key={s.status} className="rounded-xl border bg-white px-3 py-2">
|
||||
<p className="text-[11px] font-medium text-slate-500">{s.label}</p>
|
||||
<p className="mt-0.5 text-lg font-semibold tabular-nums text-slate-950">
|
||||
{s.count}
|
||||
@@ -187,7 +180,7 @@ export function ConfigVersionSwitcher({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-5">
|
||||
<div className="flex-1 overflow-auto space-y-5 px-4 py-4">
|
||||
{sortedVersions.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-200 bg-white/80 p-5 text-center text-sm text-slate-500 shadow-none">
|
||||
暂无版本记录。
|
||||
@@ -225,15 +218,14 @@ export function ConfigVersionSwitcher({
|
||||
<Card
|
||||
key={v.id}
|
||||
className={cn(
|
||||
"group overflow-hidden rounded-3xl border border-slate-200/90 bg-white p-0 shadow-[0_12px_34px_rgba(15,23,42,0.06)] transition-all duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-[0_18px_42px_rgba(15,23,42,0.1)]",
|
||||
isCurrent &&
|
||||
"border-amber-300 bg-gradient-to-br from-amber-50 via-white to-white shadow-[0_18px_48px_rgba(245,158,11,0.16)] ring-1 ring-amber-200/70",
|
||||
"group overflow-hidden rounded-2xl border bg-white p-0 shadow-none transition-colors hover:border-slate-300",
|
||||
isCurrent && "border-slate-900 ring-1 ring-slate-900/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-3 p-3.5">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 h-auto w-1.5 shrink-0 rounded-full bg-slate-200",
|
||||
"mt-1 h-auto w-1 shrink-0 rounded-full bg-slate-200",
|
||||
v.status === "draft" && "bg-amber-300",
|
||||
v.status === "active" && "bg-emerald-400",
|
||||
v.status === "archived" && "bg-slate-300",
|
||||
|
||||
@@ -33,8 +33,8 @@ export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<aside className="shrink-0 lg:sticky lg:top-4 lg:w-56 lg:self-start">
|
||||
<div className="rounded-2xl border border-border/70 bg-card/80 p-3 shadow-sm backdrop-blur lg:max-h-[calc(100vh-2rem)] lg:overflow-auto">
|
||||
<aside className="shrink-0 lg:sticky lg:top-20 lg:h-[calc(100vh-6rem)] lg:w-56 lg:self-start">
|
||||
<div className="h-full rounded-2xl border border-border/70 bg-card/80 p-3 shadow-sm backdrop-blur lg:overflow-auto">
|
||||
<div className="mb-3 px-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
运营配置导航
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user