407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Drawer,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Table,
|
|
Tabs,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import {
|
|
CITY_PARTNER_SCOPE_LABELS,
|
|
CityPartnerScopeType,
|
|
PARTNER_PERMISSION_KEYS,
|
|
PARTNER_PERMISSION_LABELS,
|
|
type PartnerPermissionKey,
|
|
} from '@dukang/shared-types';
|
|
import { request, type Paginated } from '../lib/api';
|
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|
import { useAdminList } from '../lib/useAdminList';
|
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
|
|
|
type Row = {
|
|
id: string;
|
|
companyName: string;
|
|
phone: string;
|
|
name: string;
|
|
contactPhone?: string | null;
|
|
cityId?: string | null;
|
|
cityName?: string | null;
|
|
scopeType?: string;
|
|
districtCodes?: string[] | null;
|
|
orderCommissionRate?: number;
|
|
redeemCommissionRate?: number;
|
|
storeCount: number;
|
|
accountCount: number;
|
|
createdAt: string;
|
|
};
|
|
|
|
type PartnerDetail = Row & {
|
|
address?: string;
|
|
districtCodes?: string[] | null;
|
|
bindingStatus?: string;
|
|
bankAccountName?: string | null;
|
|
bankAccountNo?: string | null;
|
|
bankBranch?: string | null;
|
|
managedWarehouseId?: string | null;
|
|
children?: Array<{
|
|
id: string;
|
|
phone: string;
|
|
name: string;
|
|
staffRole?: string;
|
|
permissions?: string[];
|
|
status: string;
|
|
}>;
|
|
};
|
|
|
|
type CityOption = { id: string; name: string; code: string };
|
|
type WarehouseOption = { id: string; name: string };
|
|
|
|
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
|
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
|
|
|
|
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
|
if (!values?.length) return [];
|
|
if (Array.isArray(values[0])) {
|
|
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
|
}
|
|
return values as string[];
|
|
}
|
|
|
|
export default function PartnersPage() {
|
|
const [form] = Form.useForm();
|
|
const [editForm] = Form.useForm();
|
|
const [createForm] = Form.useForm();
|
|
const [subForm] = Form.useForm();
|
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
|
'/admin/partners',
|
|
() => {
|
|
const qs = new URLSearchParams();
|
|
if (filters.companyName) qs.set('companyName', filters.companyName);
|
|
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
|
|
return qs;
|
|
},
|
|
[filters],
|
|
);
|
|
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [subOpen, setSubOpen] = useState(false);
|
|
const [cities, setCities] = useState<CityOption[]>([]);
|
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
|
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
|
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
|
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
|
|
|
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
|
|
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
|
|
|
|
function formatApiError(err: unknown): string | null {
|
|
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
|
if (!(err instanceof Error)) return '操作失败';
|
|
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
|
const label = districtCodeLabel(code);
|
|
return label !== code ? `${label}(${code})` : code;
|
|
});
|
|
}
|
|
|
|
const loadCities = useCallback(async () => {
|
|
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
|
setCities(res.items);
|
|
}, []);
|
|
|
|
const loadWarehouses = useCallback(async (cityId: string) => {
|
|
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
|
|
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadCities();
|
|
}, [loadCities]);
|
|
|
|
async function openPartner(id: string) {
|
|
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
|
setDetail(d);
|
|
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
|
if (d.cityId) await loadWarehouses(d.cityId);
|
|
editForm.setFieldsValue({
|
|
name: d.name,
|
|
phone: d.phone,
|
|
companyName: d.companyName,
|
|
contactPhone: d.contactPhone ?? d.phone,
|
|
address: d.address ?? '',
|
|
scopeType: d.scopeType,
|
|
districtCodes: d.districtCodes ?? [],
|
|
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
|
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
|
bindingStatus: d.bindingStatus,
|
|
managedWarehouseId: d.managedWarehouseId,
|
|
bankAccountName: d.bankAccountName ?? '',
|
|
bankAccountNo: d.bankAccountNo ?? '',
|
|
bankBranch: d.bankBranch ?? '',
|
|
});
|
|
setDrawerOpen(true);
|
|
}
|
|
|
|
async function savePartner() {
|
|
if (!detail) return;
|
|
try {
|
|
const v = await editForm.validateFields();
|
|
const body = {
|
|
...v,
|
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
|
};
|
|
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
|
message.success('已保存');
|
|
setDrawerOpen(false);
|
|
void reload();
|
|
} catch (e) {
|
|
const msg = formatApiError(e);
|
|
if (msg) message.error(msg);
|
|
}
|
|
}
|
|
|
|
const columns: ColumnsType<Row> = [
|
|
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
|
{
|
|
title: '区县',
|
|
dataIndex: 'districtCodes',
|
|
width: 160,
|
|
ellipsis: true,
|
|
render: (codes: string[] | null | undefined, row) =>
|
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
|
},
|
|
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
|
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
|
{
|
|
title: '管辖',
|
|
dataIndex: 'scopeType',
|
|
width: 100,
|
|
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
|
},
|
|
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
|
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
|
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
|
{
|
|
title: '操作',
|
|
width: 120,
|
|
render: (_, row) => (
|
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
|
管理
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
|
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
|
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
|
新建城市合伙人
|
|
</Button>
|
|
</Space>
|
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
|
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
|
<Form.Item name="contactPhone" label="电话"><Input allowClear /></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 ?? []}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total: data?.total ?? 0,
|
|
showSizeChanger: true,
|
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
|
}}
|
|
/>
|
|
|
|
<Drawer
|
|
title="城市合伙人"
|
|
width={640}
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
|
>
|
|
{detail && (
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'info',
|
|
label: '基本信息',
|
|
children: (
|
|
<Form form={editForm} layout="vertical">
|
|
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
|
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
|
</Form.Item>
|
|
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
|
<Form.Item
|
|
name="districtCodes"
|
|
label="区县"
|
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
|
>
|
|
<CityDistrictMultiSelect cityCode={editCityCode} />
|
|
</Form.Item>
|
|
)}
|
|
<Space style={{ width: '100%' }} size="large">
|
|
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
|
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
|
</Space>
|
|
<Form.Item name="managedWarehouseId" label="管仓仓库">
|
|
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
|
</Form.Item>
|
|
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
|
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
|
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
|
</Form>
|
|
),
|
|
},
|
|
{
|
|
key: 'staff',
|
|
label: '子账号',
|
|
children: (
|
|
<>
|
|
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
|
|
添加子账号
|
|
</Button>
|
|
<Table
|
|
size="small"
|
|
rowKey="id"
|
|
pagination={false}
|
|
dataSource={detail.children ?? []}
|
|
columns={[
|
|
{ title: '姓名', dataIndex: 'name' },
|
|
{ title: '手机', dataIndex: 'phone' },
|
|
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
|
{
|
|
title: '权限',
|
|
dataIndex: 'permissions',
|
|
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
|
|
},
|
|
]}
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</Drawer>
|
|
|
|
<Modal
|
|
title="新建城市合伙人"
|
|
open={createOpen}
|
|
width={560}
|
|
onCancel={() => setCreateOpen(false)}
|
|
onOk={async () => {
|
|
try {
|
|
const v = await createForm.validateFields();
|
|
await request('/admin/partners', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
...v,
|
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
|
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
|
}),
|
|
});
|
|
message.success('已创建');
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
void reload();
|
|
} catch (e) {
|
|
const msg = formatApiError(e);
|
|
if (msg) message.error(msg);
|
|
}
|
|
}}
|
|
>
|
|
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
|
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
|
<Select
|
|
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
|
onChange={(id) => {
|
|
setCreateCityId(id);
|
|
createForm.setFieldsValue({ districtCodes: undefined });
|
|
void loadWarehouses(id);
|
|
}}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
|
</Form.Item>
|
|
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
|
<Form.Item
|
|
name="districtCodes"
|
|
label="区县"
|
|
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
|
>
|
|
<CityDistrictMultiSelect cityCode={createCityCode} />
|
|
</Form.Item>
|
|
)}
|
|
<Space style={{ width: '100%' }} size="large">
|
|
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
|
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
|
</Space>
|
|
{createCityId && (
|
|
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
|
|
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
|
</Form.Item>
|
|
)}
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="添加子账号"
|
|
open={subOpen}
|
|
onCancel={() => setSubOpen(false)}
|
|
onOk={async () => {
|
|
if (!detail) return;
|
|
const v = await subForm.validateFields();
|
|
await request('/admin/partner-accounts', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
|
});
|
|
message.success('已创建');
|
|
setSubOpen(false);
|
|
void openPartner(detail.id);
|
|
}}
|
|
>
|
|
<Form form={subForm} layout="vertical">
|
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
|
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
|
<Form.Item name="permissions" label="权限">
|
|
<Select mode="multiple" options={PERM_OPTIONS} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|