城市合伙人端的修改(后台)

This commit is contained in:
2026-07-12 10:19:39 +08:00
parent d0f0fa09af
commit 7f031cc4c2
74 changed files with 5430 additions and 975 deletions
@@ -0,0 +1,645 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Cascader,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow;
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: string;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null;
storeCount: number;
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
/** 详情接口仍返回 children */
children?: SubRow[];
};
function toTableRows(items: Row[]): Row[] {
return items.map((row) => {
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
});
}
type CityOption = { id: string; name: string; code: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
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[];
}
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
const sum = orderPercent / 100 + redeemPercent / 100;
if (sum > maxRate + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
export default function CityPartnersPage() {
const [filterForm] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = 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.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
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 [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
const [cities, setCities] = useState<CityOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
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);
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
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 ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
return d;
}
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
}
async function refreshPartnerContext(parentId: string) {
if (detail?.id === parentId) {
await openPartner(parentId);
}
void reload();
}
async function deleteSubAccount(subId: string, parentId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshPartnerContext(parentId);
}
function openSubEdit(row: SubRow, parentId: string) {
setSubEditId(row.id);
setSubEditParentId(parentId);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true);
}
async function onCreateCityChange(cityId: string) {
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
}
const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 90,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 110,
render: (_, row) =>
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{
title: '绑定状态',
dataIndex: 'bindingStatus',
width: 80,
render: (v) => (
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
</Tag>
),
},
{
title: '管仓仓库',
dataIndex: 'managedWarehouseName',
width: 110,
ellipsis: true,
render: (v) => v || '—',
},
{ title: '门店', dataIndex: 'storeCount', width: 60 },
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
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={() => {
createForm.resetFields();
createForm.setFieldsValue({
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05);
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={
<>
{' '}
<Link to="/city-warehouses"></Link>
</>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }}
childrenColumnName="__noTreeChildren__"
expandable={{
expandedRowRender: (record) => (
<PartnerSubAccountList
subs={record.subAccounts ?? []}
onAdd={() => void openAddSubAccount(record.id)}
onEdit={(sub) => openSubEdit(sub, record.id)}
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
/>
),
rowExpandable: () => true,
columnWidth: 40,
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
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 && (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
<Link to="/city-warehouses"></Link>
</Typography.Text>
)}
</Descriptions.Item>
</Descriptions>
<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="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</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="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</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>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<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: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
/>
),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
createMaxRate,
);
if (err) {
message.error(err);
return;
}
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);
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => void onCreateCityChange(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 }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</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>
<Typography.Text type="secondary" style={{ display: 'block' }}>
+ {(createMaxRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
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);
await refreshPartnerContext(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="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑子账号"
open={subEditOpen}
onCancel={() => setSubEditOpen(false)}
onOk={async () => {
if (!subEditId) return;
const parentId = detail?.id ?? subEditParentId;
if (!parentId) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
await refreshPartnerContext(parentId);
}}
>
<Form form={subEditForm} 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="staffRole" label="角色">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}