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
+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}`}>