v3.5.9版本迭代列表优化
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-25 12:38:21 +08:00
parent 282da24bc4
commit f4da71e952
62 changed files with 5027 additions and 4202 deletions
+120 -12
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -9,6 +10,7 @@ import {
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
@@ -17,11 +19,22 @@ import {
import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CouponOrder = {
id?: string;
orderNo?: string;
productName?: string | null;
productSpec?: string | null;
quantity?: number | null;
saleUnit?: string | null;
deliveryType?: string | null;
payAmount?: number | string | null;
};
type Row = {
id: string;
@@ -32,10 +45,43 @@ type Row = {
status: string;
sourceProduct: string;
createdAt: string;
user?: { userNo: string; phone: string | null };
order?: { orderNo: string } | null;
user?: { id?: string; userNo: string; phone: string | null };
order?: CouponOrder | null;
};
function saleUnitLabel(unit?: string | null) {
if (unit === 'BOX') return '箱';
if (unit === 'BOTTLE') return '瓶';
return '';
}
function formatPayAmount(v?: number | string | null) {
if (v == null || v === '') return '';
const n = Number(v);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
}
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
const order = row.order;
if (!order?.orderNo && !order?.productName) {
return row.sourceProduct || '—';
}
const unit = saleUnitLabel(order.saleUnit);
const qty =
order.quantity != null ? `${order.quantity}${unit}` : '';
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
const amount = formatPayAmount(order.payAmount);
const parts = [
order.productName || row.sourceProduct,
order.productSpec,
qty,
delivery,
amount,
].filter((p) => p != null && String(p).trim() !== '');
return parts.join(' / ') || row.sourceProduct || '—';
}
type CouponRedeemRecord = {
id: string;
redeemNo: string;
@@ -60,11 +106,10 @@ type CouponRedeemSummary = {
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
};
export default function BenefitCouponsPage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -102,20 +147,61 @@ export default function BenefitCouponsPage() {
}
const baseColumns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
{
title: '券号',
dataIndex: 'couponNo',
width: 200,
ellipsis: false,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 120,
ellipsis: false,
render: (v: string | undefined, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{v}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
{
title: '订单',
dataIndex: ['order', 'orderNo'],
width: 180,
ellipsis: false,
render: (v) => v || '—',
render: (v: string | undefined) =>
v ? (
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
{v}
</AdminPrimaryLink>
) : (
'—'
),
},
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
{ title: '来源', dataIndex: 'sourceProduct' },
{
title: '来源',
dataIndex: 'sourceProduct',
width: 360,
ellipsis: false,
render: (_: string, row) => formatCouponSource(row),
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
@@ -276,15 +362,37 @@ export default function BenefitCouponsPage() {
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
>
{detail.user?.userNo}
</AdminPrimaryLink>
) : (
(detail.user?.userNo ?? '—')
)}
</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">
{detail.order?.orderNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
}
>
{detail.order.orderNo}
</AdminPrimaryLink>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -232,7 +233,14 @@ export default function CityWarehousesPage() {
</span>
),
},
{ title: '仓库', dataIndex: 'name', width: 140 },
{
title: '仓库',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '地址', dataIndex: 'address', width: 180 },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
+18 -1
View File
@@ -8,6 +8,7 @@ import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -62,7 +63,23 @@ export default function DeliveriesPage() {
}
const baseColumns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
{
title: '订单号',
dataIndex: ['order', 'orderNo'],
width: 170,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: 'provider', dataIndex: 'provider', width: 90 },
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
@@ -31,6 +31,7 @@ import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
@@ -243,7 +244,14 @@ export default function DevPlanTasksPage() {
}
const baseColumns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
{
title: '任务号',
dataIndex: 'taskNo',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '类型',
dataIndex: 'type',
@@ -23,6 +23,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -122,7 +123,9 @@ export default function DevPlanVersionsPage() {
}
const baseColumns: ColumnsType<DevPlanVersionDto> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
{ title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
) },
{
title: '状态',
dataIndex: 'status',
+15 -1
View File
@@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -59,7 +60,20 @@ export default function DomainEventsPage() {
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
{
title: '类型',
dataIndex: 'eventType',
width: 130,
render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
<Tag>{v}</Tag>
</AdminPrimaryLink>
),
},
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
@@ -29,6 +29,7 @@ import {
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
@@ -177,7 +178,13 @@ export default function FulfillmentProvidersPage() {
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' },
{
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '类型',
dataIndex: 'type',
+26 -1
View File
@@ -8,6 +8,7 @@ import { request, type HqProfile, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -69,7 +70,31 @@ export default function HqAccountsPage() {
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
const baseColumns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name' },
{
title: '姓名',
dataIndex: 'name',
render: (v, row) =>
isSuperAdmin ? (
<AdminPrimaryLink
onClick={() => {
setDetail(row);
editForm.setFieldsValue({
name: row.name,
phone: row.phone,
loginName: row.loginName,
adminRole: row.adminRole,
status: row.status,
cityIds: row.cityIds ?? [],
});
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
) : (
v
),
},
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 130 },
{
+209 -198
View File
@@ -1,204 +1,215 @@
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = {
id: string;
hqAccountId: string | null;
hqName: string | null;
hqPhone: string | null;
hqRole: string | null;
action: string | null;
actionLabel: string;
refType: string | null;
refId: string | null;
status: string | null;
remark: string | null;
detail: Record<string, unknown> | null;
createdAt: string;
};
export default function HqLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => ({
hqAccountId: searchParams.get('hqAccountId') ?? '',
action: searchParams.get('action') ?? '',
refType: searchParams.get('refType') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/logs/hq',
() => {
const qs = new URLSearchParams();
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
if (filters.action) qs.set('action', filters.action);
if (filters.refType) qs.set('refType', filters.refType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作人',
width: 200,
render: (_, r) => (
<AdminCellLine
primary={r.hqName}
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
),
},
{
title: '行为',
dataIndex: 'actionLabel',
width: 160,
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
},
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
</Button>
),
},
];
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
hqAccountId: string | null;
hqName: string | null;
hqPhone: string | null;
hqRole: string | null;
action: string | null;
actionLabel: string;
refType: string | null;
refId: string | null;
status: string | null;
remark: string | null;
detail: Record<string, unknown> | null;
createdAt: string;
};
export default function HqLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => ({
hqAccountId: searchParams.get('hqAccountId') ?? '',
action: searchParams.get('action') ?? '',
refType: searchParams.get('refType') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/logs/hq',
() => {
const qs = new URLSearchParams();
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
if (filters.action) qs.set('action', filters.action);
if (filters.refType) qs.set('refType', filters.refType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作人',
width: 200,
render: (_, r) => (
<AdminCellLine
primary={r.hqName}
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
),
},
{
title: '行为',
dataIndex: 'actionLabel',
width: 160,
render: (v, r) => (
<AdminPrimaryLink
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
</AdminPrimaryLink>
),
},
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
return (
<div>
<div>
{settingsModal}
<Typography.Title level={4}>HQ </Typography.Title>
<Typography.Title level={4}>HQ </Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
const qs = new URLSearchParams();
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
if (values.action) qs.set('action', values.action);
if (values.refType) qs.set('refType', values.refType);
setSearchParams(qs);
}}
>
<Form.Item name="hqAccountId" label="HQ 账户 ID">
<Input allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="action" label="行为">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</Form.Item>
<Form.Item name="refType" label="对象类型">
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => {
form.resetFields();
setFilters({ hqAccountId: '', action: '', refType: '' });
setSearchParams({});
setPage(1);
void reload();
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="操作人">
<AdminCellLine
primary={detail.hqName}
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
</Descriptions.Item>
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
<Descriptions.Item label="行为">
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
</Descriptions.Item>
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title>
<pre style={{
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
maxHeight: 400, overflow: 'auto', fontSize: 12,
}}
>
{JSON.stringify(detail.detail, null, 2)}
</pre>
</>
)}
</Drawer>
</div>
);
{settingsButton}
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
const qs = new URLSearchParams();
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
if (values.action) qs.set('action', values.action);
if (values.refType) qs.set('refType', values.refType);
setSearchParams(qs);
}}
>
<Form.Item name="hqAccountId" label="HQ 账户 ID">
<Input allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="action" label="行为">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</Form.Item>
<Form.Item name="refType" label="对象类型">
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => {
form.resetFields();
setFilters({ hqAccountId: '', action: '', refType: '' });
setSearchParams({});
setPage(1);
void reload();
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="操作人">
<AdminCellLine
primary={detail.hqName}
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
</Descriptions.Item>
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
<Descriptions.Item label="行为">
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
</Descriptions.Item>
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title>
<pre style={{
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
maxHeight: 400, overflow: 'auto', fontSize: 12,
}}
>
{JSON.stringify(detail.detail, null, 2)}
</pre>
</>
)}
</Drawer>
</div>
);
}
+8 -1
View File
@@ -31,6 +31,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -204,7 +205,13 @@ export default function InvoicesPage() {
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
: '—',
},
{ title: '名称', dataIndex: 'titleName' },
{
title: '名称',
dataIndex: 'titleName',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '状态',
width: 110,
@@ -26,6 +26,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
@@ -127,7 +128,13 @@ export default function KnowledgeBasesPage() {
};
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' },
{
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description' },
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
{
+8 -1
View File
@@ -26,6 +26,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
@@ -156,7 +157,13 @@ export default function LlmConfigsPage() {
};
const baseColumns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' },
{
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '提供商',
dataIndex: 'provider',
@@ -30,6 +30,7 @@ import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillRow = {
id: string;
@@ -246,7 +247,14 @@ export default function LogisticsBillsPage() {
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170 },
{
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '承运商',
width: 140,
+84 -46
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
@@ -27,10 +27,12 @@ import dayjs, { type Dayjs } from 'dayjs';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { downloadBase64File } from '../lib/exportExcel';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS,
ORDER_STATUS_OPERATOR_LABELS,
@@ -273,6 +275,7 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
}
export default function OrdersPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm();
@@ -593,63 +596,98 @@ export default function OrdersPage() {
const baseColumns: ColumnsType<AdminOrderRow> = [
{
title: '商品',
width: 240,
render: (_, row) => (
<div>
<Space size={4} wrap>
<span>{row.productName || '—'}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
.filter(Boolean)
.join(' ')}
</Typography.Text>
</div>
</div>
title: '订单号',
dataIndex: 'orderNo',
width: 180,
render: (v, row) => (
<Space size={4}>
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{
title: '状态 / 实付',
width: 130,
title: '用户',
key: 'user',
width: 120,
render: (_, row) =>
row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{row.user.userNo}
</AdminPrimaryLink>
) : (
(row.user?.userNo || '—')
),
},
{
title: '商品',
key: 'productName',
width: 180,
render: (_, row) => (
<div>
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
{ORDER_STATUS_LABELS[row.status] || row.status}
</Tag>
<div>¥{row.payAmount}</div>
</div>
<Space size={4} wrap>
<span>{row.productName || '—'}</span>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
),
},
{
title: '规格',
dataIndex: 'productSpec',
width: 140,
render: (v: string | undefined) => v || '—',
},
{
title: '数量',
dataIndex: 'quantity',
width: 80,
render: (v: number | undefined, row) =>
v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`,
},
{
title: '配送方式',
dataIndex: 'deliveryType',
width: 100,
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: string) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v: number) => `¥${v}`,
},
{
title: '好客权益',
width: 200,
render: (_, row) => formatBenefitBrief(row),
},
{
title: '收货信息',
width: 240,
render: (_, row) => {
const address = formatReceiverAddress(row);
return (
<div>
<div>
{row.receiverName || '—'} {row.receiverPhone || ''}
</div>
{address ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{address}
</Typography.Text>
) : null}
</div>
);
},
title: '收货',
dataIndex: 'receiverName',
width: 90,
render: (v: string | undefined) => v || '—',
},
{
title: '电话',
dataIndex: 'receiverPhone',
width: 120,
render: (v: string | undefined) => v || '—',
},
{
title: '地址',
key: 'receiverAddress',
width: 260,
render: (_, row) => formatReceiverAddress(row) || '—',
},
{
title: '下单时间',
@@ -5,7 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
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';
@@ -176,14 +176,13 @@ export default function PartnerAccountsPage() {
title: '姓名 / 类型',
width: 200,
render: (_, row) => (
<AdminCellLine
primary={row.name}
secondary={
row.parentAccountId
? `子账号 · ${staffRoleLabel(row.staffRole)}`
: '主账号'
}
/>
<span className="admin-cell-line">
<AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
<span className="admin-cell-line-secondary">
{' · '}
{row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
</span>
</span>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
File diff suppressed because it is too large Load Diff
+246 -231
View File
@@ -1,237 +1,252 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
PARTNER_STAFF_ROLE_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
type PartnerStaffRole,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
PARTNER_STAFF_ROLE_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
type PartnerStaffRole,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = {
id: string;
partnerId: string;
partnerAccountId: string | null;
accountName: string | null;
accountPhone: string | null;
companyName: string | null;
isSubAccount?: boolean;
staffRole?: string | null;
category: PartnerLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
export default function PartnerLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
partnerId: searchParams.get('partnerId') ?? '',
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
companyName: searchParams.get('companyName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/partners',
() => {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '合伙人',
width: 180,
render: (_, r) => (
r.isSubAccount ? (
<Tag color="blue"></Tag>
) : (
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
)
),
},
{
title: '账号',
width: 150,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
secondary={
r.isSubAccount && r.staffRole
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
: r.accountPhone || r.partnerAccountId
}
/>
),
},
{
title: '分类',
dataIndex: 'category',
width: 100,
render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 180 },
{
title: '关联',
width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
partnerId: string;
partnerAccountId: string | null;
accountName: string | null;
accountPhone: string | null;
companyName: string | null;
isSubAccount?: boolean;
staffRole?: string | null;
category: PartnerLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
export default function PartnerLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
partnerId: searchParams.get('partnerId') ?? '',
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
companyName: searchParams.get('companyName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/partners',
() => {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '合伙人',
width: 180,
render: (_, r) => (
r.isSubAccount ? (
<Tag color="blue"></Tag>
) : (
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
)
),
},
{
title: '账号',
width: 150,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
secondary={
r.isSubAccount && r.staffRole
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
: r.accountPhone || r.partnerAccountId
}
/>
),
},
{
title: '分类',
dataIndex: 'category',
width: 100,
render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
),
},
{
title: '事件',
dataIndex: 'eventName',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '关联',
width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
return (
<div>
return (
<div>
{settingsModal}
<Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title>
{settingsButton}
<Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (v) next.set('category', String(v));
else next.delete('category');
return next;
});
}}
style={{ marginBottom: 16 }}
/>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID">
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="eventName" label="事件名">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setFilters({
partnerId: '',
partnerAccountId: '',
phone: '',
companyName: '',
eventName: '',
});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 'max-content' }}
/>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<Descriptions column={1} bordered size="small">
{Object.entries(detail).map(([k, v]) => (
<Descriptions.Item key={k} label={k}>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
</Descriptions.Item>
))}
</Descriptions>
)}
</Drawer>
{settingsButton}
<Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (v) next.set('category', String(v));
else next.delete('category');
return next;
});
}}
style={{ marginBottom: 16 }}
/>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID">
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="eventName" label="事件名">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setFilters({
partnerId: '',
partnerAccountId: '',
phone: '',
companyName: '',
eventName: '',
});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 'max-content' }}
/>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<Descriptions column={1} bordered size="small">
{Object.entries(detail).map(([k, v]) => (
<Descriptions.Item key={k} label={k}>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
</Descriptions.Item>
))}
</Descriptions>
)}
</Drawer>
</div>
);
}
+411 -404
View File
@@ -1,410 +1,417 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CityPartnerScopeType,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CityPartnerScopeType,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
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 { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
storeCount: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = { id: string; name: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
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[];
}
export default function PartnersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
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;
});
}
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
if (d.cityId) await loadWarehouses(d.cityId);
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,
managedWarehouseId: d.managedWarehouseId,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 100 },
{
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: 130 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 120,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
storeCount: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = { id: string; name: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
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[];
}
export default function PartnersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
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;
});
}
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
if (d.cityId) await loadWarehouses(d.cityId);
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,
managedWarehouseId: d.managedWarehouseId,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 100 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{
title: '公司名',
dataIndex: 'companyName',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{ title: '主账号', dataIndex: 'phone', width: 130 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 120,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
return (
<div>
<div>
{settingsModal}
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<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="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={editCityCode} />
</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>
<Form.Item name="managedWarehouseId" label="管仓仓库">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</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: '子账号',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
</Button>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.children ?? []}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{
title: '权限',
dataIndex: 'permissions',
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
},
]}
/>
</>
),
},
]}
/>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
try {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/>
</Form.Item>
<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={createCityCode} />
</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>
{createCityId && (
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
)}
</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);
}}
>
<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="permissions" label="权限">
<Select mode="multiple" options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
{settingsButton}
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<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="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={editCityCode} />
</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>
<Form.Item name="managedWarehouseId" label="管仓仓库">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</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: '子账号',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
</Button>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.children ?? []}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{
title: '权限',
dataIndex: 'permissions',
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
},
]}
/>
</>
),
},
]}
/>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
try {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/>
</Form.Item>
<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={createCityCode} />
</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>
{createCityId && (
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
)}
</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);
}}
>
<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="permissions" label="权限">
<Select mode="multiple" options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+273 -265
View File
@@ -1,271 +1,279 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem,
type RedeemPendingStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem,
type RedeemPendingStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
COMPLETED: 'green',
REJECTED: 'default',
};
export default function PendingRedeemPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
'/admin/redeem-pending',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
async function openDetail(id: string) {
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res);
setDrawerOpen(true);
}
async function complete() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
message.success('已补核销');
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '补核销失败');
} finally {
setActing(false);
}
}
async function reject() {
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '驳回失败');
} finally {
setActing(false);
}
}
const baseColumns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
{
title: '核销码 ID',
dataIndex: 'redeemToken',
width: 160,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
{
title: '用户',
width: 120,
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
},
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: RedeemPendingStatus) => (
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
COMPLETED: 'green',
REJECTED: 'default',
};
export default function PendingRedeemPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
'/admin/redeem-pending',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
async function openDetail(id: string) {
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res);
setDrawerOpen(true);
}
async function complete() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
message.success('已补核销');
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '补核销失败');
} finally {
setActing(false);
}
}
async function reject() {
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '驳回失败');
} finally {
setActing(false);
}
}
const baseColumns: ColumnsType<RedeemPendingItem> = [
{
title: '待处理单号',
dataIndex: 'pendingNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '核销码 ID',
dataIndex: 'redeemToken',
width: 160,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
{
title: '用户',
width: 120,
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
},
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: RedeemPendingStatus) => (
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
return (
<div>
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="pendingNo" label="待处理单号">
<Input allowClear />
</Form.Item>
<Form.Item name="redeemToken" label="核销码 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="待处理核销详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' && (
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
</Button>
</Space>
)
}
>
{detail && (
<>
{detail.photoUrl && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
</div>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions.Item label="核销码 ID">
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="门店">
{detail.store?.name || '—'}{detail.store?.id}
</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
</Descriptions.Item>
<Descriptions.Item label="关联核销单">
{detail.redeemRecord?.redeemNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="处理时间">
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
<Modal
title="驳回待处理单"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
onOk={() => void reject()}
onCancel={() => setRejectOpen(false)}
>
<Input.TextArea
rows={3}
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
{settingsButton}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="pendingNo" label="待处理单号">
<Input allowClear />
</Form.Item>
<Form.Item name="redeemToken" label="核销码 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="待处理核销详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' && (
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
</Button>
</Space>
)
}
>
{detail && (
<>
{detail.photoUrl && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
</div>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions.Item label="核销码 ID">
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="门店">
{detail.store?.name || '—'}{detail.store?.id}
</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
</Descriptions.Item>
<Descriptions.Item label="关联核销单">
{detail.redeemRecord?.redeemNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="处理时间">
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
<Modal
title="驳回待处理单"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
onOk={() => void reject()}
onCancel={() => setRejectOpen(false)}
>
<Input.TextArea
rows={3}
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
@@ -14,6 +14,7 @@ import DetailImageUrlList from '../components/DetailImageUrlList';
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = ProductDetailTemplateDto;
@@ -140,7 +141,23 @@ export default function ProductDetailTemplatesPage() {
const baseColumns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', width: 120 },
{
title: '名称',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapToForm(d));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description', width: 200 },
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
+9 -1
View File
@@ -15,6 +15,7 @@ import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePick
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ProductDetailContentDto = {
@@ -391,7 +392,14 @@ export default function ProductsPage() {
const baseColumns: ColumnsType<Row> = useMemo(() => [
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '品名', dataIndex: 'name', width: 200 },
{ title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
<AdminPrimaryLink onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}>{v}</AdminPrimaryLink>
) },
{
title: '累计销售',
dataIndex: 'soldBottles',
+9 -1
View File
@@ -15,6 +15,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = PromoCodeItem;
@@ -77,7 +78,14 @@ export default function PromoCodesPage() {
}, []);
const baseColumns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160 },
{
title: '名称',
dataIndex: 'name',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
),
},
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
@@ -8,6 +8,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -84,7 +85,7 @@ export default function RedeemRecordsPage() {
width: 200,
render: (v, row) => (
<span>
{v}
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
@@ -7,6 +7,7 @@ import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
@@ -83,7 +84,14 @@ export default function StoreAccountsPage() {
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CategoryNode = {
@@ -130,7 +131,7 @@ export default function StoreCategoriesPage() {
render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name}
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</span>
),
},
+18 -1
View File
@@ -13,6 +13,7 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -103,7 +104,23 @@ export default function StoreLogsPage() {
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 160 },
{
title: '事件',
dataIndex: 'eventName',
width: 160,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
const parsed = parseCompositeId(row.id);
if (!parsed) return;
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '来源', dataIndex: 'source', width: 100,
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
+17 -1
View File
@@ -8,6 +8,7 @@ import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/cons
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -43,7 +44,22 @@ export default function StoreMediaPage() {
}
const baseColumns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
{
title: '门店',
dataIndex: ['store', 'name'],
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditing(row);
editForm.setFieldsValue(row);
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
{
title: '预览', dataIndex: 'url', width: 100,
@@ -33,6 +33,7 @@ import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
@@ -199,7 +200,15 @@ function InfoChangeAuditPanel({
}
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -471,7 +480,15 @@ export default function StorePackageAuditsPage() {
}
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -21,6 +21,7 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -156,7 +157,7 @@ export default function StoreWithdrawalsPage() {
width: 180,
render: (v, row) => (
<Space>
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null}
</Space>
),
+2 -1
View File
@@ -40,6 +40,7 @@ import {
} from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
@@ -739,7 +740,7 @@ export default function StoresPage() {
const name = v || '—';
return (
<Space size={4} wrap={false}>
<span>{name}</span>
<AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
);
@@ -47,6 +47,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
@@ -505,7 +506,13 @@ export default function SupportTicketsPage() {
</Space>
),
},
{ title: '标题', dataIndex: 'title' },
{
title: '标题',
dataIndex: 'title',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
+17 -1
View File
@@ -21,6 +21,7 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
@@ -254,7 +255,22 @@ export default function TestWhitelistPage() {
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '手机号',
dataIndex: 'phone',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '备注',
dataIndex: 'note',
@@ -5,6 +5,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -95,7 +96,21 @@ export default function ThirdPartyLogsPage() {
title: '请求摘要',
render: (_, r) => summarizeJson(r.requestBody),
},
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' },
{
title: '外部单号',
dataIndex: 'externalNo',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/common/third-party-logs/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '操作',
width: 80,
+16 -1
View File
@@ -26,6 +26,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -118,7 +119,21 @@ export default function TicketsPage() {
}
const baseColumns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{
title: '工单号',
dataIndex: 'ticketNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '类型',
dataIndex: 'ticketType',
+16 -1
View File
@@ -8,6 +8,7 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -80,7 +81,21 @@ export default function UserLogsPage() {
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 160 },
{
title: '事件',
dataIndex: 'eventName',
width: 160,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/logs/users/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
+4 -1
View File
@@ -20,6 +20,7 @@ import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = {
@@ -332,7 +333,9 @@ export default function UsersPage() {
title: '昵称',
dataIndex: 'nickname',
width: 140,
render: (v: string | null) => v || '—',
render: (v: string | null, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '备注',
+315 -312
View File
@@ -1,318 +1,321 @@
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime, maskPhone } from '../lib/constants';
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
type Identity = {
actorType: ActorType;
actorId: string;
phone: string | null;
name: string | null;
wxOpenId: string;
wxUnionId: string | null;
phoneVerified?: boolean;
refLabel: string | null;
refId: string | null;
lastLoginAt: string | null;
status: string | number;
};
type GroupRow = {
groupKey: string;
unionId: string | null;
identityCount: number;
actorTypes: ActorType[];
multiRole: boolean;
primaryPhone: string | null;
latestLoginAt: string | null;
identities: Identity[];
};
const ACTOR_TYPE_LABELS: Record<ActorType, string> = {
USER: 'C 端用户',
STORE: '门店账号',
PARTNER: '合伙人账号',
HQ: 'HQ 账号',
};
const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
USER: 'blue',
STORE: 'green',
PARTNER: 'orange',
HQ: 'purple',
};
function renderActorTags(types: ActorType[]) {
return types.map((t) => (
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
{ACTOR_TYPE_LABELS[t]}
</Tag>
));
}
export default function WechatBindingsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
actorType: '',
phone: '',
unionId: '',
openId: '',
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
'/admin/wechat-bindings',
() => {
const qs = new URLSearchParams();
if (filters.actorType) qs.set('actorType', filters.actorType);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.unionId) qs.set('unionId', filters.unionId);
if (filters.openId) qs.set('openId', filters.openId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<GroupRow | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
async function openDetail(row: GroupRow) {
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
setDetail(res);
setDrawerOpen(true);
}
const baseColumns: ColumnsType<GroupRow> = [
{
title: 'unionId',
dataIndex: 'unionId',
width: 180,
render: (v) => v || <Tag> unionId</Tag>,
},
{
title: '身份数',
dataIndex: 'identityCount',
width: 90,
render: (v, r) => (
<Space size={4}>
<span>{v}</span>
{r.multiRole ? <Tag color="red"></Tag> : null}
</Space>
),
},
{
title: '端类型',
dataIndex: 'actorTypes',
width: 220,
render: (types: ActorType[]) => renderActorTags(types),
},
{
title: '手机号',
dataIndex: 'primaryPhone',
width: 140,
render: (v) => maskPhone(v),
},
{
title: '身份摘要',
render: (_, r) => (
<AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
.filter(Boolean)
.join(' · ')}
/>
),
},
{
title: '最近登录',
dataIndex: 'latestLoginAt',
width: 160,
render: fmtTime,
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
),
},
];
const identityColumns: ColumnsType<Identity> = [
{
title: '端类型',
dataIndex: 'actorType',
width: 120,
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
},
{
title: '账号',
render: (_, r) => (
<AdminCellLine
primary={r.name || ''}
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
/>
),
},
{
title: '归属',
dataIndex: 'refLabel',
width: 160,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
},
{
title: 'wxOpenId',
dataIndex: 'wxOpenId',
width: 160,
},
{
title: '手机验证',
width: 90,
render: (_, r) =>
r.actorType === 'USER' ? (
r.phoneVerified ? <Tag color="blue"></Tag> : <Tag></Tag>
) : (
'—'
),
},
{
title: '最近登录',
dataIndex: 'lastLoginAt',
width: 160,
render: fmtTime,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v) => String(v),
},
];
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
type Identity = {
actorType: ActorType;
actorId: string;
phone: string | null;
name: string | null;
wxOpenId: string;
wxUnionId: string | null;
phoneVerified?: boolean;
refLabel: string | null;
refId: string | null;
lastLoginAt: string | null;
status: string | number;
};
type GroupRow = {
groupKey: string;
unionId: string | null;
identityCount: number;
actorTypes: ActorType[];
multiRole: boolean;
primaryPhone: string | null;
latestLoginAt: string | null;
identities: Identity[];
};
const ACTOR_TYPE_LABELS: Record<ActorType, string> = {
USER: 'C 端用户',
STORE: '门店账号',
PARTNER: '合伙人账号',
HQ: 'HQ 账号',
};
const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
USER: 'blue',
STORE: 'green',
PARTNER: 'orange',
HQ: 'purple',
};
function renderActorTags(types: ActorType[]) {
return types.map((t) => (
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
{ACTOR_TYPE_LABELS[t]}
</Tag>
));
}
export default function WechatBindingsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
actorType: '',
phone: '',
unionId: '',
openId: '',
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
'/admin/wechat-bindings',
() => {
const qs = new URLSearchParams();
if (filters.actorType) qs.set('actorType', filters.actorType);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.unionId) qs.set('unionId', filters.unionId);
if (filters.openId) qs.set('openId', filters.openId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<GroupRow | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
async function openDetail(row: GroupRow) {
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
setDetail(res);
setDrawerOpen(true);
}
const baseColumns: ColumnsType<GroupRow> = [
{
title: 'unionId',
dataIndex: 'unionId',
width: 180,
render: (v) => v || <Tag> unionId</Tag>,
},
{
title: '身份数',
dataIndex: 'identityCount',
width: 90,
render: (v, r) => (
<Space size={4}>
<span>{v}</span>
{r.multiRole ? <Tag color="red"></Tag> : null}
</Space>
),
},
{
title: '端类型',
dataIndex: 'actorTypes',
width: 220,
render: (types: ActorType[]) => renderActorTags(types),
},
{
title: '手机号',
dataIndex: 'primaryPhone',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
),
},
{
title: '身份摘要',
render: (_, r) => (
<AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities
.map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
.filter(Boolean)
.join(' · ')}
/>
),
},
{
title: '最近登录',
dataIndex: 'latestLoginAt',
width: 160,
render: fmtTime,
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
),
},
];
const identityColumns: ColumnsType<Identity> = [
{
title: '端类型',
dataIndex: 'actorType',
width: 120,
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
},
{
title: '账号',
render: (_, r) => (
<AdminCellLine
primary={r.name || '—'}
secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
/>
),
},
{
title: '归属',
dataIndex: 'refLabel',
width: 160,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
},
{
title: 'wxOpenId',
dataIndex: 'wxOpenId',
width: 160,
},
{
title: '手机验证',
width: 90,
render: (_, r) =>
r.actorType === 'USER' ? (
r.phoneVerified ? <Tag color="blue"></Tag> : <Tag></Tag>
) : (
'—'
),
},
{
title: '最近登录',
dataIndex: 'lastLoginAt',
width: 160,
render: fmtTime,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v) => String(v),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
return (
return (
<div>
{settingsModal}
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary">
unionId C HQ unionId
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setPage(1);
setFilters({
actorType: values.actorType ?? '',
phone: values.phone?.trim() ?? '',
unionId: values.unionId?.trim() ?? '',
openId: values.openId?.trim() ?? '',
});
}}
>
<Form.Item name="actorType" label="端类型">
<Select
allowClear
placeholder="全部"
style={{ width: 140 }}
options={[
{ value: 'USER', label: 'C 端用户' },
{ value: 'STORE', label: '门店账号' },
{ value: 'PARTNER', label: '合伙人账号' },
{ value: 'HQ', label: 'HQ 账号' },
]}
/>
</Form.Item>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="unionId" label="unionId">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="openId" label="openId">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
}}
>
</Button>
<Button onClick={() => void reload()}></Button>
</Space>
</Form.Item>
</Form>
<Table<GroupRow>
rowKey="groupKey"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="微信绑定详情"
width={960}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
<Descriptions.Item label="一人多角色">
{detail.multiRole ? <Tag color="red"></Tag> : <Tag></Tag>}
</Descriptions.Item>
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
</Descriptions>
<Table<Identity>
rowKey={(r) => `${r.actorType}-${r.actorId}`}
size="small"
columns={identityColumns}
dataSource={detail.identities}
pagination={false}
/>
</>
) : null}
</Drawer>
</div>
);
}
{settingsButton}
<Typography.Paragraph type="secondary">
unionId C HQ unionId
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setPage(1);
setFilters({
actorType: values.actorType ?? '',
phone: values.phone?.trim() ?? '',
unionId: values.unionId?.trim() ?? '',
openId: values.openId?.trim() ?? '',
});
}}
>
<Form.Item name="actorType" label="端类型">
<Select
allowClear
placeholder="全部"
style={{ width: 140 }}
options={[
{ value: 'USER', label: 'C 端用户' },
{ value: 'STORE', label: '门店账号' },
{ value: 'PARTNER', label: '合伙人账号' },
{ value: 'HQ', label: 'HQ 账号' },
]}
/>
</Form.Item>
<Form.Item name="phone" label="手机号">
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="unionId" label="unionId">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="openId" label="openId">
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
}}
>
</Button>
<Button onClick={() => void reload()}></Button>
</Space>
</Form.Item>
</Form>
<Table<GroupRow>
rowKey="groupKey"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="微信绑定详情"
width={960}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
<Descriptions.Item label="一人多角色">
{detail.multiRole ? <Tag color="red"></Tag> : <Tag></Tag>}
</Descriptions.Item>
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
</Descriptions>
<Table<Identity>
rowKey={(r) => `${r.actorType}-${r.actorId}`}
size="small"
columns={identityColumns}
dataSource={detail.identities}
pagination={false}
/>
</>
) : null}
</Drawer>
</div>
);
}
@@ -6,6 +6,7 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import type { WecomBotLogDto } from '@dukang/shared-types';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
export default function WecomBotLogsPage() {
@@ -28,7 +29,14 @@ export default function WecomBotLogsPage() {
const baseColumns: ColumnsType<WecomBotLogDto> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: '机器人', dataIndex: 'botName', width: 120, render: (v, r) => v || r.botKey || '—' },
{
title: '机器人',
dataIndex: 'botName',
width: 120,
render: (v, r) => (
<AdminPrimaryLink onClick={() => setDetail(r)}>{v || r.botKey || ''}</AdminPrimaryLink>
),
},
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
{ title: '动作', dataIndex: 'action', width: 160 },
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
+9 -1
View File
@@ -37,6 +37,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
@@ -293,7 +294,14 @@ export default function WecomBotsPage() {
</Avatar>
),
},
{ title: '名称', dataIndex: 'name', width: 140 },
{
title: '名称',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '角色',
dataIndex: 'role',
@@ -35,6 +35,7 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
@@ -202,7 +203,7 @@ function PushRoutesTab() {
render: (name: string, row) => (
<Space>
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
<span>{name}</span>
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</Space>
),
},
+9 -1
View File
@@ -27,6 +27,7 @@ import { downloadExcelCsv } from '../lib/exportExcel';
import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
@@ -238,7 +239,14 @@ export default function WineryBillsPage() {
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
const baseColumns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170 },
{
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '账单日',
dataIndex: 'billDate',
@@ -10,6 +10,7 @@ import { fmtTime } from '../../lib/constants';
import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import { useAdminListColumns } from '../../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
export default function PromoCodeUsersPage() {
@@ -25,7 +26,16 @@ export default function PromoCodeUsersPage() {
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{
title: '昵称',
dataIndex: 'nickname',
width: 100,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
{v}
</AdminPrimaryLink>
),
},
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{
title: '验手机',