feat: 增加管理端多语言与多模块界面国际化支持
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user