feat(api, ui, i18n): 增强奖池管理与钱包功能

新增奖池余额调整与调整记录查询相关 API,提升后台对奖池的管理与控制能力。
更新奖池与钱包相关多语言文案,新增余额调整与转账完成提示信息,提升用户理解与反馈体验。
优化奖池管理相关 UI 组件,新增余额调整功能并改进页面布局,提升操作易用性。
重构相关组件以整合新功能,并进一步优化后台管理界面的整体用户体验。
This commit is contained in:
2026-05-26 14:59:41 +08:00
parent 60271d87fb
commit eb83bcf360
23 changed files with 881 additions and 228 deletions

View File

@@ -40,6 +40,7 @@ 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 type { OddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
import { PRD_REBATE_MANAGE, PRD_WALLET_RECONCILE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -100,12 +101,14 @@ function dimensionDistinctPrimaryScopePercents(
type RebateConfigDocScreenProps = {
embedded?: boolean;
workspace?: OddsConfigWorkspace;
versionId?: string;
onVersionIdChange?: (id: string) => void;
};
export function RebateConfigDocScreen({
embedded = false,
workspace,
versionId: controlledVersionId,
onVersionIdChange,
}: RebateConfigDocScreenProps) {
@@ -125,14 +128,22 @@ export function RebateConfigDocScreen({
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
const [internalSelectedId, setInternalSelectedId] = useState("");
const selectedId = controlledVersionId ?? internalSelectedId;
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId;
const setSelectedId = workspace?.setSelectedId ?? onVersionIdChange ?? setInternalSelectedId;
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const resolvedTypes = workspace?.types ?? types;
const resolvedList = workspace?.list ?? listRows;
const resolvedDetail = workspace?.detail ?? detail;
const resolvedDraftRows = workspace?.draftRows ?? draftRows;
const setResolvedDraftRows = workspace?.setDraftRows ?? setDraftRows;
const resolvedLoading = workspace ? workspace.loadingList || workspace.loadingTypes : loading;
const resolvedLoadingDetail = workspace?.loadingDetail ?? loadingDetail;
const [p2, setP2] = useState("0");
const [p3, setP3] = useState("0");
const [p4, setP4] = useState("0");
@@ -173,13 +184,16 @@ export function RebateConfigDocScreen({
}, [t]);
useEffect(() => {
if (workspace) {
return;
}
queueMicrotask(async () => {
setLoading(true);
await refreshTypes();
await refreshList();
setLoading(false);
});
}, [refreshTypes, refreshList]);
}, [refreshTypes, refreshList, workspace]);
useEffect(() => {
queueMicrotask(() => {
@@ -187,6 +201,15 @@ export function RebateConfigDocScreen({
});
}, [loadWinEnjoySetting]);
useEffect(() => {
if (!workspace) {
return;
}
setP2(inferPercentFrom(2, workspace.draftRows, workspace.types));
setP3(inferPercentFrom(3, workspace.draftRows, workspace.types));
setP4(inferPercentFrom(4, workspace.draftRows, workspace.types));
}, [workspace?.draftRows, workspace?.types, workspace]);
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
if (!canEditWinEnjoy) {
return;
@@ -226,6 +249,9 @@ export function RebateConfigDocScreen({
}, [t]);
useEffect(() => {
if (workspace) {
return;
}
if (listRows.length === 0) {
if (selectedId !== "") {
queueMicrotask(() => {
@@ -245,9 +271,12 @@ export function RebateConfigDocScreen({
setSelectedId(pickId);
}
});
}, [listRows, selectedId, setSelectedId]);
}, [listRows, selectedId, setSelectedId, workspace]);
useEffect(() => {
if (workspace) {
return;
}
if (selectedId === "") {
return;
}
@@ -258,34 +287,34 @@ export function RebateConfigDocScreen({
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail]);
}, [selectedId, loadDetail, workspace]);
const typesByCode = useMemo(() => {
const m = new Map<string, AdminPlayTypeRow>();
for (const t of types) {
m.set(t.play_code, t);
for (const row of resolvedTypes) {
m.set(row.play_code, row);
}
return m;
}, [types]);
}, [resolvedTypes]);
const rebateBulkPercentsMixed = useMemo(() => {
if (types.length === 0 || draftRows.length === 0) {
if (resolvedTypes.length === 0 || resolvedDraftRows.length === 0) {
return false;
}
for (const dim of [2, 3, 4] as const) {
if (dimensionDistinctPrimaryScopePercents(dim, draftRows, types).size > 1) {
if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes).size > 1) {
return true;
}
}
return false;
}, [types, draftRows]);
}, [resolvedTypes, resolvedDraftRows]);
const selectedVersionSummary = useMemo(
() => listRows.find((x) => String(x.id) === selectedId) ?? null,
[listRows, selectedId],
() => resolvedList.find((x) => String(x.id) === selectedId) ?? null,
[resolvedList, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isSelectedDetail = resolvedDetail !== null && String(resolvedDetail.id) === selectedId;
const selectedStatus = isSelectedDetail ? resolvedDetail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const canEditDraft = isDraft && canManage;
@@ -305,12 +334,12 @@ export function RebateConfigDocScreen({
}
async function handleSave() {
if (!detail || !canEditDraft) {
if (!resolvedDetail || !canEditDraft) {
return;
}
setSaving(true);
try {
const nextRows = applyDimensionPercentsToRows(draftRows);
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
const payload = nextRows.map((r) => ({
play_code: r.play_code,
prize_scope: r.prize_scope,
@@ -320,15 +349,19 @@ export function RebateConfigDocScreen({
currency_code: r.currency_code,
extra_config_json: r.extra_config_json,
}));
const d = await putOddsItems(detail.id, payload);
const d = await putOddsItems(resolvedDetail.id, payload);
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferPercentFrom(2, rows, resolvedTypes));
setP3(inferPercentFrom(3, rows, resolvedTypes));
setP4(inferPercentFrom(4, rows, resolvedTypes));
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
void (workspace?.refreshList() ?? refreshList());
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" }));
} finally {
@@ -337,20 +370,24 @@ export function RebateConfigDocScreen({
}
async function handlePublish() {
if (!detail || !canEditDraft) {
if (!resolvedDetail || !canEditDraft) {
return;
}
setSaving(true);
try {
const d = await publishOddsVersion(detail.id);
const d = await publishOddsVersion(resolvedDetail.id);
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferPercentFrom(2, rows, resolvedTypes));
setP3(inferPercentFrom(3, rows, resolvedTypes));
setP4(inferPercentFrom(4, rows, resolvedTypes));
toast.success(t("rebate.publishSuccess", { ns: "config" }));
void refreshList();
void (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" }));
@@ -362,20 +399,24 @@ export function RebateConfigDocScreen({
async function handleNewDraft() {
setSaving(true);
try {
const active = listRows.find((x) => x.status === "active");
const active = resolvedList.find((x) => x.status === "active");
const d = await postOddsVersion({
reason: `rebate draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(t("rebate.createDraftSuccess", { ns: "config", version: d.version_no }));
await refreshList();
await (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferPercentFrom(2, rows, resolvedTypes));
setP3(inferPercentFrom(3, rows, resolvedTypes));
setP4(inferPercentFrom(4, rows, resolvedTypes));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
} finally {
@@ -383,7 +424,7 @@ export function RebateConfigDocScreen({
}
}
const activeHead = listRows.find((x) => x.status === "active");
const activeHead = resolvedList.find((x) => x.status === "active");
function requestRollback(row: ConfigVersionSummary) {
setRollbackTarget(row);
@@ -407,14 +448,18 @@ export function RebateConfigDocScreen({
version: d.version_no,
}),
);
await refreshList();
await (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
if (workspace) {
workspace.applyDetail(d);
} else {
setDetail(d);
setDraftRows(rows);
}
setP2(inferPercentFrom(2, rows, resolvedTypes));
setP3(inferPercentFrom(3, rows, resolvedTypes));
setP4(inferPercentFrom(4, rows, resolvedTypes));
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
@@ -428,7 +473,7 @@ export function RebateConfigDocScreen({
try {
await deleteOddsVersion(row.id);
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
await (workspace?.refreshList() ?? refreshList());
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" }));
throw e;
@@ -439,10 +484,10 @@ export function RebateConfigDocScreen({
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={listRows}
versions={resolvedList}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
loading={resolvedLoading}
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion}
@@ -454,8 +499,8 @@ export function RebateConfigDocScreen({
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loading}
loadingDetail={loadingDetail}
loadingList={resolvedLoading}
loadingDetail={resolvedLoadingDetail}
saving={saving}
publishLabel={t("rebate.publishLabel", { ns: "config" })}
onRefresh={() => void refreshList()}
@@ -473,16 +518,16 @@ export function RebateConfigDocScreen({
/>
}
footer={
embedded || !detail ? null : (
embedded || !resolvedDetail ? null : (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("rebate.editingVersion", {
ns: "config",
version: detail.version_no,
version: resolvedDetail.version_no,
status:
detail.status === "draft"
resolvedDetail.status === "draft"
? t("versionStatus.draft", { ns: "config" })
: detail.status === "active"
: resolvedDetail.status === "active"
? t("versionStatus.active", { ns: "config" })
: t("versionStatus.archived", { ns: "config" }),
})}
@@ -583,7 +628,7 @@ export function RebateConfigDocScreen({
</div>
) : null}
{loading || loadingDetail ? (
{resolvedLoading || resolvedLoadingDetail ? (
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
</>