feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
+4
View File
@@ -105,6 +105,10 @@ body.admin-col-resizing * {
width: 180px;
}
.admin-users-filter-item--keyword {
width: 240px;
}
.admin-orders-filter-item--status {
width: 220px;
min-width: 180px;
@@ -1,458 +1,465 @@
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>
</>
);
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 { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList';
import PartnerAssocPanel from './PartnerAssocPanel';
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({
...omitNullFields(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)}
/>
),
},
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]}
/>
)}
</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>
</>
);
}
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
function formatNicknameWithRemark(row: PartnerAssocUserItem) {
const name = row.nickname?.trim() || '—';
const remark = row.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
}
type PartnerAssocPanelProps = {
partnerId: string;
};
export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) {
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
const [users, setUsers] = useState<PartnerAssocUserItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [issuing, setIssuing] = useState(false);
const [canEditAssoc, setCanEditAssoc] = useState(false);
const loadSummary = useCallback(async () => {
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
setSummary(data);
}, [partnerId]);
const loadUsers = useCallback(async (p = 1) => {
setLoading(true);
try {
const res = await request<Paginated<PartnerAssocUserItem>>(
`/admin/partners/${partnerId}/assoc/users?page=${p}&pageSize=20`,
);
setUsers(res.items ?? []);
setTotal(res.total ?? 0);
setPage(p);
} finally {
setLoading(false);
}
}, [partnerId]);
useEffect(() => {
void request<HqProfile>('/admin/auth/me')
.then((p) => setCanEditAssoc((p.permissionKeys ?? []).includes('users_partner_assoc')))
.catch(() => setCanEditAssoc(false));
}, []);
useEffect(() => {
void loadSummary().catch((e) => message.error(e instanceof Error ? e.message : '加载关联码失败'));
void loadUsers(1).catch(() => undefined);
}, [loadSummary, loadUsers]);
async function reissue() {
setIssuing(true);
try {
await request(`/admin/partners/${partnerId}/assoc/qrcode`, { method: 'POST' });
message.success('已重新生成关联码');
await loadSummary();
} catch (e) {
message.error(e instanceof Error ? e.message : '生成失败');
} finally {
setIssuing(false);
}
}
async function unbind(userId: string) {
try {
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
message.success('已解绑');
await Promise.all([loadSummary(), loadUsers(page)]);
} catch (e) {
message.error(e instanceof Error ? e.message : '解绑失败');
}
}
const columns: ColumnsType<PartnerAssocUserItem> = [
{ title: '用户编号', dataIndex: 'userNo', width: 140 },
{
title: '用户',
dataIndex: 'nickname',
render: (_, row) => (
<Link
className="admin-primary-link"
to={`/users?userId=${encodeURIComponent(row.id)}`}
title="在用户列表中查看"
>
{formatNicknameWithRemark(row)}
</Link>
),
},
{ title: '手机', dataIndex: 'phone', render: (v) => v || '—' },
{ title: '关联时间', dataIndex: 'boundAt', render: (v) => (v ? fmtTime(v) : '—') },
{ title: '已付订单', dataIndex: 'orderCount', width: 90 },
...(canEditAssoc
? [
{
title: '操作',
width: 90,
render: (_: unknown, row: PartnerAssocUserItem) => (
<Popconfirm title="解绑后该用户新单不再计订单佣金" onConfirm={() => void unbind(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
} satisfies ColumnsType<PartnerAssocUserItem>[number],
]
: []),
];
return (
<div>
<Space align="start" style={{ marginBottom: 16 }} wrap>
{summary?.qrcodeUrl ? (
<img src={summary.qrcodeUrl} alt="关联码" style={{ width: 160, height: 160, background: '#fff' }} />
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
<div>
<Typography.Paragraph style={{ marginBottom: 8 }}>
<Link to={`/users?assocPartnerAccountId=${encodeURIComponent(partnerId)}`}>
{summary?.userCount ?? 0}
</Link>
</Typography.Paragraph>
<Button loading={issuing} onClick={() => void reissue()}>
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
</Button>
</div>
</Space>
<Table
rowKey="id"
size="small"
loading={loading}
columns={columns}
dataSource={users}
pagination={{
current: page,
pageSize: 20,
total,
onChange: (p) => void loadUsers(p),
}}
/>
</div>
);
}
@@ -54,6 +54,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [assocPartnerAccountId, setAssocPartnerAccountId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
@@ -168,6 +169,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined);
setAssocPartnerAccountId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
setPreview(null);
@@ -235,6 +237,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
assocPartnerAccountId: assocPartnerAccountId || undefined,
};
setSubmitting(true);
@@ -388,6 +391,21 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/>
</Form.Item>
<Form.Item label="关联合伙人(选填)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="不选则本单不计订单佣金"
value={assocPartnerAccountId}
onChange={setAssocPartnerAccountId}
options={(options?.partners ?? []).map((p) => ({
value: p.id,
label: `${p.companyName || p.name}${p.phone ? ` · ${p.phone}` : ''}`,
}))}
/>
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)">
<Select
@@ -103,6 +103,9 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
<Descriptions.Item label="评价">
{(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(detail.rating as { envScore?: number }).envScore ?? '—'}
{(detail.rating as { comment?: string | null }).comment
? ` · ${(detail.rating as { comment?: string }).comment}`
: ''}
</Descriptions.Item>
) : null}
</Descriptions>
+6
View File
@@ -183,6 +183,12 @@ export type AdminUserRow = {
benefitUsedAmount?: number;
/** 好客权益·剩余未使用 */
benefitBalance?: number;
assocPartner?: {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
} | null;
};
export type AdminOrderItem = {
+1
View File
@@ -16,6 +16,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
{ value: 'USER_ASSOC_UPDATE', label: '修改用户关联合伙人' },
{ value: 'USER_DELETE', label: '删除用户' },
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
{ value: 'ORDER_SHIP', label: '订单发货' },
@@ -0,0 +1,9 @@
/** 表单选填项常为 null,PUT 时去掉以免后端对 null 调 trim */
export function omitNullFields<T extends Record<string, unknown>>(input: T): Partial<T> {
const out: Partial<T> = {};
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
(out as Record<string, unknown>)[key] = value;
}
return out;
}
+34 -2
View File
@@ -31,11 +31,13 @@ import {
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { omitNullFields } from '../lib/omit-null-fields';
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';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -60,6 +62,7 @@ type Row = {
managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null;
storeCount: number;
assocUserCount?: number;
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
@@ -209,7 +212,7 @@ export default function CityPartnersPage() {
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
...omitNullFields(v),
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
@@ -332,6 +335,19 @@ export default function CityPartnersPage() {
</Link>
),
},
{
title: '关联用户',
dataIndex: 'assocUserCount',
width: 80,
render: (n: number, row) => (
<Link
to={`/users?assocPartnerAccountId=${encodeURIComponent(row.id)}`}
title={`查看「${row.companyName || row.phone}」关联用户`}
>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
@@ -355,7 +371,9 @@ export default function CityPartnersPage() {
title="城市合伙人"
settings={settingsButton}
actions={
<Button
<>
<Link to="/users?assocPartnerAccountId=any"></Link>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
@@ -372,6 +390,7 @@ export default function CityPartnersPage() {
>
</Button>
</>
}
/>
@@ -486,6 +505,14 @@ export default function CityPartnersPage() {
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="关联用户">
<Link
to={`/users?assocPartnerAccountId=${encodeURIComponent(detail.id)}`}
title="查看该城市合伙人关联用户"
>
{detail.assocUserCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
@@ -570,6 +597,11 @@ export default function CityPartnersPage() {
/>
),
},
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]}
/>
</>
@@ -244,7 +244,7 @@ export default function HqPermissionsPage() {
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
//
//
/
//
</Typography.Paragraph>
@@ -274,7 +274,7 @@ export default function HqPermissionsPage() {
<Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市、修改用户关联合伙人请到「按用户分配」为具体账号勾选。"
/>
) : (
<>
+66
View File
@@ -57,6 +57,7 @@ type ShipDefaults = {
};
type CityOption = { id: string; name: string; code: string };
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
type WarehouseOption = {
id: string;
@@ -93,6 +94,20 @@ type OrderRedeemSummary = {
redeemRecordSum: number;
};
type AssocPartnerBrief = {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
orderCommissionRate?: number | null;
};
function formatAssocPartner(p?: AssocPartnerBrief | null) {
if (!p) return '—';
const title = p.companyName || p.name;
return [title, p.phone].filter(Boolean).join(' / ') || '—';
}
type OrderDetail = AdminOrderRow & {
receiverAddress?: string;
receiverProvince?: string;
@@ -136,6 +151,11 @@ type OrderDetail = AdminOrderRow & {
lng?: number | null;
lat?: number | null;
} | null;
assocPartnerAtPay?: AssocPartnerBrief | null;
orderCommissionRateAtPay?: number | null;
user?: AdminOrderRow['user'] & {
assocPartner?: AssocPartnerBrief | null;
};
};
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
@@ -154,6 +174,7 @@ type OrderExportFilters = {
fulfillmentHold?: boolean;
deliveryType?: string;
promoCodeId?: string;
assocPartnerAccountId?: string;
dateRange?: [Dayjs, Dayjs];
};
@@ -261,6 +282,7 @@ function buildExportPayload(
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
if (filters.assocPartnerAccountId) payload.assocPartnerAccountId = filters.assocPartnerAccountId;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload;
@@ -330,6 +352,7 @@ export default function OrdersPage() {
const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [shipModalOpen, setShipModalOpen] = useState(false);
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
@@ -418,6 +441,7 @@ export default function OrdersPage() {
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
@@ -437,6 +461,9 @@ export default function OrdersPage() {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => {});
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
@@ -888,6 +915,30 @@ export default function OrdersPage() {
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
className="admin-orders-filter-item admin-orders-filter-item--status"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={[
{ value: 'none', label: '未关联' },
...partners.map((p) => ({
value: p.id,
label: formatAssocPartner({
id: p.id,
name: p.name || p.id,
companyName: p.companyName,
phone: p.phone,
}),
})),
]}
/>
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
@@ -1082,6 +1133,21 @@ export default function OrdersPage() {
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item>
) : null}
<Descriptions.Item label="关联合伙人(本单佣金)">
{detail.assocPartnerAtPay
? `${formatAssocPartner(detail.assocPartnerAtPay)}${
detail.assocPartnerAtPay.orderCommissionRate != null
? ` · 费率 ${(Number(detail.assocPartnerAtPay.orderCommissionRate) * 100).toFixed(2)}%`
: ''
}`
: '—'}
</Descriptions.Item>
{detail.user?.assocPartner &&
detail.user.assocPartner.id !== detail.assocPartnerAtPay?.id ? (
<Descriptions.Item label="用户当前关联">
{formatAssocPartner(detail.user.assocPartner)}
</Descriptions.Item>
) : null}
<Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
@@ -9,6 +9,7 @@ import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
type PartnerOption = { id: string; companyName: string };
@@ -413,6 +414,13 @@ export default function PartnerAccountsPage() {
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
),
},
...(!detail.parentAccountId
? [{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
}]
: []),
]} />
)}
</Drawer>
+95 -1
View File
@@ -12,6 +12,7 @@ import {
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
@@ -27,6 +28,19 @@ import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillItemRow = {
id: string;
kind: 'ORDER' | 'REDEEM';
refId: string;
refNo: string;
title?: string | null;
extra?: string | null;
baseAmount: number;
rate: number;
commission: number;
occurredAt: string;
};
type Row = {
id: string;
billNo: string;
@@ -39,6 +53,8 @@ type Row = {
rejectReason?: string | null;
paymentRef?: string | null;
paidAt?: string | null;
orderItems?: BillItemRow[];
redeemItems?: BillItemRow[];
partner?: {
companyName?: string;
phone?: string;
@@ -531,7 +547,7 @@ export default function PartnerBillsPage() {
title="合伙人账单明细"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={560}
width={720}
>
{detail && (
<>
@@ -562,6 +578,84 @@ export default function PartnerBillsPage() {
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
<Tabs
style={{ marginTop: 16 }}
items={[
{
key: 'orders',
label: `酒订单 (${detail.orderItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.orderItems ?? []}
columns={[
{ title: '订单号', dataIndex: 'refNo', width: 160 },
{ title: '商品', dataIndex: 'title' },
{ title: '数量', dataIndex: 'extra', width: 70 },
{
title: '实付',
dataIndex: 'baseAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
¥{Number(detail.orderCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
{
key: 'redeems',
label: `核销订单 (${detail.redeemItems?.length ?? 0})`,
children: (
<>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.redeemItems ?? []}
columns={[
{ title: '核销单号', dataIndex: 'refNo', width: 160 },
{ title: '门店', dataIndex: 'title' },
{
title: '核销额',
dataIndex: 'baseAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '费率',
dataIndex: 'rate',
render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`,
},
{
title: '佣金',
dataIndex: 'commission',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
]}
/>
<Typography.Paragraph style={{ textAlign: 'right', marginTop: 8 }}>
¥{Number(detail.redeemCommission).toFixed(2)}
</Typography.Paragraph>
</>
),
},
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
+8 -1
View File
@@ -23,6 +23,7 @@ import {
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { omitNullFields } from '../lib/omit-null-fields';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
@@ -30,6 +31,7 @@ import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import PartnerAssocPanel from '../components/PartnerAssocPanel';
type Row = {
@@ -162,7 +164,7 @@ export default function PartnersPage() {
try {
const v = await editForm.validateFields();
const body = {
...v,
...omitNullFields(v),
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
@@ -320,6 +322,11 @@ export default function PartnersPage() {
</>
),
},
{
key: 'assoc',
label: '关联码',
children: <PartnerAssocPanel partnerId={detail.id} />,
},
]}
/>
)}
@@ -13,6 +13,7 @@ type Row = {
id: string;
serviceScore: number;
envScore: number;
comment?: string | null;
createdAt: string;
redeemNo: string;
redeemAmount: number;
@@ -62,6 +63,12 @@ export default function StoreRatingsPage() {
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
{ title: '服务分', dataIndex: 'serviceScore', width: 80 },
{ title: '环境分', dataIndex: 'envScore', width: 80 },
{
title: '评语',
dataIndex: 'comment',
ellipsis: true,
render: (v: string | null) => v || '—',
},
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
];
+210 -57
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
@@ -17,12 +17,13 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
import { ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels';
type UserOrderRow = {
@@ -120,6 +121,24 @@ type UserBehaviorLog = {
extraJson?: Record<string, unknown> | null;
};
type AssocPartnerBrief = {
id: string;
name: string;
companyName?: string | null;
phone?: string | null;
};
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
const FILTERS_COLLAPSED_KEY = 'admin-users-filters-collapsed';
const ASSOC_UNBOUND = 'none';
function formatAssocPartner(p?: AssocPartnerBrief | PartnerOption | null) {
if (!p) return '—';
const title = p.companyName || p.name || p.id;
return [title, p.phone].filter(Boolean).join(' / ') || '—';
}
type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null;
@@ -128,6 +147,8 @@ type UserDetail = AdminUserRow & {
orders?: UserOrderRow[];
mergedFromCount?: number;
addressCount?: number;
assocPartner?: AssocPartnerBrief | null;
assocBoundAt?: string | null;
};
type BatchDeletePreviewItem = {
@@ -176,11 +197,36 @@ export default function UsersPage() {
const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
const canEditAssoc = (profile?.permissionKeys ?? []).includes('users_partner_assoc');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [assocPartnerId, setAssocPartnerId] = useState<string | undefined>();
const [savingAssoc, setSavingAssoc] = useState(false);
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const partnerOptions = useMemo(() => {
const map = new Map(partners.map((p) => [p.id, p]));
if (detail?.assocPartner && !map.has(detail.assocPartner.id)) {
map.set(detail.assocPartner.id, detail.assocPartner);
}
return [...map.values()];
}, [partners, detail?.assocPartner]);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
@@ -190,24 +236,32 @@ export default function UsersPage() {
if (userId) qs.set('userId', userId);
if (values.phone) qs.set('phone', values.phone);
if (values.userNo) qs.set('userNo', values.userNo);
if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
if (values.phoneVerified !== undefined && values.phoneVerified !== '') {
qs.set('phoneVerified', values.phoneVerified);
}
if (values.status !== undefined && values.status !== '') {
qs.set('status', String(values.status));
}
if (values.excludeTest) qs.set('excludeTest', 'true');
const keyword = String(values.keyword || '').trim();
if (keyword) qs.set('keyword', keyword);
const assocPartnerAccountId = String(
values.assocPartnerAccountId || searchParams.get('assocPartnerAccountId') || '',
).trim();
if (assocPartnerAccountId) qs.set('assocPartnerAccountId', assocPartnerAccountId);
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize]);
}, [form, page, pageSize, searchParams]);
useEffect(() => {
const userId = searchParams.get('userId');
if (userId) form.setFieldsValue({ userId });
const assocPartnerAccountId = searchParams.get('assocPartnerAccountId');
form.setFieldsValue({
...(userId ? { userId } : {}),
...(assocPartnerAccountId ? { assocPartnerAccountId } : {}),
});
}, [searchParams, form]);
useEffect(() => {
@@ -234,10 +288,29 @@ export default function UsersPage() {
),
]);
setDetail(res);
setAssocPartnerId(res.assocPartner?.id);
setBehaviorLogs(logs.items ?? []);
setDrawerOpen(true);
}
async function saveAssoc() {
if (!detail) return;
setSavingAssoc(true);
try {
await request(`/admin/users/${detail.id}/assoc`, {
method: 'PUT',
body: JSON.stringify({ partnerAccountId: assocPartnerId || null }),
});
message.success(assocPartnerId ? '已更新关联合伙人' : '已解除关联');
await openDetail(detail.id);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingAssoc(false);
}
}
function openDeleteModal() {
setDeleteConfirm('');
setDeleteOpen(true);
@@ -420,6 +493,12 @@ export default function UsersPage() {
width: 120,
render: (v) => v || '—',
},
{
title: '关联合伙人',
dataIndex: 'assocPartner',
width: 180,
render: (p: AdminUserRow['assocPartner']) => formatAssocPartner(p),
},
{
title: 'deviceKey',
dataIndex: 'deviceKey',
@@ -512,64 +591,106 @@ export default function UsersPage() {
/>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
layout="vertical"
onFinish={() => {
setPage(1);
const next = new URLSearchParams();
const userId = String(form.getFieldValue('userId') || '').trim();
const current = searchParams.get('userId') ?? '';
if (userId !== current) {
if (userId) setSearchParams({ userId }, { replace: true });
else setSearchParams({}, { replace: true });
const assoc = String(form.getFieldValue('assocPartnerAccountId') || '').trim();
if (userId) next.set('userId', userId);
if (assoc) next.set('assocPartnerAccountId', assoc);
if (next.toString() !== searchParams.toString()) {
setSearchParams(next, { replace: true });
}
void load();
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="模糊搜索" allowClear />
</Form.Item>
<Form.Item name="userNo" label="用户编号">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="userId" label="用户ID">
<Input placeholder="精确匹配" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="deviceKey" label="deviceKey">
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
</Form.Item>
<Form.Item name="phoneVerified" label="验手机">
<Select allowClear style={{ width: 100 }} options={[
{ value: '1', label: '已验证' },
{ value: '0', label: '访客' },
]} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 90 }} options={[
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
]} />
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (searchParams.get('userId')) {
setSearchParams({}, { replace: true });
} else {
void load();
}
}}
>
</Button>
</Space>
</Form.Item>
<div className="admin-orders-filter-row">
<Form.Item name="keyword" label="搜索" className="admin-orders-filter-item admin-users-filter-item--keyword">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item
name="assocPartnerAccountId"
label="关联合伙人"
className="admin-orders-filter-item admin-orders-filter-item--status"
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={[
{ value: 'any', label: '已关联(全部)' },
{ value: ASSOC_UNBOUND, label: '未关联' },
...partners.map((p) => ({
value: p.id,
label: formatAssocPartner(p),
})),
]}
/>
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (searchParams.toString()) {
setSearchParams({}, { replace: true });
} else {
void load();
}
}}
>
</Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<div className="admin-orders-filter-row">
<Form.Item name="phone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input placeholder="模糊搜索" allowClear />
</Form.Item>
<Form.Item name="userNo" label="用户编号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="userId" label="用户ID" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Input placeholder="精确匹配" allowClear />
</Form.Item>
<Form.Item name="phoneVerified" label="验手机" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={[
{ value: '1', label: '已验证' },
{ value: '0', label: '访客' },
]} />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={[
{ value: 1, label: '正常' },
{ value: 0, label: '停用' },
]} />
</Form.Item>
</div>
) : null}
</Form>
<Table
@@ -633,6 +754,38 @@ export default function UsersPage() {
) : '—'}
</Descriptions.Item>
<Descriptions.Item label="来源标签">{detail.sourceLabel || '—'}</Descriptions.Item>
<Descriptions.Item label="关联合伙人">
{canEditAssoc ? (
<Space wrap>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="未关联"
style={{ minWidth: 260 }}
value={assocPartnerId}
onChange={setAssocPartnerId}
options={partnerOptions.map((p) => ({
value: p.id,
label: `${p.companyName || p.name || p.id}${p.phone ? ` · ${p.phone}` : ''}`,
}))}
/>
<Button type="primary" size="small" loading={savingAssoc} onClick={() => void saveAssoc()}>
</Button>
<Typography.Text type="secondary"></Typography.Text>
</Space>
) : detail.assocPartner ? (
`${detail.assocPartner.companyName || detail.assocPartner.name}${
detail.assocPartner.phone ? ` / ${detail.assocPartner.phone}` : ''
}`
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="关联时间">
{detail.assocBoundAt ? fmtTime(detail.assocBoundAt) : '—'}
</Descriptions.Item>
{detail.sourcePromo && (
<Descriptions.Item label="推广码">
<Link to={`/promo-codes/${detail.sourcePromo.id}`}>