405 lines
14 KiB
JavaScript
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");
|
|
}
|