2026-07-05 13:45:04 +08:00

405 lines
14 KiB
JavaScript

import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { createCoinAdjustment, listCoinAdjustments, lookupCoinAdjustmentTarget } from "@/features/operations/api";
import { useCoinAdjustmentAbilities } from "@/features/operations/permissions.js";
import styles from "@/features/operations/operations.module.css";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFormAmountField,
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
} from "@/shared/ui/AdminFormDialog.jsx";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createUserIdentityColumnFilter } from "@/shared/ui/tableFilters.js";
import { emptyUserIdentityFilter, isUserIdentityFilterEmpty, userIdentityFilterParams } from "@/shared/ui/userIdentityFilter.js";
import { formatMillis } from "@/shared/utils/time.js";
const pageSize = 50;
const adjustmentReasons = ["运营发放", "活动补发", "系统补偿", "异常扣回", "风控扣减"];
const emptyForm = () => ({
amount: "",
mode: "increase",
reason: adjustmentReasons[0],
remark: "",
userKeyword: "",
});
const columns = [
{
key: "user",
label: "接收用户",
render: (item) => <AdjustmentUserCell item={item} />,
width: "minmax(260px, 1.35fr)",
},
{
key: "amount",
label: "增减数量",
render: (item) => <AdjustmentAmount item={item} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "reason",
label: "发放理由",
render: (item) => item.reason || "-",
width: "minmax(170px, 0.85fr)",
},
{
key: "remark",
label: "备注",
render: (item) => item.remark || "-",
width: "minmax(220px, 1fr)",
},
{
key: "operator",
label: "发放人",
render: (item) => operatorName(item),
width: "minmax(150px, 0.8fr)",
},
{
key: "balance",
label: "余额",
render: (item) => formatNumber(item.availableAfter),
width: "minmax(120px, 0.65fr)",
},
{
key: "createdAtMs",
label: "时间",
render: (item) => formatMillis(item.createdAtMs),
width: "minmax(170px, 0.85fr)",
},
];
export function CoinAdjustmentPage() {
const abilities = useCoinAdjustmentAbilities();
const { showToast } = useToast();
const [page, setPage] = useState(1);
const [userFilter, setUserFilter] = useState(emptyUserIdentityFilter);
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [targetUser, setTargetUser] = useState(null);
const [lookupLoading, setLookupLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
start_at_ms: timeRange.startMs || "",
...userIdentityFilterParams(userFilter),
}),
[timeRange.endMs, timeRange.startMs, userFilter],
);
const query = usePaginatedQuery({
errorMessage: "获取金币增减列表失败",
fetcher: listCoinAdjustments,
filters,
page,
pageSize,
queryKey: ["coin-adjustments", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const tableColumns = columns.map((column) =>
column.key === "user"
? {
...column,
filter: createUserIdentityColumnFilter({
value: userFilter,
onChange: (value) => {
setUserFilter(value);
setPage(1);
},
}),
}
: column,
);
const resetFilters = () => {
setUserFilter(emptyUserIdentityFilter);
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const openDialog = () => {
setForm(emptyForm());
setTargetUser(null);
setDialogOpen(true);
};
const closeDialog = () => {
if (submitting) {
return;
}
setDialogOpen(false);
setTargetUser(null);
};
const changeForm = (patch) => {
setForm((current) => ({ ...current, ...patch }));
};
const searchTargetUser = async () => {
const keyword = form.userKeyword.trim();
if (!keyword) {
showToast("请输入用户 ID", "error");
return;
}
setLookupLoading(true);
try {
const user = await lookupCoinAdjustmentTarget(keyword);
setTargetUser(user);
} catch (err) {
setTargetUser(null);
showToast(err.message || "查询用户失败", "error");
} finally {
setLookupLoading(false);
}
};
const submitAdjustment = async (event) => {
event.preventDefault();
if (!targetUser?.userId) {
showToast("请先搜索用户", "error");
return;
}
let payload;
try {
payload = buildAdjustmentPayload(form, targetUser);
} catch (err) {
showToast(err.message || "金币增减参数不正确", "error");
return;
}
setSubmitting(true);
try {
await createCoinAdjustment(payload);
showToast("金币已调整", "success");
setForm(emptyForm());
setTargetUser(null);
setDialogOpen(false);
await query.reload();
} catch (err) {
showToast(err.message || "金币调整失败", "error");
} finally {
setSubmitting(false);
}
};
return (
<AdminListPage>
<AdminListToolbar
actions={
abilities.canCreate ? (
<AdminActionIconButton label="金币增减" primary onClick={openDialog}>
<MonetizationOnOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={isUserIdentityFilterEmpty(userFilter) && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1230px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(item) => item.entryId}
/>
</AdminListBody>
</DataState>
<AdminFormDialog
disabled={!abilities.canCreate}
loading={submitting}
open={dialogOpen}
size="large"
submitDisabled={!abilities.canCreate || submitting || !targetUser?.userId}
submitLabel="确认"
title="金币增减"
onClose={closeDialog}
onSubmit={submitAdjustment}
>
<AdminFormSection title="接收用户">
<div className={styles.lookupRow}>
<TextField
disabled={!abilities.canCreate || submitting}
label="用户 ID"
required
value={form.userKeyword}
onChange={(event) => {
changeForm({ userKeyword: event.target.value });
setTargetUser(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
searchTargetUser();
}
}}
/>
<Button
disabled={!abilities.canCreate || lookupLoading || submitting}
startIcon={<SearchOutlined fontSize="small" />}
onClick={searchTargetUser}
>
搜索
</Button>
</div>
{targetUser ? <TargetUserPreview user={targetUser} /> : null}
</AdminFormSection>
<AdminFormSection title="备注">
<TextField
disabled={!abilities.canCreate || submitting}
fullWidth
label="备注"
multiline
required
rows={3}
slotProps={{ htmlInput: { maxLength: 512 } }}
value={form.remark}
onChange={(event) => changeForm({ remark: event.target.value })}
/>
</AdminFormSection>
<AdminFormSection title="金币信息">
<AdminFormFieldGrid columns="repeat(3, minmax(0, 1fr))">
<TextField
disabled={!abilities.canCreate || submitting}
label="类型"
required
select
value={form.mode}
onChange={(event) => changeForm({ mode: event.target.value })}
>
<MenuItem value="increase">增加</MenuItem>
<MenuItem value="decrease">扣减</MenuItem>
</TextField>
<AdminFormAmountField
disabled={!abilities.canCreate || submitting}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="发放数量"
required
unit="金币"
value={form.amount}
onChange={(event) => changeForm({ amount: event.target.value })}
/>
<TextField
disabled={!abilities.canCreate || submitting}
label="发放理由"
required
select
value={form.reason}
onChange={(event) => changeForm({ reason: event.target.value })}
>
{adjustmentReasons.map((reason) => (
<MenuItem key={reason} value={reason}>
{reason}
</MenuItem>
))}
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
</AdminListPage>
);
}
function AdjustmentUserCell({ item }) {
const user = item.user || {};
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || item.userId }} />;
}
function TargetUserPreview({ user }) {
return <AdminUserIdentity openInAppUserDetail className={styles.targetPreview} user={user} />;
}
function AdjustmentAmount({ item }) {
const delta = Number(item.availableDelta ?? item.amount ?? 0);
const expense = delta < 0;
const sign = expense ? "-" : "+";
return (
<span
className={`${styles.amount} ${styles.adjustmentAmount} ${
expense ? styles.amountExpense : styles.amountIncome
}`}
>
{sign}
{formatNumber(Math.abs(delta))}
</span>
);
}
function buildAdjustmentPayload(form, targetUser) {
const rawAmount = String(form.amount || "").trim();
if (!/^[1-9]\d*$/.test(rawAmount)) {
throw new Error("请输入金币数量");
}
const amount = Number(rawAmount);
if (!Number.isSafeInteger(amount)) {
throw new Error("金币数量过大");
}
const signedAmount = form.mode === "decrease" ? -amount : amount;
const reason = String(form.reason || "").trim();
if (!reason) {
throw new Error("请选择发放理由");
}
const remark = String(form.remark || "").trim();
if (!remark) {
throw new Error("请输入备注");
}
if (remark.length > 512) {
throw new Error("备注不能超过 512 个字符");
}
return {
amount: signedAmount,
commandId: `coin-adjustment-${Date.now()}`,
reason,
remark,
targetUserId: String(targetUser.userId),
};
}
function operatorName(item) {
const operator = item.operator || {};
return operator.name || operator.username || item.operatorUserId || "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}