c2914c37e5
CI / verify (pull_request) Has been cancelled
Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate. Co-authored-by: Cursor <cursoragent@cursor.com>
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } 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';
|
|
|
|
type PartnerOption = { id: string; companyName: string };
|
|
type ParentAccount = { id: string; name: string; phone: string };
|
|
|
|
type AccountTreeRow = {
|
|
id: string;
|
|
phone: string;
|
|
name: string;
|
|
status: string;
|
|
isPrimary: number;
|
|
staffRole?: string | null;
|
|
permissions?: string[] | null;
|
|
parentAccountId?: string | null;
|
|
parent?: ParentAccount | null;
|
|
createdAt: string;
|
|
lastLoginAt?: string | null;
|
|
companyName?: string | null;
|
|
children?: AccountTreeRow[];
|
|
};
|
|
|
|
type BillRow = {
|
|
id: string; billNo: string; totalAmount: number; orderCommission: number; redeemCommission: number;
|
|
status: string; periodStart: string; periodEnd: string; createdAt: string;
|
|
};
|
|
|
|
type OrderRow = {
|
|
id: string; orderNo: string; status: string; payAmount: number; createdAt: string;
|
|
user?: { userNo: string; phone: string | null };
|
|
};
|
|
|
|
type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] };
|
|
|
|
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({
|
|
value,
|
|
label,
|
|
}));
|
|
|
|
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
|
|
value: key,
|
|
label: PARTNER_PERMISSION_LABELS[key],
|
|
}));
|
|
|
|
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[] => {
|
|
const result: AccountTreeRow[] = [];
|
|
for (const row of list) {
|
|
const children = row.children?.length ? walk(row.children) : undefined;
|
|
if (match(row) || (children && children.length > 0)) {
|
|
result.push({ ...row, children: children?.length ? children : undefined });
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
return walk(rows);
|
|
}
|
|
|
|
function staffRoleLabel(role?: string | null) {
|
|
if (!role) return '-';
|
|
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
|
|
}
|
|
|
|
export default function PartnerAccountsPage() {
|
|
const [form] = Form.useForm();
|
|
const [editForm] = Form.useForm();
|
|
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 [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,
|
|
permissions: detail.permissions ?? [],
|
|
});
|
|
}, [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) {
|
|
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() {
|
|
if (!detail) return;
|
|
const v = await editForm.validateFields();
|
|
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
|
message.success('已保存');
|
|
setDrawerOpen(false);
|
|
void loadTree();
|
|
}
|
|
|
|
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', permissions: ['store:create', 'store:manage'] });
|
|
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
|
|
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
|
: '主账号'
|
|
}
|
|
/>
|
|
),
|
|
},
|
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
|
{
|
|
title: '开城合伙人',
|
|
width: 140,
|
|
ellipsis: true,
|
|
render: (_, row) => row.companyName || row.parent?.name || '—',
|
|
},
|
|
{
|
|
title: '账号类型',
|
|
width: 90,
|
|
render: (_, row) => (
|
|
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
|
|
{row.parentAccountId ? '子账号' : '主账号'}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: '角色',
|
|
dataIndex: 'staffRole',
|
|
width: 100,
|
|
render: (role) => staffRoleLabel(role),
|
|
},
|
|
{
|
|
title: '所属主账号',
|
|
width: 140,
|
|
render: (_, row) => (
|
|
row.parentAccountId && row.parent
|
|
? `${row.parent.name} / ${row.parent.phone}`
|
|
: '-'
|
|
),
|
|
},
|
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
|
{
|
|
title: '操作',
|
|
width: 180,
|
|
render: (_, row) => (
|
|
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
|
|
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
|
{!row.parentAccountId ? (
|
|
<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>
|
|
),
|
|
},
|
|
];
|
|
|
|
const billColumns: ColumnsType<BillRow> = [
|
|
{ title: '账单号', dataIndex: 'billNo', width: 140 },
|
|
{ title: '总额', dataIndex: 'totalAmount', width: 90, render: (v) => `¥${v}` },
|
|
{ title: '订单佣金', dataIndex: 'orderCommission', width: 90, render: (v) => `¥${v}` },
|
|
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 90, render: (v) => `¥${v}` },
|
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{PARTNER_BILL_STATUS_LABELS[s] || s}</Tag> },
|
|
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart)} ~ ${fmtTime(r.periodEnd)}` },
|
|
];
|
|
|
|
const orderColumns: ColumnsType<OrderRow> = [
|
|
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
|
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
|
{ title: '金额', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag> },
|
|
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
|
<div>
|
|
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
|
<Typography.Text type="secondary">
|
|
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
|
</Typography.Text>
|
|
</div>
|
|
</Space>
|
|
<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>
|
|
<Space>
|
|
<Button type="primary" htmlType="submit">查询</Button>
|
|
<Button onClick={() => setExpandedRowKeys(collectExpandableKeys(treeData))}>全部展开</Button>
|
|
<Button onClick={() => setExpandedRowKeys([])}>全部收起</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
<Table<AccountTreeRow>
|
|
rowKey="id"
|
|
className="admin-table-nowrap"
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={treeData}
|
|
scroll={{ x: 1200 }}
|
|
pagination={false}
|
|
expandable={{
|
|
expandedRowKeys,
|
|
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]),
|
|
defaultExpandAllRows: false,
|
|
}}
|
|
/>
|
|
<Drawer
|
|
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
|
|
width={720}
|
|
open={drawerOpen}
|
|
onClose={() => {
|
|
setDrawerOpen(false);
|
|
setDetail(null);
|
|
}}
|
|
destroyOnClose
|
|
extra={
|
|
detail?.parentAccountId
|
|
? <Button type="primary" onClick={() => void saveAccount()}>保存</Button>
|
|
: null
|
|
}
|
|
>
|
|
{detail && (
|
|
<Tabs items={[
|
|
{
|
|
key: 'info',
|
|
label: '基本信息',
|
|
children: (
|
|
<Form
|
|
form={editForm}
|
|
layout="vertical"
|
|
key={detail.id}
|
|
initialValues={{
|
|
name: detail.name,
|
|
phone: detail.phone,
|
|
status: detail.status,
|
|
permissions: detail.permissions ?? [],
|
|
}}
|
|
>
|
|
<Form.Item label="开城合伙人">
|
|
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
|
|
</Form.Item>
|
|
<Form.Item label="账号类型">
|
|
<Input
|
|
value={
|
|
detail.parentAccountId
|
|
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
|
|
: '主账号'
|
|
}
|
|
disabled
|
|
/>
|
|
</Form.Item>
|
|
{detail.parentAccountId && detail.parent ? (
|
|
<Form.Item label="所属主账号">
|
|
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
|
|
</Form.Item>
|
|
) : null}
|
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
|
|
<Form.Item
|
|
name="phone"
|
|
label="登录手机"
|
|
rules={[
|
|
{ required: true },
|
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
|
]}
|
|
>
|
|
<Input maxLength={11} disabled={!detail.parentAccountId} />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
|
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
|
</Form.Item>
|
|
{detail.parentAccountId ? (
|
|
<Form.Item name="permissions" label="权限">
|
|
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
|
</Form.Item>
|
|
) : null}
|
|
{!detail.parentAccountId ? (
|
|
<Typography.Text type="secondary">
|
|
主账号请在「开城合伙人」页面编辑。
|
|
</Typography.Text>
|
|
) : (
|
|
<Typography.Text type="secondary">
|
|
合伙人 H5 登录使用「登录手机」,与主账号联系电话可不同。
|
|
</Typography.Text>
|
|
)}
|
|
</Form>
|
|
),
|
|
},
|
|
{
|
|
key: 'bills',
|
|
label: `账单 (${detail.bills?.length ?? 0})`,
|
|
children: (
|
|
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
|
|
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
|
|
),
|
|
},
|
|
{
|
|
key: 'orders',
|
|
label: `名下订单 (${detail.orders?.length ?? 0})`,
|
|
children: (
|
|
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
|
|
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
|
|
),
|
|
},
|
|
]} />
|
|
)}
|
|
</Drawer>
|
|
<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({
|
|
parentAccountId: subParent.id,
|
|
name: v.name,
|
|
phone: v.phone,
|
|
staffRole: v.staffRole,
|
|
permissions: v.permissions,
|
|
}),
|
|
});
|
|
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.Item name="permissions" label="权限">
|
|
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|