@@ -1,12 +1,10 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
type AdminCellLineProps = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
||||
/** 表格单元格:主/副信息同一行完整展示 */
|
||||
export function AdminCellLine({
|
||||
primary,
|
||||
secondary,
|
||||
@@ -14,14 +12,9 @@ export function AdminCellLine({
|
||||
}: AdminCellLineProps) {
|
||||
const main = primary?.trim() || '—';
|
||||
const sub = secondary?.trim();
|
||||
const full = sub ? `${main}${separator}${sub}` : main;
|
||||
|
||||
return (
|
||||
<Typography.Text
|
||||
className="admin-cell-line"
|
||||
ellipsis={{ tooltip: full }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<span className="admin-cell-line">
|
||||
{main}
|
||||
{sub ? (
|
||||
<span className="admin-cell-line-secondary">
|
||||
@@ -29,6 +22,6 @@ export function AdminCellLine({
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Checkbox, Modal, Space, Typography } from 'antd';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import type { ListColumnSettingItem } from '../lib/list-column-prefs';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
items: ListColumnSettingItem[];
|
||||
saving?: boolean;
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
onSave: (items: ListColumnSettingItem[]) => void;
|
||||
};
|
||||
|
||||
export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) {
|
||||
const [draft, setDraft] = useState<ListColumnSettingItem[]>(items);
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDraft(items);
|
||||
}, [open, items]);
|
||||
|
||||
function move(from: number, to: number) {
|
||||
if (to < 0 || to >= draft.length) return;
|
||||
const next = [...draft];
|
||||
const [row] = next.splice(from, 1);
|
||||
next.splice(to, 0, row);
|
||||
setDraft(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="列设置"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button onClick={onReset}>重置默认</Button>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => onSave(draft)}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
勾选显示列,拖拽或上下箭头调整顺序。序号与操作列固定,不在此列表中。
|
||||
</Typography.Paragraph>
|
||||
<div>
|
||||
{draft.map((item, index) => (
|
||||
<div
|
||||
key={item.key}
|
||||
draggable
|
||||
onDragStart={() => setDragKey(item.key)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (!dragKey || dragKey === item.key) return;
|
||||
const from = draft.findIndex((r) => r.key === dragKey);
|
||||
if (from >= 0) move(from, index);
|
||||
setDragKey(null);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'move',
|
||||
}}
|
||||
>
|
||||
<HolderOutlined style={{ color: '#999' }} />
|
||||
<Checkbox
|
||||
checked={item.visible}
|
||||
onChange={(e) => {
|
||||
const next = [...draft];
|
||||
next[index] = { ...item, visible: e.target.checked };
|
||||
setDraft(next);
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</Checkbox>
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
<Button type="link" size="small" disabled={index === 0} onClick={() => move(index, index - 1)}>
|
||||
上移
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={index === draft.length - 1}
|
||||
onClick={() => move(index, index + 1)}
|
||||
>
|
||||
下移
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,459 +1,458 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
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 { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
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 {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
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);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
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 loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
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: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<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="公司名"><Input placeholder="选填" /></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="地址"><Input placeholder="选填" /></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="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</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 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</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: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
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 loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="地址"><Input placeholder="选填" /></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="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</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 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</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);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<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 || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
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 { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
districtCodes?: string[] | null;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
cityCode?: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
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 {
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({
|
||||
cityId,
|
||||
cityCode,
|
||||
maxPartnerCommissionRate = 0.05,
|
||||
onChanged,
|
||||
}: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
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);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
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 loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{
|
||||
title: '区县',
|
||||
dataIndex: 'districtCodes',
|
||||
width: 160,
|
||||
|
||||
render: (codes: string[] | null | undefined, row) =>
|
||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<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="公司名"><Input placeholder="选填" /></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="地址"><Input placeholder="选填" /></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="区县"
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</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 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</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: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
try {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
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 loadPartners();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
const msg = formatApiError(e);
|
||||
if (msg) message.error(msg);
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="地址"><Input placeholder="选填" /></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="区县"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</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 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</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);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<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 || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,92 +1,91 @@
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user