Files
dukang/apps/admin-web/src/pages/StoreAccountsPage.tsx
T
jacy b626db5d84 feat(ops): add global test whitelist and exclude test accounts from settlement
Unify product/store visibility on HQ whitelist, mark isTest snapshots, and fix SUPER_ADMIN access for the new module.
2026-08-07 15:46:23 +08:00

314 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import {
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
type Row = {
id: string;
phone: string;
name: string;
status: string;
createdAt: string;
isTest?: boolean;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
store?: StoreBrief | null;
stores?: StoreBrief[];
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
};
type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.status) qs.set('status', String(filters.status));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange">测试</Tag> : null}
</Space>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '绑定门店',
width: 180,
render: (_, row) =>
row.stores?.length
? row.stores.map((s) => s.name).join('、')
: row.store?.name ?? '—',
},
{
title: '门店数',
dataIndex: 'storeCount',
width: 70,
render: (n, row) => n ?? row.stores?.length ?? 0,
},
{
title: '子账号',
dataIndex: 'staffCount',
width: 70,
render: (n) => n ?? 0,
},
{
title: '收款户名',
dataIndex: 'bankAccountName',
width: 120,
render: (v) => v || '—',
},
{
title: '账号状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
详情
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
<Typography.Text type="secondary">
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
</Typography.Text>
</Space>
<Button
type="primary"
onClick={() => {
void loadStores();
setCreateOpen(true);
}}
>
新建账户
</Button>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox>过滤测试账号</Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="门店主账号"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (
<Select
defaultValue={detail.status}
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/store-accounts/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
message.success('已更新');
void reload();
}}
/>
)
}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="收款户名">{detail.bankAccountName || '—'}</Descriptions.Item>
<Descriptions.Item label="收款账号">{detail.bankAccountNo || '—'}</Descriptions.Item>
<Descriptions.Item label="开户行">{detail.bankBranch || '—'}</Descriptions.Item>
<Descriptions.Item label="绑定门店">
{(detail.stores ?? []).map((s) => (
<Tag key={s.id}>
{s.name}
{s.status ? `${STORE_STATUS_LABELS[s.status] || s.status}` : ''}
</Tag>
))}
{!detail.stores?.length ? '—' : null}
</Descriptions.Item>
</Descriptions>
{detail.staff?.length ? (
<>
<Typography.Title level={5} style={{ marginTop: 24 }}>子账号</Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.staff}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{
title: '状态',
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
删除
</Button>
</Popconfirm>
),
},
]}
/>
</>
) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
暂无子账号
</Typography.Paragraph>
)}
</>
)}
</Drawer>
<Modal
title="新建门店账户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
</Form>
</Modal>
</div>
);
}