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.
This commit is contained in:
2026-05-26 11:23:36 +08:00
parent 4080f0b601
commit 7fb5ec6dff
10 changed files with 181 additions and 65 deletions

View File

@@ -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);
}

View File

@@ -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" })}
</p>
</div>
<ConfirmableSwitch
checked={groupOn}
confirmBusy={confirmBusy}
disabled={!isDraft || saving || group.total === 0}
aria-label={t("play.aria.batchGroupSwitch", {
ns: "config",
group: group.label,
})}
onCheckedChange={(checked) => {
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),
});
}}
/>
<div className="flex shrink-0 items-center justify-center">
<Checkbox
checked={groupOn}
indeterminate={isPartial}
disabled={!isDraft || saving || group.total === 0 || confirmBusy}
aria-label={t("play.aria.batchGroupSwitch", {
ns: "config",
group: group.label,
})}
onCheckedChange={(checked) => {
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),
});
}}
/>
</div>
</div>
);
})}

View File

@@ -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<string> {
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<string>();
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 ? (
<Alert className="border-amber-500/35 bg-amber-500/10 text-amber-950 dark:text-amber-100">
<AlertDescription>{t("rebate.dimensionRatesMixedHint", { ns: "config" })}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-5 sm:grid-cols-3">
<div className="grid gap-2">
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>

View File

@@ -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);
}

View File

@@ -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"));

View File

@@ -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 {
</TableCell>
</TableRow>
) : (
items.map((row) => (
items.map((row) => {
const displayWallet = preferredDisplayWallet(row);
return (
<TableRow key={row.id}>
<TableCell className="tabular-nums">#{row.id}</TableCell>
<TableCell>
@@ -370,13 +381,16 @@ export function PlayersConsole(): React.ReactElement {
<TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell>{row.default_currency}</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{row.wallets.length > 0
? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code)
{displayWallet
? formatAdminMinorUnits(displayWallet.balance, displayWallet.currency_code)
: "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{row.wallets.length > 0
? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code)
{displayWallet
? formatAdminMinorUnits(
displayWallet.available_balance,
displayWallet.currency_code,
)
: "—"}
</TableCell>
<TableCell>
@@ -451,7 +465,8 @@ export function PlayersConsole(): React.ReactElement {
)}
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>