feat(admin-web): 余额设值/批量、时区与复用整顿、用户列表排序筛选 (#9)

- 调金币/现金:新增「设为指定值」模式;修 InputNumber addonAfter 废弃告警;
  弹窗抽成共享组件 AdjustBalanceModals(列表页/详情页复用,内含防竞态余额拉取)
- 新增 lib/format.ts 统一金额/时间格式化:区分 UTC 字段与钱包流水(北京 wall-clock),
  修正提现详情现金流水快 8 小时、用户注册时间慢 8 小时等偏差
- 提现页:账本校验(ledger-check)改按需触发,写操作后不再重算;现金流水时区修正
- 用户详情页:加载失败 Result 兜底(不再永久转圈)+ 现金流水 tab + 调金币/现金/封禁入口
- 金币审计页:加「只看不一致」过滤;截断提示改用后端精确 truncated 旗标
- 用户列表:注册/最近登录/ID 列排序 + 渠道/昵称/时间范围筛选 + 批量封禁解封/开关调试链接

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #9
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
This commit was merged in pull request #9.
This commit is contained in:
ouzhou
2026-06-13 23:43:52 +08:00
committed by marco
parent 2eff4a71be
commit 94d550db9b
7 changed files with 694 additions and 169 deletions
+201
View File
@@ -0,0 +1,201 @@
'use client';
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
import type { UserOverview } from '@/lib/types';
export type AdjustTargetUser = { id: number; phone: string };
interface ModalProps {
user: AdjustTargetUser | null;
open: boolean;
onClose: () => void;
onDone?: () => void; // 调整成功后回调(刷新余额/列表)
}
const balanceLine = (balances: { coin: number; cashCents: number } | null) => (
<p style={{ color: '#888', marginBottom: 12 }}>
当前余额:金币 {balances ? balances.coin : '…'} {balances ? yuan(balances.cashCents) : '…'}
</p>
);
// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。
function useBalances(userId: number | undefined, open: boolean) {
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
useEffect(() => {
if (!open || userId == null) {
setBalances(null);
return;
}
let alive = true;
setBalances(null);
api
.get<UserOverview>(`/admin/api/users/${userId}`)
.then((r) => {
if (alive) setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents });
})
.catch(() => {});
return () => {
alive = false;
};
}, [userId, open]);
return balances;
}
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
useEffect(() => {
if (open) {
setMode('delta');
form.resetFields();
}
}, [open, form]);
const submit = async () => {
if (!user) return;
const v = await form.validateFields();
try {
await api.post(`/admin/api/users/${user.id}/coins`, { mode, ...v });
message.success(mode === 'set' ? '已设置金币' : '已调整金币');
onClose();
onDone?.();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={`调整金币 - ${user?.phone ?? ''}`}
open={open}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
{balanceLine(balances)}
<Form form={form} layout="vertical">
<Form.Item label="操作方式">
<Segmented
value={mode}
onChange={(v) => setMode(v as 'delta' | 'set')}
options={[
{ label: '增减', value: 'delta' },
{ label: '设为指定值', value: 'set' },
]}
/>
</Form.Item>
<Form.Item
name="amount"
label={mode === 'set' ? '目标金币值(直接设为该值)' : '金币变动(正=增加,负=扣减)'}
rules={[
{ required: true },
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
]}
>
<InputNumber style={{ width: '100%' }} min={mode === 'set' ? 0 : undefined} />
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
}
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
useEffect(() => {
if (open) {
setMode('delta');
form.resetFields();
}
}, [open, form]);
// 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
const submit = async () => {
if (!user) return;
const v = await form.validateFields();
const amountCents = Math.round(Number(v.amount_yuan) * 100);
if (mode === 'delta' && amountCents === 0) {
message.warning('金额不能为 0(且不小于 1 分)');
return;
}
if (mode === 'set' && amountCents < 0) {
message.warning('目标金额不能为负');
return;
}
try {
await api.post(`/admin/api/users/${user.id}/cash`, {
mode,
amount_cents: amountCents,
reason: v.reason,
});
message.success(
mode === 'set'
? `已设为 ¥${(amountCents / 100).toFixed(2)} 现金`
: `${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`,
);
onClose();
onDone?.();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={`调现金 - ${user?.phone ?? ''}`}
open={open}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
{balanceLine(balances)}
<Form form={form} layout="vertical">
<Form.Item label="操作方式">
<Segmented
value={mode}
onChange={(v) => setMode(v as 'delta' | 'set')}
options={[
{ label: '增减', value: 'delta' },
{ label: '设为指定值', value: 'set' },
]}
/>
</Form.Item>
<Form.Item
name="amount_yuan"
label={
mode === 'set'
? '目标现金(元,直接设为该值,须≥0)'
: '现金变动(元,正=发放,负=扣减;不可扣成负)'
}
rules={[
{ required: true },
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
]}
>
<InputNumber
style={{ width: '100%' }}
step={0.01}
precision={2}
suffix="元"
min={mode === 'set' ? 0 : undefined}
/>
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
}