From 7fb5ec6dff2843175e621c5cde3835813fc2384a Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 26 May 2026 11:23:36 +0800 Subject: [PATCH] feat(i18n): add saveFailed message and dimensionRatesMixedHint to locale files - Updated English, Nepali, and Chinese locale files to include a new message for "Failed to save configuration". - Added a hint for mixed rebate rates within the same dimension to enhance user understanding. - Improved internationalization support across multiple locales for better user experience in the admin interface. --- src/i18n/locales/en/config.json | 4 +- src/i18n/locales/ne/config.json | 4 +- src/i18n/locales/zh/config.json | 4 +- src/lib/config-version-auto-pick.ts | 12 +++ .../config/doc/odds-config-doc-screen.tsx | 25 ++++-- .../config/doc/play-config-doc-screen.tsx | 57 +++++++------- .../config/doc/rebate-config-doc-screen.tsx | 77 ++++++++++++++++--- .../config/doc/risk-cap-doc-screen.tsx | 24 ++++-- src/modules/draws/draws-index-console.tsx | 10 ++- src/modules/players/players-console.tsx | 29 +++++-- 10 files changed, 181 insertions(+), 65 deletions(-) create mode 100644 src/lib/config-version-auto-pick.ts diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index 94c9f1d..ee6dcdd 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -63,7 +63,8 @@ "refreshing": "Refreshing", "refresh": "Refresh versions", "newDraft": "New draft", - "saveDraft": "Save draft" + "saveDraft": "Save draft", + "saveFailed": "Failed to save configuration" }, "wallet": { "title": "Wallet transfer limit settings", @@ -318,6 +319,7 @@ "deleteFailed": "Delete failed", "editingVersion": "Editing version v{{version}} · {{status}}", "readOnlyHint": "Create a draft before editing rebate.", + "dimensionRatesMixedHint": "Rebate rates within the same dimension (2D/3D/4D) are not identical: the three percentage inputs show the first play (alphabetically) that has the primary prize scope; use the table as the source of truth. Bulk inputs will overwrite all plays in that dimension to one rate.", "fields": { "d2": "2D rebate rate (%)", "d3": "3D rebate rate (%)", diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index 222ca8f..c64cb88 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -63,7 +63,8 @@ "refreshing": "रिफ्रेस हुँदैछ", "refresh": "संस्करण रिफ्रेस", "newDraft": "नयाँ ड्राफ्ट", - "saveDraft": "ड्राफ्ट सेभ गर्नुहोस्" + "saveDraft": "ड्राफ्ट सेभ गर्नुहोस्", + "saveFailed": "कन्फिगरेसन सुरक्षित गर्न असफल" }, "wallet": { "title": "वालेट ट्रान्सफर सीमा सेटिङ", @@ -318,6 +319,7 @@ "deleteFailed": "मेटाउन असफल", "editingVersion": "सम्पादन भइरहेको संस्करण v{{version}} · {{status}}", "readOnlyHint": "रिबेट सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।", + "dimensionRatesMixedHint": "एउटै डाइमेन्शनभित्रका खेलका रिबेट दरहरू उस्तै छैनन्: माथिको तीन फिल्डहरू मुख्य पुरस्कार स्कोपको पहिलो प्ले (क्रम)को उदाहरण मात्र देखाउँछन्; वास्तविक दर टेबुलको लाइन हेर्नुहोस्। थोक इनपुटले डाइमेन्शनभित्रका सबै प्लेहरू एउटै दरले अधिलेखन गर्छ।", "fields": { "d2": "2D रिबेट दर (%)", "d3": "3D रिबेट दर (%)", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index e64b4f1..5424e27 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -63,7 +63,8 @@ "refreshing": "刷新中", "refresh": "刷新版本", "newDraft": "新建草稿", - "saveDraft": "保存草稿" + "saveDraft": "保存草稿", + "saveFailed": "配置保存失败" }, "wallet": { "title": "钱包转账限额配置", @@ -318,6 +319,7 @@ "deleteFailed": "删除失败", "editingVersion": "当前编辑版本 v{{version}} · {{status}}", "readOnlyHint": "修改回水前请先创建草稿。", + "dimensionRatesMixedHint": "检测到同一维度(2D/3D/4D)内各玩法的首奖级回水比例不完全相同:上方三个百分比输入仅展示按玩法编码排序后的第一个有值示例,实际回水请以下方表格各行数据为准;使用批量输入会先按维度覆盖为同一比例。", "fields": { "d2": "2D 回水比例 (%)", "d3": "3D 回水比例 (%)", diff --git a/src/lib/config-version-auto-pick.ts b/src/lib/config-version-auto-pick.ts new file mode 100644 index 0000000..249688c --- /dev/null +++ b/src/lib/config-version-auto-pick.ts @@ -0,0 +1,12 @@ +import type { ConfigVersionSummary } from "@/types/api/admin-config"; + +/** Prefer newest draft, then active, otherwise highest numeric id — matches config doc UX. */ +export function pickDefaultConfigVersionId(list: ConfigVersionSummary[]): string | null { + if (list.length === 0) { + return null; + } + const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id); + const active = list.find((x) => x.status === "active"); + const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0]; + return pick ? String(pick.id) : null; +} diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx index 9061f00..a2b843d 100644 --- a/src/modules/config/doc/odds-config-doc-screen.tsx +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -41,6 +41,7 @@ import { cn } from "@/lib/utils"; import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; +import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; import type { AdminPlayTypeRow, ConfigVersionSummary, @@ -168,18 +169,26 @@ export function OddsConfigDocScreen({ }, [t]); useEffect(() => { - if (list.length === 0 || selectedId !== "") { + if (list.length === 0) { + if (selectedId !== "") { + queueMicrotask(() => { + setSelectedId(""); + setDetail(null); + setDraftRows([]); + }); + } + return; + } + if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) { return; } queueMicrotask(() => { - const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id); - const active = list.find((x) => x.status === "active"); - const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0]; - if (pick) { - setSelectedId(String(pick.id)); + const pickId = pickDefaultConfigVersionId(list); + if (pickId) { + setSelectedId(pickId); } }); - }, [list, selectedId]); + }, [list, selectedId, setSelectedId]); useEffect(() => { if (selectedId === "") { @@ -292,7 +301,7 @@ export function OddsConfigDocScreen({ toast.success(t("versionActions.saveDraft", { ns: "config" })); void refreshList(); } catch (e) { - toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" })); + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { setSaving(false); } diff --git a/src/modules/config/doc/play-config-doc-screen.tsx b/src/modules/config/doc/play-config-doc-screen.tsx index a1634df..f3d06bc 100644 --- a/src/modules/config/doc/play-config-doc-screen.tsx +++ b/src/modules/config/doc/play-config-doc-screen.tsx @@ -14,6 +14,7 @@ import { putPlayConfigItems, } from "@/api/admin-config"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { @@ -312,7 +313,7 @@ export function PlayConfigDocScreen() { toast.success(t("versionActions.saveDraft", { ns: "config" })); void refreshList(); } catch (e) { - toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" })); + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { setSaving(false); } @@ -490,32 +491,34 @@ export function PlayConfigDocScreen() { : t("play.noPlayTypes", { ns: "config" })}

- { - const enable = checked; - const action = enable - ? t("play.batchSwitchEnable", { ns: "config" }) - : t("play.batchSwitchDisable", { ns: "config" }); - requestConfirm({ - title: t("play.batchSwitchConfirmTitle", { ns: "config", action }), - description: t("play.batchSwitchConfirmDescription", { - ns: "config", - action, - group: group.label, - count: group.total, - }), - confirmVariant: enable ? "default" : "destructive", - onConfirm: () => applyBatchSwitch(group, enable), - }); - }} - /> +
+ { + const enable = checked === true; + const action = enable + ? t("play.batchSwitchEnable", { ns: "config" }) + : t("play.batchSwitchDisable", { ns: "config" }); + requestConfirm({ + title: t("play.batchSwitchConfirmTitle", { ns: "config", action }), + description: t("play.batchSwitchConfirmDescription", { + ns: "config", + action, + group: group.label, + count: group.total, + }), + confirmVariant: enable ? "default" : "destructive", + onConfirm: () => applyBatchSwitch(group, enable), + }); + }} + /> +
); })} diff --git a/src/modules/config/doc/rebate-config-doc-screen.tsx b/src/modules/config/doc/rebate-config-doc-screen.tsx index d249e97..b61244c 100644 --- a/src/modules/config/doc/rebate-config-doc-screen.tsx +++ b/src/modules/config/doc/rebate-config-doc-screen.tsx @@ -20,6 +20,7 @@ import { ConfigVersionToolbarMetaEmphasis, } from "@/modules/config/config-version-toolbar-meta"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; @@ -28,6 +29,7 @@ import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher" import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; import { PRD_REBATE_MANAGE } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -49,10 +51,38 @@ function rateToPercentUi(rateStr: string): string { } function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPlayTypeRow[]): string { - const codes = typeList.filter((t) => (t.dimension ?? 2) === dim).map((t) => t.play_code); + const codes = typeList + .filter((t) => (t.dimension ?? 2) === dim) + .map((t) => t.play_code) + .sort((a, b) => a.localeCompare(b)); const scope = PRIZE_SCOPE_ORDER[0]; - const hit = rows.find((r) => codes.includes(r.play_code) && r.prize_scope === scope); - return hit ? rateToPercentUi(String(hit.rebate_rate)) : "0"; + for (const code of codes) { + const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope); + if (hit) { + return rateToPercentUi(String(hit.rebate_rate)); + } + } + return "0"; +} + +function dimensionDistinctPrimaryScopePercents( + dim: 2 | 3 | 4, + rows: OddsItemRow[], + typeList: AdminPlayTypeRow[], +): Set { + const codes = typeList + .filter((t) => (t.dimension ?? 2) === dim) + .map((t) => t.play_code) + .sort((a, b) => a.localeCompare(b)); + const scope = PRIZE_SCOPE_ORDER[0]; + const percents = new Set(); + for (const code of codes) { + const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope); + if (hit) { + percents.add(rateToPercentUi(String(hit.rebate_rate))); + } + } + return percents; } type RebateConfigDocScreenProps = { @@ -139,18 +169,26 @@ export function RebateConfigDocScreen({ }, [t]); useEffect(() => { - if (listRows.length === 0 || selectedId !== "") { + if (listRows.length === 0) { + if (selectedId !== "") { + queueMicrotask(() => { + setSelectedId(""); + setDetail(null); + setDraftRows([]); + }); + } + return; + } + if (selectedId !== "" && listRows.some((x) => String(x.id) === selectedId)) { return; } queueMicrotask(() => { - const drafts = listRows.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id); - const active = listRows.find((x) => x.status === "active"); - const pick = drafts[0] ?? active ?? [...listRows].sort((a, b) => b.id - a.id)[0]; - if (pick) { - setSelectedId(String(pick.id)); + const pickId = pickDefaultConfigVersionId(listRows); + if (pickId) { + setSelectedId(pickId); } }); - }, [listRows, selectedId]); + }, [listRows, selectedId, setSelectedId]); useEffect(() => { if (selectedId === "") { @@ -173,6 +211,18 @@ export function RebateConfigDocScreen({ return m; }, [types]); + const rebateBulkPercentsMixed = useMemo(() => { + if (types.length === 0 || draftRows.length === 0) { + return false; + } + for (const dim of [2, 3, 4] as const) { + if (dimensionDistinctPrimaryScopePercents(dim, draftRows, types).size > 1) { + return true; + } + } + return false; + }, [types, draftRows]); + const selectedVersionSummary = useMemo( () => listRows.find((x) => String(x.id) === selectedId) ?? null, [listRows, selectedId], @@ -223,7 +273,7 @@ export function RebateConfigDocScreen({ toast.success(t("versionActions.saveDraft", { ns: "config" })); void refreshList(); } catch (e) { - toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" })); + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { setSaving(false); } @@ -354,6 +404,11 @@ export function RebateConfigDocScreen({ const fieldsBlock = ( <> + {rebateBulkPercentsMixed ? ( + + {t("rebate.dimensionRatesMixedHint", { ns: "config" })} + + ) : null}
diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx index fc75fe4..fbd8ddf 100644 --- a/src/modules/config/doc/risk-cap-doc-screen.tsx +++ b/src/modules/config/doc/risk-cap-doc-screen.tsx @@ -48,6 +48,7 @@ import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money"; import { PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; +import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; import type { ConfigVersionSummary, RiskCapItemRow, @@ -156,15 +157,24 @@ export function RiskCapDocScreen() { }, [t]); useEffect(() => { - if (list.length === 0 || selectedId !== "") { + if (list.length === 0) { + if (selectedId !== "") { + queueMicrotask(() => { + setSelectedId(""); + setDetail(null); + setDraftRows([]); + syncDefaultCapFromRows([]); + }); + } + return; + } + if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) { return; } queueMicrotask(() => { - const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id); - const active = list.find((x) => x.status === "active"); - const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0]; - if (pick) { - setSelectedId(String(pick.id)); + const pickId = pickDefaultConfigVersionId(list); + if (pickId) { + setSelectedId(pickId); } }); }, [list, selectedId]); @@ -242,7 +252,7 @@ export function RiskCapDocScreen() { toast.success(t("versionActions.saveDraft", { ns: "config" })); void refreshList(); } catch (e) { - toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" })); + toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { setSaving(false); } diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 26c1e31..58158b1 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -231,8 +231,14 @@ export function DrawsIndexConsole() { t("batchDelete.success", { count: result.success.length }), ); } - - setSelectedDrawIds(new Set()); + + setSelectedDrawIds((prev) => { + const next = new Set(prev); + for (const id of result.success) { + next.delete(id); + } + return next; + }); await load(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("batchDelete.failed")); diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index 48c65c2..49b4bf0 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -52,7 +52,7 @@ import { import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { formatAdminMinorUnits } from "@/lib/money"; import { LotteryApiBizError } from "@/types/api/errors"; -import type { AdminPlayerRow } from "@/types/api/admin-player"; +import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player"; function playerStatusLabelT(status: number, t: (key: string) => string): string { if (status === 0) return t("statusNormal"); @@ -61,6 +61,15 @@ function playerStatusLabelT(status: number, t: (key: string) => string): string return String(status); } +function preferredDisplayWallet(row: AdminPlayerRow): AdminPlayerWalletRow | null { + const { wallets, default_currency } = row; + if (wallets.length === 0) { + return null; + } + const code = default_currency.trim().toUpperCase(); + return wallets.find((w) => w.currency_code.toUpperCase() === code) ?? wallets[0]; +} + const PLAYER_STATUS_OPTIONS = [ { value: 0, label: "statusNormal" }, { value: 1, label: "statusFrozen" }, @@ -357,7 +366,9 @@ export function PlayersConsole(): React.ReactElement { ) : ( - items.map((row) => ( + items.map((row) => { + const displayWallet = preferredDisplayWallet(row); + return ( #{row.id} @@ -370,13 +381,16 @@ export function PlayersConsole(): React.ReactElement { {row.nickname ?? "—"} {row.default_currency} - {row.wallets.length > 0 - ? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code) + {displayWallet + ? formatAdminMinorUnits(displayWallet.balance, displayWallet.currency_code) : "—"} - {row.wallets.length > 0 - ? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code) + {displayWallet + ? formatAdminMinorUnits( + displayWallet.available_balance, + displayWallet.currency_code, + ) : "—"} @@ -451,7 +465,8 @@ export function PlayersConsole(): React.ReactElement { )} - )) + ); + }) )}