feat(users): 用户管理加注销账号入口(super_admin) (#35)
## 用户管理:加注销账号入口(super_admin) 配合后端注销级联清理 + 删除端点(app-server PR #115),后台加删除入口。 ### 改动 - **新增 `DeleteUserModal`**:受控弹窗,必填「注销原因(入审计)」,红字说明删什么/保留什么;`api.delete` 带 body 调 `DELETE /admin/api/users/{id}`;后端 `409`(有未提现现金/邀请金 / 在审提现单)经 `errMsg` 原样透出。列表页与详情页共用一套(同 `AdjustBalanceModals`)。 - **用户详情页**:顶部加红色「注销账号」按钮(`canDo(['super_admin'])`,`deleted` 状态隐藏)。 - **用户列表页**:操作列加红色「注销」内联链接(同门控),列宽 300→340。 ### 门控 前端 `canDo(['super_admin'])` 与后端 `require_role("super_admin")` 对齐;门控被绕过后端亦兜底(403)。 ### 验证 `next build` ✓ Compiled successfully(tsc + eslint + 21 路由静态生成)。 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: no_gen_mu <liujianhishen@gmail.com> Reviewed-on: #35 Co-authored-by: liujiahui <liujiahui@wonderable.ai> Co-committed-by: liujiahui <liujiahui@wonderable.ai>
This commit was merged in pull request #35.
This commit is contained in:
@@ -23,6 +23,7 @@ import { canDo } from '@/lib/auth';
|
|||||||
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
|
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
|
||||||
import { useCursorList } from '@/lib/useCursorList';
|
import { useCursorList } from '@/lib/useCursorList';
|
||||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
|
import { DeleteUserModal } from '@/components/DeleteUserModal';
|
||||||
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
@@ -33,6 +34,7 @@ export default function UserDetailPage() {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [coinOpen, setCoinOpen] = useState(false);
|
const [coinOpen, setCoinOpen] = useState(false);
|
||||||
const [cashOpen, setCashOpen] = useState(false);
|
const [cashOpen, setCashOpen] = useState(false);
|
||||||
|
const [delOpen, setDelOpen] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(() => {
|
const loadData = useCallback(() => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
@@ -52,6 +54,7 @@ export default function UserDetailPage() {
|
|||||||
|
|
||||||
const canCoins = canDo(['finance']);
|
const canCoins = canDo(['finance']);
|
||||||
const canStatus = canDo(['operator']);
|
const canStatus = canDo(['operator']);
|
||||||
|
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
|
||||||
|
|
||||||
const toggleStatus = () => {
|
const toggleStatus = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -154,6 +157,11 @@ export default function UserDetailPage() {
|
|||||||
{data.user.status === 'active' ? '封禁' : '解封'}
|
{data.user.status === 'active' ? '封禁' : '解封'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && data.user.status !== 'deleted' && (
|
||||||
|
<Button danger onClick={() => setDelOpen(true)}>
|
||||||
|
注销账号
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
<Card style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
@@ -272,6 +280,12 @@ export default function UserDetailPage() {
|
|||||||
cash.reload();
|
cash.reload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DeleteUserModal
|
||||||
|
user={data.user}
|
||||||
|
open={delOpen}
|
||||||
|
onClose={() => setDelOpen(false)}
|
||||||
|
onDone={loadData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { canDo } from '@/lib/auth';
|
|||||||
import { formatUtcTime } from '@/lib/format';
|
import { formatUtcTime } from '@/lib/format';
|
||||||
import { usePagedList } from '@/lib/usePagedList';
|
import { usePagedList } from '@/lib/usePagedList';
|
||||||
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
|
||||||
|
import { DeleteUserModal } from '@/components/DeleteUserModal';
|
||||||
import type { UserListItem } from '@/lib/types';
|
import type { UserListItem } from '@/lib/types';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
@@ -43,9 +44,11 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
const canCoins = canDo(['finance']);
|
const canCoins = canDo(['finance']);
|
||||||
const canStatus = canDo(['operator']);
|
const canStatus = canDo(['operator']);
|
||||||
|
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
|
||||||
|
|
||||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||||
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
|
||||||
|
const [delUser, setDelUser] = useState<UserListItem | null>(null);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||||
|
|
||||||
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
|
||||||
@@ -253,7 +256,7 @@ export default function UsersPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'op',
|
key: 'op',
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
width: 300,
|
width: 340,
|
||||||
render: (_: unknown, u: UserListItem) => (
|
render: (_: unknown, u: UserListItem) => (
|
||||||
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
|
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
|
||||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||||
@@ -276,6 +279,11 @@ export default function UsersPage() {
|
|||||||
) : (
|
) : (
|
||||||
<a onClick={() => setForceOnboarding(u, true)}>开启新手引导</a>
|
<a onClick={() => setForceOnboarding(u, true)}>开启新手引导</a>
|
||||||
))}
|
))}
|
||||||
|
{canDelete && u.status !== 'deleted' && (
|
||||||
|
<a style={{ color: '#cf1322' }} onClick={() => setDelUser(u)}>
|
||||||
|
注销
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -384,12 +392,18 @@ export default function UsersPage() {
|
|||||||
showTotal: (t) => `共 ${t} 个用户`,
|
showTotal: (t) => `共 ${t} 个用户`,
|
||||||
onChange: onPageChange,
|
onChange: onPageChange,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1160 }}
|
scroll={{ x: 1200 }}
|
||||||
onChange={onTableChange}
|
onChange={onTableChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
|
||||||
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
|
||||||
|
<DeleteUserModal
|
||||||
|
user={delUser}
|
||||||
|
open={!!delUser}
|
||||||
|
onClose={() => setDelUser(null)}
|
||||||
|
onDone={reload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user