更新英文、尼泊尔语与中文语言包,新增默认币种、开奖间隔、投注窗口及相关设置的翻译文案,提升界面清晰度与用户体验。 在系统设置中新增开奖相关配置字段,增强彩票系统参数管理的灵活性。 优化多语言国际化支持,确保后台管理界面在不同语言环境下的信息表达一致性。
551 lines
21 KiB
TypeScript
551 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getAdminSettings,
|
|
updateAdminSetting,
|
|
} from "@/api/admin-settings";
|
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
|
|
const DRAW_GROUP = "draw";
|
|
const SETTLEMENT_GROUP = "settlement";
|
|
|
|
const DRAW_KEYS = {
|
|
DEFAULT_CURRENCY: "currency.default_code",
|
|
DRAW_INTERVAL_MINUTES: "draw.interval_minutes",
|
|
DRAW_BETTING_WINDOW_SECONDS: "draw.betting_window_seconds",
|
|
DRAW_CLOSE_BEFORE_DRAW_SECONDS: "draw.close_before_draw_seconds",
|
|
DRAW_BUFFER_DRAWS_AHEAD: "draw.buffer_draws_ahead",
|
|
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
|
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
|
CURRENCY_DISPLAY_DECIMALS: "currency.display_decimals",
|
|
CURRENCY_DECIMAL_SEPARATOR: "currency.decimal_separator",
|
|
CURRENCY_THOUSANDS_SEPARATOR: "currency.thousands_separator",
|
|
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
|
AUTO_APPROVE: "settlement.auto_approve_on_tick",
|
|
AUTO_PAYOUT: "settlement.auto_payout_on_tick",
|
|
APPLY_REBATE_TO_PAYOUT: "settlement.apply_rebate_to_payout",
|
|
} as const;
|
|
|
|
const FRONTEND_GROUP = "frontend";
|
|
const FRONTEND_KEYS = {
|
|
PLAY_RULES_HTML: "frontend.play_rules_html",
|
|
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
|
|
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
|
|
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
|
|
} as const;
|
|
|
|
interface RuntimeDraft {
|
|
defaultCurrency: string;
|
|
drawIntervalMinutes: string;
|
|
drawBettingWindowSeconds: string;
|
|
drawCloseBeforeDrawSeconds: string;
|
|
drawBufferDrawsAhead: string;
|
|
requireManualReview: boolean;
|
|
cooldownMinutes: string;
|
|
currencyDisplayDecimals: string;
|
|
currencyDecimalSeparator: string;
|
|
currencyThousandsSeparator: string;
|
|
autoSettlement: boolean;
|
|
autoApprove: boolean;
|
|
autoPayout: boolean;
|
|
applyRebateToPayout: boolean;
|
|
playRulesHtmlZh: string;
|
|
playRulesHtmlEn: string;
|
|
playRulesHtmlNe: string;
|
|
}
|
|
|
|
function SaveActions({
|
|
dirty,
|
|
loading,
|
|
saving,
|
|
onSave,
|
|
onDiscard,
|
|
saveLabel,
|
|
savingLabel,
|
|
discardLabel,
|
|
}: {
|
|
dirty: boolean;
|
|
loading: boolean;
|
|
saving: boolean;
|
|
onSave: () => void;
|
|
onDiscard: () => void;
|
|
saveLabel: string;
|
|
savingLabel: string;
|
|
discardLabel: string;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
|
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
|
|
{saving ? savingLabel : saveLabel}
|
|
</Button>
|
|
{dirty ? (
|
|
<Button type="button" variant="outline" onClick={onDiscard}>
|
|
{discardLabel}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SystemSettingsScreen() {
|
|
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
const [draft, setDraft] = useState<RuntimeDraft>({
|
|
defaultCurrency: "NPR",
|
|
drawIntervalMinutes: "5",
|
|
drawBettingWindowSeconds: "270",
|
|
drawCloseBeforeDrawSeconds: "30",
|
|
drawBufferDrawsAhead: "8",
|
|
requireManualReview: false,
|
|
cooldownMinutes: "15",
|
|
currencyDisplayDecimals: "2",
|
|
currencyDecimalSeparator: ".",
|
|
currencyThousandsSeparator: ",",
|
|
autoSettlement: true,
|
|
autoApprove: true,
|
|
autoPayout: true,
|
|
applyRebateToPayout: false,
|
|
playRulesHtmlZh: "",
|
|
playRulesHtmlEn: "",
|
|
playRulesHtmlNe: "",
|
|
});
|
|
const [saved, setSaved] = useState<RuntimeDraft>({
|
|
defaultCurrency: "NPR",
|
|
drawIntervalMinutes: "5",
|
|
drawBettingWindowSeconds: "270",
|
|
drawCloseBeforeDrawSeconds: "30",
|
|
drawBufferDrawsAhead: "8",
|
|
requireManualReview: false,
|
|
cooldownMinutes: "15",
|
|
currencyDisplayDecimals: "2",
|
|
currencyDecimalSeparator: ".",
|
|
currencyThousandsSeparator: ",",
|
|
autoSettlement: true,
|
|
autoApprove: true,
|
|
autoPayout: true,
|
|
applyRebateToPayout: false,
|
|
playRulesHtmlZh: "",
|
|
playRulesHtmlEn: "",
|
|
playRulesHtmlNe: "",
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [dirty, setDirty] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [drawRes, settlementRes, frontendRes] = await Promise.all([
|
|
getAdminSettings(DRAW_GROUP),
|
|
getAdminSettings(SETTLEMENT_GROUP),
|
|
getAdminSettings(FRONTEND_GROUP),
|
|
]);
|
|
|
|
const kv: Record<string, unknown> = {};
|
|
for (const item of [...drawRes.items, ...settlementRes.items, ...frontendRes.items]) {
|
|
kv[item.key] = item.value;
|
|
}
|
|
|
|
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
|
|
const nextDraft: RuntimeDraft = {
|
|
defaultCurrency: String(kv[DRAW_KEYS.DEFAULT_CURRENCY] ?? "NPR"),
|
|
drawIntervalMinutes: String(kv[DRAW_KEYS.DRAW_INTERVAL_MINUTES] ?? 5),
|
|
drawBettingWindowSeconds: String(kv[DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS] ?? 270),
|
|
drawCloseBeforeDrawSeconds: String(kv[DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS] ?? 30),
|
|
drawBufferDrawsAhead: String(kv[DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD] ?? 8),
|
|
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
|
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
|
currencyDisplayDecimals: String(kv[DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS] ?? 2),
|
|
currencyDecimalSeparator: String(kv[DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR] ?? "."),
|
|
currencyThousandsSeparator: String(kv[DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR] ?? ","),
|
|
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
|
autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true),
|
|
autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true),
|
|
applyRebateToPayout: Boolean(kv[DRAW_KEYS.APPLY_REBATE_TO_PAYOUT] ?? false),
|
|
playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
|
|
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
|
|
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
|
|
};
|
|
setDraft(nextDraft);
|
|
setSaved(nextDraft);
|
|
setDirty(false);
|
|
} catch {
|
|
toast.error(t("system.loadFailed", { ns: "config" }));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
|
|
setDraft((prev) => ({ ...prev, [field]: value }));
|
|
setDirty(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.DEFAULT_CURRENCY,
|
|
draft.defaultCurrency.trim().toUpperCase() || "NPR",
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.DRAW_INTERVAL_MINUTES,
|
|
Math.max(1, Number.parseInt(draft.drawIntervalMinutes || "5", 10) || 5),
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.DRAW_BETTING_WINDOW_SECONDS,
|
|
Math.max(10, Number.parseInt(draft.drawBettingWindowSeconds || "270", 10) || 270),
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.DRAW_CLOSE_BEFORE_DRAW_SECONDS,
|
|
Math.max(5, Number.parseInt(draft.drawCloseBeforeDrawSeconds || "30", 10) || 30),
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.DRAW_BUFFER_DRAWS_AHEAD,
|
|
Math.max(1, Number.parseInt(draft.drawBufferDrawsAhead || "8", 10) || 8),
|
|
);
|
|
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.COOLDOWN_MINUTES,
|
|
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.CURRENCY_DISPLAY_DECIMALS,
|
|
Math.max(0, Math.min(12, Number.parseInt(draft.currencyDisplayDecimals || "2", 10) || 2)),
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.CURRENCY_DECIMAL_SEPARATOR,
|
|
(draft.currencyDecimalSeparator || ".").slice(0, 1),
|
|
);
|
|
await updateAdminSetting(
|
|
DRAW_KEYS.CURRENCY_THOUSANDS_SEPARATOR,
|
|
(draft.currencyThousandsSeparator || ",").slice(0, 1),
|
|
);
|
|
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
|
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
|
|
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
|
|
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
|
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
|
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
|
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
|
|
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
|
|
toast.success(t("system.saveSuccess", { ns: "config" }));
|
|
setSaved(draft);
|
|
setDirty(false);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveLabel = t("actions.save", { ns: "adminUsers" });
|
|
const savingLabel = t("saving", { ns: "adminUsers" });
|
|
const discardLabel = t("system.discard", { ns: "config" });
|
|
|
|
return (
|
|
<div className="flex w-full max-w-none flex-col gap-6">
|
|
<AdminPageCard
|
|
title={t("system.title", { ns: "config" })}
|
|
description={t("system.description", { ns: "config" })}
|
|
>
|
|
<div className="space-y-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
|
<Switch
|
|
checked={draft.requireManualReview}
|
|
disabled={loading || saving}
|
|
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
|
onCheckedChange={(value) => updateDraft("requireManualReview", value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-border/60" />
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="default-currency" className="text-sm font-medium">
|
|
{t("system.fields.defaultCurrency", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="default-currency"
|
|
value={draft.defaultCurrency}
|
|
onChange={(e) => updateDraft("defaultCurrency", e.target.value.toUpperCase())}
|
|
disabled={loading || saving}
|
|
maxLength={16}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
|
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="draw-interval-minutes"
|
|
type="number"
|
|
min="1"
|
|
max="1440"
|
|
step="1"
|
|
value={draft.drawIntervalMinutes}
|
|
onChange={(e) => updateDraft("drawIntervalMinutes", e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
|
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="draw-betting-window-seconds"
|
|
type="number"
|
|
min="10"
|
|
step="1"
|
|
value={draft.drawBettingWindowSeconds}
|
|
onChange={(e) => updateDraft("drawBettingWindowSeconds", e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
|
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="draw-close-before-seconds"
|
|
type="number"
|
|
min="5"
|
|
step="1"
|
|
value={draft.drawCloseBeforeDrawSeconds}
|
|
onChange={(e) => updateDraft("drawCloseBeforeDrawSeconds", e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
|
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="draw-buffer-ahead"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={draft.drawBufferDrawsAhead}
|
|
onChange={(e) => updateDraft("drawBufferDrawsAhead", e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="currency-display-decimals" className="text-sm font-medium">
|
|
{t("system.fields.currencyDisplayDecimals", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="currency-display-decimals"
|
|
type="number"
|
|
min="0"
|
|
max="12"
|
|
step="1"
|
|
value={draft.currencyDisplayDecimals}
|
|
onChange={(e) => updateDraft("currencyDisplayDecimals", e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="currency-decimal-separator" className="text-sm font-medium">
|
|
{t("system.fields.currencyDecimalSeparator", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="currency-decimal-separator"
|
|
value={draft.currencyDecimalSeparator}
|
|
onChange={(e) => updateDraft("currencyDecimalSeparator", e.target.value)}
|
|
disabled={loading || saving}
|
|
maxLength={1}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="currency-thousands-separator" className="text-sm font-medium">
|
|
{t("system.fields.currencyThousandsSeparator", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="currency-thousands-separator"
|
|
value={draft.currencyThousandsSeparator}
|
|
onChange={(e) => updateDraft("currencyThousandsSeparator", e.target.value)}
|
|
disabled={loading || saving}
|
|
maxLength={1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-px bg-border/60" />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
|
<Switch
|
|
checked={draft.autoSettlement}
|
|
disabled={loading || saving}
|
|
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
|
onCheckedChange={(value) => updateDraft("autoSettlement", value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-border/60" />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
|
<Switch
|
|
checked={draft.autoApprove}
|
|
disabled={loading || saving}
|
|
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
|
onCheckedChange={(value) => updateDraft("autoApprove", value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-border/60" />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
|
<Switch
|
|
checked={draft.autoPayout}
|
|
disabled={loading || saving}
|
|
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
|
onCheckedChange={(value) => updateDraft("autoPayout", value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-border/60" />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0 space-y-1 pr-4">
|
|
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
|
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
|
</div>
|
|
<Switch
|
|
checked={draft.applyRebateToPayout}
|
|
disabled={loading || saving}
|
|
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
|
onCheckedChange={(value) => updateDraft("applyRebateToPayout", value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-border/60" />
|
|
|
|
<div className="grid max-w-xs gap-2">
|
|
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
|
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
|
</Label>
|
|
<Input
|
|
id="cooldown-minutes"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={draft.cooldownMinutes}
|
|
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AdminPageCard>
|
|
|
|
<AdminPageCard
|
|
title={t("wallet.title", { ns: "config" })}
|
|
description={t("wallet.description", { ns: "config" })}
|
|
>
|
|
<WalletConfigDocScreen embedded />
|
|
</AdminPageCard>
|
|
|
|
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
|
<div className="grid gap-2">
|
|
<Label className="text-sm font-medium">
|
|
{t("system.fields.playRulesHtml", { ns: "config" })}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
|
</p>
|
|
<Tabs defaultValue="zh" className="w-full">
|
|
<TabsList className="w-full max-w-md">
|
|
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
|
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
|
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="zh" className="mt-3">
|
|
<Textarea
|
|
id="play-rules-html-zh"
|
|
value={draft.playRulesHtmlZh}
|
|
onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
|
|
disabled={loading || saving}
|
|
className="min-h-[200px] font-mono text-xs"
|
|
placeholder="<div>...</div>"
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="en" className="mt-3">
|
|
<Textarea
|
|
id="play-rules-html-en"
|
|
value={draft.playRulesHtmlEn}
|
|
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
|
|
disabled={loading || saving}
|
|
className="min-h-[200px] font-mono text-xs"
|
|
placeholder="<div>...</div>"
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="ne" className="mt-3">
|
|
<Textarea
|
|
id="play-rules-html-ne"
|
|
value={draft.playRulesHtmlNe}
|
|
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
|
|
disabled={loading || saving}
|
|
className="min-h-[200px] font-mono text-xs"
|
|
placeholder="<div>...</div>"
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</AdminPageCard>
|
|
|
|
<Card className="admin-list-card">
|
|
<CardContent className="admin-list-content">
|
|
<SaveActions
|
|
dirty={dirty}
|
|
loading={loading}
|
|
saving={saving}
|
|
onSave={() =>
|
|
requestConfirm({
|
|
title: t("system.confirmSaveTitle", { ns: "config" }),
|
|
description: t("system.confirmSaveDescription", { ns: "config" }),
|
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
onConfirm: () => handleSave(),
|
|
})
|
|
}
|
|
onDiscard={() => {
|
|
setDraft(saved);
|
|
setDirty(false);
|
|
}}
|
|
saveLabel={saveLabel}
|
|
savingLabel={savingLabel}
|
|
discardLabel={discardLabel}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
}
|