feat: 增加管理端多语言与多模块界面国际化支持

This commit is contained in:
2026-05-19 09:11:55 +08:00
parent 49a4caf01e
commit 1b1dfc92ab
110 changed files with 4053 additions and 1308 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -63,6 +64,7 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
}
export function OddsConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -76,7 +78,7 @@ export function OddsConfigDocScreen() {
const [error, setError] = useState<string | null>(null);
const [catTab, setCatTab] = useState<CatTab>("all");
/** 用户点选的玩法;空字符串表示尚未选择,由 resolvedPlayCode 回落到分类内第一项 */
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
const [playCode, setPlayCode] = useState<string>("");
const [rollbackOpen, setRollbackOpen] = useState(false);
@@ -90,12 +92,12 @@ export function OddsConfigDocScreen() {
const d = await getAdminPlayTypes();
setTypes(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setTypes([]);
} finally {
setLoadingTypes(false);
}
}, []);
}, [t]);
const refreshList = useCallback(async () => {
setLoadingList(true);
@@ -104,13 +106,13 @@ export function OddsConfigDocScreen() {
const d = await getAllConfigVersions(getOddsVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -126,13 +128,13 @@ export function OddsConfigDocScreen() {
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
}, [t]);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
@@ -255,10 +257,10 @@ export function OddsConfigDocScreen() {
const d = await putOddsItems(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -273,11 +275,11 @@ export function OddsConfigDocScreen() {
const d = await publishOddsVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已启用为当前版本");
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -309,13 +311,13 @@ export function OddsConfigDocScreen() {
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -331,7 +333,7 @@ export function OddsConfigDocScreen() {
reason: `rollback from v${rollbackTarget.version_no}`,
clone_from_version_id: rollbackTarget.id,
});
toast.success(`已自 v${rollbackTarget.version_no} 克隆为新草稿 v${d.version_no}`);
toast.success(`Cloned v${rollbackTarget.version_no} into new draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
@@ -339,7 +341,7 @@ export function OddsConfigDocScreen() {
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "回滚失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Rollback failed");
} finally {
setSaving(false);
}
@@ -350,10 +352,10 @@ export function OddsConfigDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -383,7 +385,7 @@ export function OddsConfigDocScreen() {
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "all", label: "All" },
{ id: "d4", label: "4D" },
{ id: "d3", label: "3D" },
{ id: "d2", label: "2D" },
@@ -393,11 +395,11 @@ export function OddsConfigDocScreen() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("nav.items.odds", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap gap-2">
<span className="text-base text-muted-foreground self-center mr-2"></span>
<span className="text-base text-muted-foreground self-center mr-2">Category</span>
{catTabs.map((t) => (
<Button
key={t.id}
@@ -412,10 +414,10 @@ export function OddsConfigDocScreen() {
</div>
<div className="space-y-2 min-h-[96px]">
<p className="text-base text-muted-foreground"></p>
<p className="text-base text-muted-foreground">Play Type</p>
<div className="flex flex-wrap gap-2 min-h-[44px]">
{filteredTypes.length === 0 ? (
<span className="text-base text-muted-foreground"></span>
<span className="text-base text-muted-foreground">No play types in this category.</span>
) : (
filteredTypes.map((t) => (
<Button
@@ -444,8 +446,8 @@ export function OddsConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="赔率配置版本"
sheetDescription="选择版本在本页查看;非草稿版本可回滚为新建草稿。"
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription="Choose a version to view here. Non-draft versions can be rolled back into a new draft."
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
@@ -467,7 +469,7 @@ export function OddsConfigDocScreen() {
{detail ? (
<p className="text-sm text-muted-foreground">
Active version:
{activeHead ? (
<>
v{activeHead.version_no}
@@ -477,7 +479,7 @@ export function OddsConfigDocScreen() {
"—"
)}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
<span className="text-amber-600 dark:text-amber-400"> - This version is read-only. Create a draft before editing odds.</span>
) : null}
</p>
) : null}
@@ -486,7 +488,7 @@ export function OddsConfigDocScreen() {
{loadingDetail || loadingTypes ? (
<div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground"></p>
<p className="text-base text-muted-foreground">Loading details</p>
</div>
) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md">
@@ -521,17 +523,17 @@ export function OddsConfigDocScreen() {
</ConfigReadonlyValue>
)}
<span className="text-sm text-muted-foreground tabular-nums">
×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
Multiplier x{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
</span>
</div>
) : (
<p className="text-sm text-destructive"> {scope} </p>
<p className="text-sm text-destructive">Missing {scope} row. Check seed or version data.</p>
)}
</div>
);
})}
<div className="grid gap-1 pt-2 border-t">
<Label>%</Label>
<Label>Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="text"
@@ -546,7 +548,7 @@ export function OddsConfigDocScreen() {
{rebatePercentUi}
</ConfigReadonlyValue>
)}
<p className="text-sm text-muted-foreground"> rebate_rate</p>
<p className="text-sm text-muted-foreground">Writes rebate_rate to all prize scopes under this play type.</p>
</div>
</div>
) : null}
@@ -556,17 +558,17 @@ export function OddsConfigDocScreen() {
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Confirm rollback</DialogTitle>
<DialogDescription>
v{rollbackTarget?.version_no} 稿线
A new draft will be cloned from version v{rollbackTarget?.version_no}. The active version will not be overwritten directly.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
Confirm rollback
</Button>
</DialogFooter>
</DialogContent>
@@ -575,16 +577,16 @@ export function OddsConfigDocScreen() {
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Publish odds version?</DialogTitle>
<DialogDescription>
New odds affect new tickets immediately. Existing successful tickets still settle by their saved odds snapshot.
</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>
<span>Prize Scope</span>
<span className="text-right">Current Active</span>
<span className="text-right">After Publish</span>
</div>
{publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
@@ -598,7 +600,7 @@ export function OddsConfigDocScreen() {
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPublishConfirmOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button
type="button"
@@ -608,7 +610,7 @@ export function OddsConfigDocScreen() {
void handlePublish();
}}
>
Confirm publish
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -65,49 +66,41 @@ type PlayConfigSaveItemPayload = {
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:直接按当前草稿快照落库。 */
/** Save payload for play-config drafts. Persist the current draft snapshot directly. */
function buildPlayConfigSavePayload(
draftRows: PlayConfigItemRow[],
): PlayConfigSaveItemPayload[] {
@@ -135,6 +128,7 @@ function buildPlayConfigSavePayload(
}
export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
@@ -160,13 +154,13 @@ export function PlayConfigDocScreen() {
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -191,7 +185,7 @@ export function PlayConfigDocScreen() {
if (detailRequestSeq.current !== requestSeq) {
return;
}
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
@@ -199,7 +193,7 @@ export function PlayConfigDocScreen() {
setLoadingDetail(false);
}
}
}, []);
}, [t]);
useEffect(() => {
if (list.length === 0) {
@@ -274,12 +268,13 @@ export function PlayConfigDocScreen() {
const enabledCount = rows.filter((row) => row.is_enabled).length;
return {
...group,
label: t(`play.batchGroups.${group.key}`, { ns: "config", defaultValue: group.key }),
total: rows.length,
enabledCount,
allEnabled: rows.length > 0 && enabledCount === rows.length,
};
}),
[draftRows],
[draftRows, t],
);
async function handleSaveDraft() {
@@ -289,7 +284,7 @@ export function PlayConfigDocScreen() {
const payload = buildPlayConfigSavePayload(draftRows);
for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: 最小额不能大于最大额`);
toast.error(`${r.play_code}: min_bet_amount cannot exceed max_bet_amount`);
return;
}
}
@@ -298,10 +293,10 @@ export function PlayConfigDocScreen() {
const d = await putPlayConfigItems(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -316,11 +311,11 @@ export function PlayConfigDocScreen() {
const d = await publishPlayConfigVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已启用为当前版本");
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -334,14 +329,14 @@ export function PlayConfigDocScreen() {
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
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 : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -361,7 +356,7 @@ export function PlayConfigDocScreen() {
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
setRuleDialogOpen(false);
setRulePlayCode(null);
toast.message("规则说明已写入本地草稿,记得保存草稿");
toast.message("Rule text saved into the local draft. Save the draft to persist it.");
}
const activeHead = list.find((x) => x.status === "active");
@@ -369,10 +364,10 @@ export function PlayConfigDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deletePlayConfigVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -380,7 +375,7 @@ export function PlayConfigDocScreen() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("nav.items.plays", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border bg-muted/20 p-3">
@@ -390,7 +385,7 @@ export function PlayConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="玩法配置版本"
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
className="lg:flex-1"
/>
@@ -412,14 +407,14 @@ export function PlayConfigDocScreen() {
<p className="text-sm text-muted-foreground">
{activeHead ? (
<>
线 v{activeHead.version_no}
Active version v{activeHead.version_no}
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
</>
) : null}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400">
{activeHead ? " — " : ""}
稿
Limits and rules are read-only. Create a draft first.
</span>
) : null}
</p>
@@ -429,14 +424,14 @@ export function PlayConfigDocScreen() {
<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-sm font-medium">Batch switches</p>
<p className="text-xs text-muted-foreground">
稿
Only updates the current draft. The player betting table refreshes after save and publish.
</p>
</div>
{!isDraft ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
稿
Current version is read-only. Create a draft first.
</span>
) : null}
</div>
@@ -449,7 +444,7 @@ export function PlayConfigDocScreen() {
<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} 启用` : "暂无玩法"}
{group.total > 0 ? `${group.enabledCount}/${group.total} enabled` : "No play types"}
</p>
</div>
<Button
@@ -459,7 +454,7 @@ export function PlayConfigDocScreen() {
disabled={!isDraft || saving || group.total === 0}
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
>
{group.allEnabled ? "关闭" : "开启"}
{group.allEnabled ? "Disable" : "Enable"}
</Button>
</div>
))}
@@ -470,20 +465,20 @@ export function PlayConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[88px] text-center"></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>
<TableHead className="text-center">Play Code</TableHead>
<TableHead className="w-[100px] text-center">Category</TableHead>
<TableHead className="w-[88px] text-center">Status</TableHead>
<TableHead className="min-w-[120px] text-center">Display Name</TableHead>
<TableHead className="w-[120px] text-center">Order</TableHead>
<TableHead className="w-[110px] text-center">Min Bet</TableHead>
<TableHead className="w-[110px] text-center">Max Bet</TableHead>
<TableHead className="w-[140px] text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -499,11 +494,11 @@ export function PlayConfigDocScreen() {
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${row.play_code}`}
aria-label={`Enable ${row.play_code}`}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{row.is_enabled ? "启用" : "停用"}
{row.is_enabled ? "Enabled" : "Disabled"}
</ConfigReadonlyValue>
)}
</TableCell>
@@ -593,10 +588,10 @@ export function PlayConfigDocScreen() {
disabled={saving}
onClick={() => openRuleEditor(row.play_code)}
>
Rule Text
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">Read only</span>
)}
</TableCell>
</TableRow>
@@ -610,9 +605,9 @@ export function PlayConfigDocScreen() {
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Rule Text (Chinese)</DialogTitle>
<DialogDescription>
{rulePlayCode ?? "—"}稿稿
Play {rulePlayCode ?? "—"}; changes are only stored in the draft until you save and publish it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
@@ -626,10 +621,10 @@ export function PlayConfigDocScreen() {
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={saveRuleZh}>
稿
Apply to Draft
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,4 +1,4 @@
/** 奖项档位顺序(含 starter / consolation)。 */
/** Prize scope order, including starter and consolation. */
export const PRIZE_SCOPE_ORDER = [
"first",
@@ -11,14 +11,14 @@ export const PRIZE_SCOPE_ORDER = [
export type PrizeScopeCode = (typeof PRIZE_SCOPE_ORDER)[number];
export const PRIZE_SCOPE_LABELS: Record<PrizeScopeCode, string> = {
first: "头奖赔率",
second: "二奖赔率",
third: "三奖赔率",
starter: "特别奖赔率",
consolation: "安慰奖赔率",
first: "First Prize Odds",
second: "Second Prize Odds",
third: "Third Prize Odds",
starter: "Starter Prize Odds",
consolation: "Consolation Prize Odds",
};
/** 文档示意:特别奖 / 安慰奖按组数展示时的倍数提示(仅文案)。 */
/** Display-only multiplier hints for starter and consolation grouped prizes. */
export const PRIZE_SCOPE_MULTIPLIER_HINT: Partial<Record<PrizeScopeCode, string>> = {
starter: "× 10",
consolation: "× 10",

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -47,6 +48,7 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
}
export function RebateConfigDocScreen() {
const { t } = useTranslation(["config", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
@@ -67,20 +69,20 @@ export function RebateConfigDocScreen() {
const d = await getAdminPlayTypes();
setTypes(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setTypes([]);
}
}, []);
}, [t]);
const refreshList = useCallback(async () => {
try {
const d = await getAllConfigVersions(getOddsVersions);
setListRows(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setListRows([]);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(async () => {
@@ -105,13 +107,13 @@ export function RebateConfigDocScreen() {
setP3(inferPercentFrom(3, rows, typeList));
setP4(inferPercentFrom(4, rows, typeList));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
}, [t]);
useEffect(() => {
if (listRows.length === 0 || selectedId !== "") {
@@ -194,10 +196,10 @@ export function RebateConfigDocScreen() {
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -216,11 +218,11 @@ export function RebateConfigDocScreen() {
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
toast.success("已发布赔率版本(含回水)");
toast.success("Published odds version with rebate");
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -234,7 +236,7 @@ export function RebateConfigDocScreen() {
reason: `rebate draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
@@ -244,7 +246,7 @@ export function RebateConfigDocScreen() {
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -255,10 +257,10 @@ export function RebateConfigDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -266,7 +268,7 @@ export function RebateConfigDocScreen() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"> / </CardTitle>
<CardTitle className="text-lg">{t("nav.items.rebate", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
@@ -275,8 +277,8 @@ export function RebateConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle="回水配置版本"
sheetDescription="回水写入赔率版本草稿;选择与赔率配置共用同一套版本。"
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription="Rebate is stored in the odds draft version and shares the same version set as odds."
onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0"
/>
@@ -286,7 +288,7 @@ export function RebateConfigDocScreen() {
loadingList={loading}
loadingDetail={loadingDetail}
saving={saving}
publishLabel="发布生效"
publishLabel="Publish"
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
@@ -295,9 +297,9 @@ export function RebateConfigDocScreen() {
{detail ? (
<p className="text-sm text-muted-foreground">
v{detail.version_no} · {detail.status === "draft" ? "草稿" : detail.status === "active" ? "生效中" : "已归档"}
Editing version v{detail.version_no} · {detail.status === "draft" ? "Draft" : detail.status === "active" ? "Active" : "Archived"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
<span className="text-amber-600 dark:text-amber-400"> - Create a draft before editing rebate.</span>
) : null}
</p>
) : null}
@@ -305,7 +307,7 @@ export function RebateConfigDocScreen() {
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label>2D %</Label>
<Label>2D Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="number"
@@ -321,7 +323,7 @@ export function RebateConfigDocScreen() {
)}
</div>
<div className="grid gap-2">
<Label>3D %</Label>
<Label>3D Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="number"
@@ -337,7 +339,7 @@ export function RebateConfigDocScreen() {
)}
</div>
<div className="grid gap-2">
<Label>4D %</Label>
<Label>4D Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="number"
@@ -355,26 +357,26 @@ export function RebateConfigDocScreen() {
</div>
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="中奖是否享受回水" />
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="Apply rebate on winning tickets" />
<div className="grid gap-1">
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
Apply rebate on winning tickets
</Label>
<p className="text-sm text-muted-foreground">
/
Placeholder field. It can later be aligned with risk and settlement rules and persisted.
</p>
</div>
</div>
<div className="grid gap-1 text-sm">
<span className="text-muted-foreground">线</span>
<span className="text-muted-foreground">Effective Time (current active odds version)</span>
<span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span>
</div>
{loading || loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
</CardContent>
</Card>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -70,6 +71,7 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
}
export function RiskCapDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
@@ -92,13 +94,13 @@ export function RiskCapDocScreen() {
const d = await getAllConfigVersions(getRiskCapVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -130,14 +132,14 @@ export function RiskCapDocScreen() {
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
}, [t]);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
@@ -187,19 +189,19 @@ export function RiskCapDocScreen() {
return;
}
if (draftRows.length === 0) {
toast.error("至少保留一行封顶配置");
toast.error("At least one cap row is required");
return;
}
for (const r of draftRows) {
if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) {
toast.error("默认封顶金额必须大于 0");
toast.error("Default cap amount must be greater than 0");
return;
}
continue;
}
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
toast.error(`Number must be 4 digits: ${r.normalized_number}`);
return;
}
}
@@ -222,10 +224,10 @@ export function RiskCapDocScreen() {
}));
setDraftRows(saved);
syncDefaultCapFromRows(saved);
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -248,11 +250,11 @@ export function RiskCapDocScreen() {
}));
setDraftRows(pub);
syncDefaultCapFromRows(pub);
toast.success("已启用为当前版本");
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -266,7 +268,7 @@ export function RiskCapDocScreen() {
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
@@ -280,7 +282,7 @@ export function RiskCapDocScreen() {
setDraftRows(nd);
syncDefaultCapFromRows(nd);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -289,7 +291,7 @@ export function RiskCapDocScreen() {
function applyDefaultCap() {
const n = Number.parseInt(defaultCapStr, 10);
if (!Number.isFinite(n) || n <= 0) {
toast.error("请输入有效的封顶金额");
toast.error("Enter a valid cap amount");
return;
}
setDraftRows((prev) => {
@@ -297,7 +299,7 @@ export function RiskCapDocScreen() {
return [defaultRiskRowFromAmount(n), ...next];
});
setSyncOpen(false);
toast.message("已写入本地草稿,记得保存草稿");
toast.message("Saved into local draft. Save the draft to persist it.");
}
const occFiltered = useMemo(() => {
@@ -316,10 +318,10 @@ export function RiskCapDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -328,11 +330,11 @@ export function RiskCapDocScreen() {
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg">
{t("nav.items.risk-cap", { ns: "config" })}
{detail ? (
<span className="text-muted-foreground font-normal">
{" "}
· v{detail.version_no}
· v{detail.version_no}
</span>
) : null}
</CardTitle>
@@ -344,7 +346,7 @@ export function RiskCapDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="风控封顶版本"
sheetTitle={`${t("nav.items.risk-cap", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0"
/>
@@ -362,9 +364,9 @@ export function RiskCapDocScreen() {
{detail ? (
<p className="text-sm text-muted-foreground">
{detail.effective_at ? formatDt(detail.effective_at) : "—"} · {detail.reason ?? "—"}
Effective at: {detail.effective_at ? formatDt(detail.effective_at) : "—"} · Note: {detail.reason ?? "—"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
<span className="text-amber-600 dark:text-amber-400"> - Read only. Create a draft first.</span>
) : null}
</p>
) : null}
@@ -373,13 +375,13 @@ export function RiskCapDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">Default Cap</h3>
<p className="text-sm text-muted-foreground">
使
Numbers without a special cap use this default cap template.
</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap"></Label>
<Label htmlFor="default-cap">Cap Amount (minor unit)</Label>
{isDraft ? (
<Input
id="default-cap"
@@ -398,7 +400,7 @@ export function RiskCapDocScreen() {
</div>
{isDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
Update
</Button>
) : null}
</div>
@@ -406,7 +408,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">Special Caps</h3>
{isDraft ? (
<Button
type="button"
@@ -414,25 +416,25 @@ export function RiskCapDocScreen() {
disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
+
+ Add Special Cap
</Button>
) : null}
</div>
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">Loading details</p>
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">No detail rows.</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[72px] text-center"></TableHead>
<TableHead className="w-[160px]"></TableHead>
<TableHead className="w-[110px]">Number</TableHead>
<TableHead className="w-[140px]">Cap Amount</TableHead>
<TableHead className="w-[90px] text-right">Used</TableHead>
<TableHead className="w-[90px] text-right">Remaining</TableHead>
<TableHead className="w-[72px] text-center">Sold Out</TableHead>
<TableHead className="w-[160px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -485,10 +487,10 @@ export function RiskCapDocScreen() {
disabled={saving}
onClick={() => removeRow(idx)}
>
{t("actions.delete", { ns: "adminUsers" })}
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">Read only</span>
)}
</TableCell>
</TableRow>
@@ -500,42 +502,42 @@ export function RiskCapDocScreen() {
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">All Number Occupancy</h3>
<p className="text-sm text-muted-foreground">
稿
Placeholder view: filters and exports still need ticket-summary integration. Data below still comes from the current draft list.
</p>
<div className="flex flex-wrap gap-3 items-end">
<div className="grid gap-1">
<Label htmlFor="occ-search"></Label>
<Label htmlFor="occ-search">Search Number</Label>
<Input
id="occ-search"
className="w-[140px] font-mono"
placeholder=" 8888"
placeholder="e.g. 8888"
value={occSearch}
onChange={(e) => setOccSearch(e.target.value)}
/>
</div>
<Button type="button" variant="outline" onClick={() => toast.message("售罄 / 高风险筛选待接入")}>
<Button type="button" variant="outline" onClick={() => toast.message("Sold-out / high-risk preset filter is pending integration")}>
Filter Presets
</Button>
<Button
type="button"
variant="outline"
onClick={() => toast.message("导出 CSV 待接入")}
onClick={() => toast.message("CSV export is pending integration")}
>
CSV
Export CSV
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead>Number</TableHead>
<TableHead className="text-right">Used</TableHead>
<TableHead className="text-right">Remaining</TableHead>
<TableHead className="text-right">Ratio</TableHead>
<TableHead className="text-center">Sold Out</TableHead>
<TableHead className="w-[140px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -548,7 +550,7 @@ export function RiskCapDocScreen() {
<TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell>
<Button type="button" variant="ghost" disabled>
Close
</Button>
</TableCell>
</TableRow>
@@ -562,17 +564,17 @@ export function RiskCapDocScreen() {
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Sync Default Cap</DialogTitle>
<DialogDescription>
{defaultCapStr || "(空)"}稿稿
The default cap template will be set to {defaultCapStr || "(empty)"}. This only changes the draft. Save and publish after confirming.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={applyDefaultCap}>
Confirm
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -42,6 +43,7 @@ interface Draft {
}
export function WalletConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers"]);
const [draft, setDraft] = useState<Draft>({
inMin: "",
inMax: "",
@@ -71,11 +73,11 @@ export function WalletConfigDocScreen() {
setSaved(d);
setDirty(false);
} catch {
toast.error("加载失败");
toast.error(t("wallet.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -95,11 +97,13 @@ export function WalletConfigDocScreen() {
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
toast.success("保存成功");
toast.success(t("wallet.saveSuccess", { ns: "config" }));
setSaved(draft);
setDirty(false);
} catch (error) {
toast.error(error instanceof LotteryApiBizError ? error.message : "保存失败");
toast.error(
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
);
} finally {
setSaving(false);
}
@@ -108,81 +112,81 @@ export function WalletConfigDocScreen() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("wallet.title", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-sm text-muted-foreground">
NPR 100 = 1.00 NPR 1
{t("wallet.description", { ns: "config" })}
</p>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="in-min"></Label>
<Label htmlFor="in-min">{t("wallet.fields.inMin", { ns: "config" })}</Label>
<Input
id="in-min"
type="number"
min="0"
step="0.01"
placeholder="例如 1.00"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.inMin", { ns: "config" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="in-max"></Label>
<Label htmlFor="in-max">{t("wallet.fields.inMax", { ns: "config" })}</Label>
<Input
id="in-max"
type="number"
min="0"
step="0.01"
placeholder="例如 10000.00"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.inMax", { ns: "config" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="out-min"></Label>
<Label htmlFor="out-min">{t("wallet.fields.outMin", { ns: "config" })}</Label>
<Input
id="out-min"
type="number"
min="0"
step="0.01"
placeholder="例如 1.00"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.outMin", { ns: "config" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="out-max"></Label>
<Label htmlFor="out-max">{t("wallet.fields.outMax", { ns: "config" })}</Label>
<Input
id="out-max"
type="number"
min="0"
step="0.01"
placeholder="例如 10000.00"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.outMax", { ns: "config" })}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? "保存中…" : "保存"}
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
@@ -192,7 +196,7 @@ export function WalletConfigDocScreen() {
setDirty(false);
}}
>
{t("wallet.discard", { ns: "config" })}
</Button>
)}
</div>