feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市、修改用户关联合伙人请到「按用户分配」为具体账号勾选。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
Reference in New Issue
Block a user