webadmin增加显示子账号显示列表
修复api服务端报错
This commit is contained in:
@@ -5,6 +5,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
|
||||
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
||||
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
|
||||
{ value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' },
|
||||
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
||||
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
||||
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; phone: string; name: string; status: string; isPrimary: number; createdAt: string;
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
|
||||
type AccountTreeRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
status: string;
|
||||
isPrimary: number;
|
||||
staffRole: string | null;
|
||||
parentAccountId: string | null;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
children?: AccountTreeRow[];
|
||||
};
|
||||
|
||||
type BillRow = {
|
||||
@@ -22,40 +34,94 @@ type OrderRow = {
|
||||
user?: { userNo: string; phone: string | null };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
|
||||
type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
|
||||
const phoneQ = phone?.trim();
|
||||
const match = (row: AccountTreeRow) => {
|
||||
const phoneOk = !phoneQ || row.phone.includes(phoneQ);
|
||||
const statusOk = !status || row.status === status;
|
||||
return phoneOk && statusOk;
|
||||
};
|
||||
|
||||
const walk = (list: AccountTreeRow[]): AccountTreeRow[] =>
|
||||
list
|
||||
.map((row) => {
|
||||
const children = row.children?.length ? walk(row.children) : undefined;
|
||||
if (match(row) || (children && children.length > 0)) {
|
||||
return { ...row, children: children?.length ? children : undefined };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((row): row is AccountTreeRow => row !== null);
|
||||
|
||||
return walk(rows);
|
||||
}
|
||||
|
||||
export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
|
||||
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree';
|
||||
const res = await request<AccountTreeRow[]>(path);
|
||||
setTreeData(filterTree(res, filters.phone, filters.status));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen || !detail) return;
|
||||
editForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
});
|
||||
}, [drawerOpen, detail, editForm]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
async function openAccount(id: string) {
|
||||
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ name: d.name, phone: d.phone, status: d.status });
|
||||
setDrawerOpen(true);
|
||||
try {
|
||||
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载账户详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
@@ -64,22 +130,86 @@ export default function PartnerAccountsPage() {
|
||||
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
void loadTree();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
async function deleteSubAccount(row: AccountTreeRow) {
|
||||
await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
void loadTree();
|
||||
}
|
||||
|
||||
function openAddSub(parent: AccountTreeRow) {
|
||||
setSubParent(parent);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL' });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
function collectExpandableKeys(rows: AccountTreeRow[]): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.children?.length) {
|
||||
keys.push(row.id);
|
||||
keys.push(...collectExpandableKeys(row.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AccountTreeRow> = [
|
||||
{
|
||||
title: '姓名 / 类型',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, row) => (
|
||||
<AdminCellLine
|
||||
primary={row.name}
|
||||
secondary={
|
||||
row.parentAccountId
|
||||
? `子账号 · ${PARTNER_STAFF_ROLE_LABELS[row.staffRole as keyof typeof PARTNER_STAFF_ROLE_LABELS] || row.staffRole || '—'}`
|
||||
: row.isPrimary === 1
|
||||
? '主账号'
|
||||
: '合伙人账号'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140 },
|
||||
{ title: '主账号', dataIndex: 'isPrimary', width: 80, render: (v) => (v === 1 ? '是' : '否') },
|
||||
{
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.partner?.companyName || '—',
|
||||
},
|
||||
{
|
||||
title: '标识',
|
||||
width: 100,
|
||||
render: (_, row) =>
|
||||
row.parentAccountId ? (
|
||||
<Tag>子账号</Tag>
|
||||
) : row.isPrimary === 1 ? (
|
||||
<Tag color="blue">主账号</Tag>
|
||||
) : (
|
||||
<Tag>账号</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>编辑</Button>
|
||||
{!row.parentAccountId && row.isPrimary === 1 ? (
|
||||
<Button type="link" size="small" onClick={() => openAddSub(row)}>添加子账号</Button>
|
||||
) : null}
|
||||
{row.parentAccountId ? (
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => void deleteSubAccount(row)}>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -105,23 +235,70 @@ export default function PartnerAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<Typography.Text type="secondary">主账号下可展开/收起子账号,支持添加与删除子账号</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters({
|
||||
phone: v.phone?.trim() || undefined,
|
||||
status: v.status || undefined,
|
||||
partnerId: v.partnerId || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
placeholder="全部"
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onFocus={() => void loadPartners()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<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><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => setExpandedRowKeys(collectExpandableKeys(treeData))}>全部展开</Button>
|
||||
<Button onClick={() => setExpandedRowKeys([])}>全部收起</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Table<AccountTreeRow>
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]),
|
||||
defaultExpandAllRows: false,
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title="编辑开城合伙人账户"
|
||||
title={detail?.parentAccountId ? '合伙人子账号详情' : '开城合伙人账户详情'}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
extra={<Button type="primary" onClick={() => void saveAccount()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
@@ -130,10 +307,27 @@ export default function PartnerAccountsPage() {
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
key={detail.id}
|
||||
initialValues={{
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.partner?.companyName} disabled />
|
||||
</Form.Item>
|
||||
{detail.parentAccountId ? (
|
||||
<Form.Item label="账号类型">
|
||||
<Input
|
||||
value={`子账号 · ${PARTNER_STAFF_ROLE_LABELS[detail.staffRole as keyof typeof PARTNER_STAFF_ROLE_LABELS] || detail.staffRole || '—'}`}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
@@ -179,7 +373,7 @@ export default function PartnerAccountsPage() {
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
void loadTree();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
@@ -198,6 +392,46 @@ export default function PartnerAccountsPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!subParent) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: subParent.partner?.id,
|
||||
parentAccountId: subParent.id,
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
staffRole: v.staffRole,
|
||||
}),
|
||||
});
|
||||
message.success('子账号已创建');
|
||||
setSubOpen(false);
|
||||
setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]);
|
||||
void loadTree();
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user