feat(users): 用户管理加注销账号入口(super_admin)

配合后端注销级联清理 + 删除端点(app-server PR #115),后台加删除入口。

- 新增 DeleteUserModal:受控弹窗, 必填「注销原因(入审计)」, 红字说明删什么/保留什么,
  api.delete 带 body 调 DELETE /admin/api/users/{id}, 后端 409(有未提现钱/在审提现)经
  errMsg 原样透出
- 用户详情页:顶部加红色「注销账号」按钮(canDo(['super_admin']), deleted 状态隐藏)
- 用户列表页:操作列加红色「注销」内联链接(同门控), 列宽 300→340

前端 super_admin 门控与后端 require_role("super_admin") 对齐, 门控被绕过后端亦兜底。
next build ✓ Compiled successfully(tsc + eslint + 21 路由)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
no_gen_mu
2026-07-04 15:04:21 +08:00
parent 281cfdb0e4
commit e5e6a64605
3 changed files with 91 additions and 2 deletions
+14
View File
@@ -23,6 +23,7 @@ import { canDo } from '@/lib/auth';
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import { DeleteUserModal } from '@/components/DeleteUserModal';
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
export default function UserDetailPage() {
@@ -33,6 +34,7 @@ export default function UserDetailPage() {
const [err, setErr] = useState<string | null>(null);
const [coinOpen, setCoinOpen] = useState(false);
const [cashOpen, setCashOpen] = useState(false);
const [delOpen, setDelOpen] = useState(false);
const loadData = useCallback(() => {
setErr(null);
@@ -52,6 +54,7 @@ export default function UserDetailPage() {
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
const toggleStatus = () => {
if (!data) return;
@@ -154,6 +157,11 @@ export default function UserDetailPage() {
{data.user.status === 'active' ? '封禁' : '解封'}
</Button>
)}
{canDelete && data.user.status !== 'deleted' && (
<Button danger onClick={() => setDelOpen(true)}>
</Button>
)}
</Space>
</Space>
<Card style={{ marginBottom: 16 }}>
@@ -272,6 +280,12 @@ export default function UserDetailPage() {
cash.reload();
}}
/>
<DeleteUserModal
user={data.user}
open={delOpen}
onClose={() => setDelOpen(false)}
onDone={loadData}
/>
</div>
);
}
+16 -2
View File
@@ -12,6 +12,7 @@ import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import { DeleteUserModal } from '@/components/DeleteUserModal';
import type { UserListItem } from '@/lib/types';
const { RangePicker } = DatePicker;
@@ -43,9 +44,11 @@ export default function UsersPage() {
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
const [delUser, setDelUser] = useState<UserListItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
@@ -246,7 +249,7 @@ export default function UsersPage() {
title: '操作',
key: 'op',
fixed: 'right',
width: 300,
width: 340,
render: (_: unknown, u: UserListItem) => (
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
<a onClick={() => router.push(`/users/${u.id}`)}></a>
@@ -269,6 +272,11 @@ export default function UsersPage() {
) : (
<a onClick={() => setForceOnboarding(u, true)}></a>
))}
{canDelete && u.status !== 'deleted' && (
<a style={{ color: '#cf1322' }} onClick={() => setDelUser(u)}>
</a>
)}
</Space>
),
},
@@ -377,12 +385,18 @@ export default function UsersPage() {
showTotal: (t) => `${t} 个用户`,
onChange: onPageChange,
}}
scroll={{ x: 1160 }}
scroll={{ x: 1200 }}
onChange={onTableChange}
/>
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
<DeleteUserModal
user={delUser}
open={!!delUser}
onClose={() => setDelUser(null)}
onDone={reload}
/>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
// 注销/删除账号弹窗(受控)。列表页与用户详情页共用,避免两处各写一套(同 AdjustBalanceModals)。
// 复用 C 端自助注销同一套后端清理:物理删除个人内容/行为/PII 表 + 清零余额 + 匿名化 user 行,
// 保留资金流水/邀请关系/广告幂等+对账表。仅 super_admin 可见入口;后端亦 require_role("super_admin") 兜底。
// 后端资金前置闸(有未提现现金/邀请金 / 在审提现单 → 409)带明确 detail,errMsg 原样透出。
import { App, Form, Input, Modal } from 'antd';
import { api, errMsg } from '@/lib/api';
export type DeleteTargetUser = { id: number; phone: string };
interface Props {
user: DeleteTargetUser | null;
open: boolean;
onClose: () => void;
onDone?: () => void; // 删除成功后回调(刷新详情/列表)
}
export function DeleteUserModal({ user, open, onClose, onDone }: Props) {
const { message } = App.useApp();
const [form] = Form.useForm();
const submit = async () => {
if (!user) return;
const v = await form.validateFields(); // 原因必填,缺失即红字阻断
try {
// axios 的 DELETE 带 body 要放在 config.data 里
await api.delete(`/admin/api/users/${user.id}`, { data: { reason: v.reason } });
message.success('账号已注销');
onClose();
onDone?.();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={`注销账号 - ${user?.phone ?? ''}`}
open={open}
onOk={submit}
onCancel={onClose}
okText="确认注销"
okButtonProps={{ danger: true }}
afterOpenChange={(o) => {
if (o) form.resetFields();
}}
destroyOnHidden
>
<p style={{ color: '#cf1322', marginBottom: 12 }}>
(//////),
<strong></strong>//广
</p>
<Form form={form} layout="vertical">
<Form.Item name="reason" label="注销原因(入审计)" rules={[{ required: true, message: '请填写注销原因' }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
}