Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43de361e61 | |||
| b1511ecb92 | |||
| fed8ff3d3a | |||
| 0b548a2764 | |||
| 30f8d649cb | |||
| f4da71e952 |
@@ -46,6 +46,78 @@ body,
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* 拖表头分割线改列宽 */
|
||||
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-col-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
z-index: 3;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.admin-col-resize-handle:hover,
|
||||
.admin-col-resizing .admin-col-resize-handle {
|
||||
background: rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
body.admin-col-resizing,
|
||||
body.admin-col-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
||||
.admin-list-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-list-header-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-list-header-desc {
|
||||
margin-top: 4px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-list-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-list-settings-slot {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
order: 99;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-list-settings-btn.ant-btn {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.admin-list-settings-btn.ant-btn:hover {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
/* 操作列保持可见 */
|
||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||
@@ -81,6 +153,23 @@ body,
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-primary-link {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-primary-link:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.admin-table-nowrap .ant-table-cell,
|
||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Space, Typography } from 'antd';
|
||||
|
||||
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
|
||||
export function AdminListHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
settings,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
settings?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-list-header">
|
||||
<div className="admin-list-header-left">
|
||||
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{description ? (
|
||||
<div className="admin-list-header-desc">{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-list-header-right">
|
||||
{actions ? <Space wrap>{actions}</Space> : null}
|
||||
{settings}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
|
||||
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
|
||||
export function AdminPrimaryLink({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onClick: (e?: MouseEvent) => void;
|
||||
}) {
|
||||
const empty = children == null || children === '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-primary-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
{empty ? '—' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,11 @@ export function ListColumnPrefsProvider({
|
||||
`/admin/me/list-columns/${listKey}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(next ? { order: next.order, hidden: next.hidden } : { reset: true }),
|
||||
body: JSON.stringify(
|
||||
next
|
||||
? { order: next.order, hidden: next.hidden, widths: next.widths }
|
||||
: { reset: true },
|
||||
),
|
||||
},
|
||||
);
|
||||
setPrefs(res.listColumnPrefs ?? {});
|
||||
|
||||
@@ -202,6 +202,7 @@ export type AdminOrderRow = {
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
saleUnit?: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
receiverProvince?: string;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import type { ColumnType } from 'antd/es/table';
|
||||
|
||||
export const MIN_COL_WIDTH = 48;
|
||||
export const MAX_COL_WIDTH = 960;
|
||||
|
||||
export function clampColWidth(n: number): number {
|
||||
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
|
||||
}
|
||||
|
||||
export function beginColumnResize(
|
||||
e: MouseEvent,
|
||||
startWidth: number,
|
||||
onMove: (width: number) => void,
|
||||
onEnd: (width: number) => void,
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
let latest = clampColWidth(startWidth);
|
||||
let moved = false;
|
||||
document.body.classList.add('admin-col-resizing');
|
||||
|
||||
const onMouseMove = (ev: globalThis.MouseEvent) => {
|
||||
moved = true;
|
||||
latest = clampColWidth(startWidth + ev.clientX - startX);
|
||||
onMove(latest);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.classList.remove('admin-col-resizing');
|
||||
if (moved) onEnd(latest);
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
export function withResizeTitle<T>(
|
||||
title: ColumnType<T>['title'],
|
||||
onMouseDown: (e: MouseEvent) => void,
|
||||
): ColumnType<T>['title'] {
|
||||
const handle: ReactNode = (
|
||||
<span
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="拖动调整列宽"
|
||||
className="admin-col-resize-handle"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
);
|
||||
if (typeof title === 'function') {
|
||||
return (props) => (
|
||||
<>
|
||||
{title(props)}
|
||||
{handle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
{handle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
||||
MANUAL: '手动',
|
||||
};
|
||||
|
||||
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
|
||||
LOCAL: '同城',
|
||||
CROSS_CITY: '跨城',
|
||||
ON_SITE_PICKUP: '现场提货',
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
|
||||
@@ -55,7 +55,20 @@ export function applyColumnPrefs<T>(
|
||||
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
||||
return [...orderedKeys.map((k) => byKey.get(k)!).filter(Boolean), ...actions.map((c) => c.col)];
|
||||
return [
|
||||
...orderedKeys
|
||||
.map((k) => {
|
||||
const col = byKey.get(k);
|
||||
if (!col) return null;
|
||||
const w = pref?.widths?.[k];
|
||||
return w != null ? { ...col, width: w } : col;
|
||||
})
|
||||
.filter((c): c is ColumnType<T> => !!c),
|
||||
...actions.map((c) => {
|
||||
const w = pref?.widths?.[c.key];
|
||||
return w != null ? { ...c.col, width: w } : c.col;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export type ListColumnSettingItem = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Button, message } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
ACTIONS_COLUMN_KEY,
|
||||
SERIAL_COLUMN_KEY,
|
||||
applyColumnPrefs,
|
||||
columnKey,
|
||||
settingItems,
|
||||
type ListColumnSettingItem,
|
||||
} from './list-column-prefs';
|
||||
import { beginColumnResize, withResizeTitle } from './column-resize';
|
||||
|
||||
type Options = {
|
||||
page?: number;
|
||||
@@ -27,7 +29,13 @@ export function useAdminListColumns<T>(
|
||||
const { prefs, save } = useListColumnPrefs();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
|
||||
const pref = prefs[listKey];
|
||||
const prefRef = useRef(pref);
|
||||
prefRef.current = pref;
|
||||
const localWidthsRef = useRef(localWidths);
|
||||
localWidthsRef.current = localWidths;
|
||||
const itemsRef = useRef<ListColumnSettingItem[]>([]);
|
||||
|
||||
const configured = useMemo(
|
||||
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||
@@ -38,6 +46,7 @@ export function useAdminListColumns<T>(
|
||||
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
itemsRef.current = items;
|
||||
|
||||
const serialCol: ColumnType<T> = useMemo(
|
||||
() => ({
|
||||
@@ -50,42 +59,105 @@ export function useAdminListColumns<T>(
|
||||
[page, pageSize],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<T>>(
|
||||
() => [
|
||||
const persistPref = useCallback(
|
||||
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
|
||||
if (!next) {
|
||||
setLocalWidths({});
|
||||
await save(listKey, null);
|
||||
return;
|
||||
}
|
||||
await save(listKey, next);
|
||||
},
|
||||
[listKey, save],
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
async (next: ListColumnSettingItem[] | null) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (!next) {
|
||||
await persistPref(null);
|
||||
} else {
|
||||
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
|
||||
await persistPref({
|
||||
order: next.map((i) => i.key),
|
||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||
...(Object.keys(widths).length ? { widths } : {}),
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[persistPref],
|
||||
);
|
||||
|
||||
const persistWidth = useCallback(
|
||||
async (key: string, width: number) => {
|
||||
const current = prefRef.current;
|
||||
const prevW = current?.widths?.[key];
|
||||
if (prevW === width) return;
|
||||
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
|
||||
const hidden = current?.hidden ?? [];
|
||||
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
|
||||
try {
|
||||
await persistPref({ order, hidden, widths });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '列宽保存失败');
|
||||
}
|
||||
},
|
||||
[persistPref],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<T>>(() => {
|
||||
const merged: ColumnType<T>[] = [
|
||||
serialCol,
|
||||
...configured.map((col) =>
|
||||
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||
: col,
|
||||
),
|
||||
],
|
||||
[configured, serialCol],
|
||||
);
|
||||
|
||||
async function persist(next: ListColumnSettingItem[] | null) {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (!next) {
|
||||
await save(listKey, null);
|
||||
} else {
|
||||
await save(listKey, {
|
||||
order: next.map((i) => i.key),
|
||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
];
|
||||
const widths = { ...(pref?.widths ?? {}), ...localWidths };
|
||||
return merged.map((col, i) => {
|
||||
const key = columnKey(col, i);
|
||||
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
|
||||
const prevHeader = col.onHeaderCell;
|
||||
return {
|
||||
...col,
|
||||
key,
|
||||
width,
|
||||
title: withResizeTitle(col.title, (e) => {
|
||||
const th = (e.currentTarget as HTMLElement).closest('th');
|
||||
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
|
||||
beginColumnResize(
|
||||
e,
|
||||
startWidth,
|
||||
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
|
||||
(next) => void persistWidth(key, next),
|
||||
);
|
||||
}),
|
||||
onHeaderCell: (column) => {
|
||||
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
|
||||
return {
|
||||
...extra,
|
||||
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
|
||||
|
||||
const settingsButton = (
|
||||
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
|
||||
列设置
|
||||
</Button>
|
||||
<span className="admin-list-settings-slot">
|
||||
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
|
||||
列设置
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
|
||||
const settingsModal = (
|
||||
|
||||
@@ -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,23 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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 +46,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 +107,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 +148,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: '操作',
|
||||
@@ -162,17 +249,15 @@ export default function BenefitCouponsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
好客权益券
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
{settingsButton}
|
||||
<AdminListHeader
|
||||
title="好客权益券"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||
手动发放
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="couponNo" label="券号">
|
||||
@@ -276,15 +361,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 }}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -41,8 +42,7 @@ export default function BenefitLedgersPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="权益流水" settings={settingsButton} />
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import {
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -31,6 +30,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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 },
|
||||
@@ -308,27 +316,29 @@ export default function CityWarehousesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateManagerType(WarehouseManagerType.HQ);
|
||||
setCreateCityId(undefined);
|
||||
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||
createForm.setFieldsValue({
|
||||
managerType: WarehouseManagerType.HQ,
|
||||
status: WarehouseStatus.ACTIVE,
|
||||
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新增仓库
|
||||
</Button>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="仓库"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateManagerType(WarehouseManagerType.HQ);
|
||||
setCreateCityId(undefined);
|
||||
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||
createForm.setFieldsValue({
|
||||
managerType: WarehouseManagerType.HQ,
|
||||
status: WarehouseStatus.ACTIVE,
|
||||
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新增仓库
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
|
||||
@@ -8,6 +8,8 @@ import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -17,6 +19,8 @@ type Row = {
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
updatedAt: string;
|
||||
/** 当次应付物流费 */
|
||||
logisticsFee?: number | null;
|
||||
order?: {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
@@ -62,9 +66,31 @@ 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: 'logisticsFee',
|
||||
width: 90,
|
||||
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||
},
|
||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
@@ -90,8 +116,7 @@ export default function DeliveriesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
||||
@@ -119,6 +144,9 @@ export default function DeliveriesPage() {
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||
<Descriptions.Item label="运费">
|
||||
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
|
||||
@@ -31,6 +31,8 @@ import { downloadBase64File } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||
@@ -243,7 +245,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',
|
||||
@@ -301,40 +310,40 @@ export default function DevPlanTasksPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 任务列表
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={exportFormat}
|
||||
style={{ width: 120 }}
|
||||
onChange={setExportFormat}
|
||||
options={[
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'docx', label: 'Word' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
]}
|
||||
/>
|
||||
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
|
||||
导出已勾选
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
|
||||
导出全部筛选
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
||||
批量编辑
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
|
||||
评审
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建任务
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="开发计划 · 任务列表"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<>
|
||||
<Select
|
||||
value={exportFormat}
|
||||
style={{ width: 120 }}
|
||||
onChange={setExportFormat}
|
||||
options={[
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'docx', label: 'Word' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
{ value: 'pdf', label: 'PDF' },
|
||||
]}
|
||||
/>
|
||||
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
|
||||
导出已勾选
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
|
||||
导出全部筛选
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
||||
批量编辑
|
||||
</Button>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
|
||||
评审
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建任务
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -23,6 +22,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
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',
|
||||
@@ -165,15 +168,15 @@ export default function DevPlanVersionsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 版本列表
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建版本
|
||||
</Button>
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="开发计划 · 版本列表"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建版本
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
|
||||
@@ -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 || '—' },
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -29,6 +28,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
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',
|
||||
@@ -240,20 +247,16 @@ export default function FulfillmentProvidersPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
注册承运商
|
||||
</Button>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="仓配管理"
|
||||
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
注册承运商
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -69,7 +71,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 },
|
||||
{
|
||||
@@ -120,22 +146,24 @@ export default function HqAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||
{settingsButton}
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="HQ 账户"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
isSuperAdmin ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="adminRole" label="角色">
|
||||
|
||||
@@ -1,204 +1,216 @@
|
||||
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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="HQ 操作日志"
|
||||
settings={settingsButton}
|
||||
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
@@ -31,6 +30,8 @@ import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
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,
|
||||
@@ -234,32 +241,25 @@ export default function InvoicesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
invoiceCategory: 'LIQUOR',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
创建发票申请
|
||||
</Button>
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="发票管理"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
invoiceCategory: 'LIQUOR',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
创建发票申请
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
|
||||
@@ -26,6 +26,8 @@ import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
@@ -127,7 +129,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 },
|
||||
{
|
||||
@@ -257,15 +265,11 @@ export default function KnowledgeBasesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="知识库"
|
||||
settings={settingsButton}
|
||||
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
|
||||
@@ -26,6 +26,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
@@ -156,7 +158,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',
|
||||
@@ -264,15 +272,11 @@ export default function LlmConfigsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="语言模型配置"
|
||||
settings={settingsButton}
|
||||
description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
|
||||
@@ -30,6 +30,8 @@ import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
type BillRow = {
|
||||
id: string;
|
||||
@@ -246,7 +248,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,
|
||||
@@ -397,15 +406,11 @@ export default function LogisticsBillsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
物流对账
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="物流对账"
|
||||
settings={settingsButton}
|
||||
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
|
||||
@@ -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,13 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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,
|
||||
@@ -142,7 +145,7 @@ type OrderExportFormat = 'xlsx' | 'pdf';
|
||||
|
||||
type OrderExportFilters = {
|
||||
orderNo?: string;
|
||||
status?: string;
|
||||
status?: string | string[];
|
||||
orderType?: string;
|
||||
cityId?: string;
|
||||
receiverPhone?: string;
|
||||
@@ -217,6 +220,12 @@ function formatBenefitBrief(row: AdminOrderRow) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
function selectedStatuses(status: unknown): string[] {
|
||||
if (Array.isArray(status)) return status.filter((s): s is string => Boolean(s));
|
||||
if (typeof status === 'string' && status) return [status];
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildExportPayload(
|
||||
scope: OrderExportScope,
|
||||
format: OrderExportFormat,
|
||||
@@ -229,7 +238,8 @@ function buildExportPayload(
|
||||
return payload;
|
||||
}
|
||||
if (filters.orderNo) payload.orderNo = filters.orderNo;
|
||||
if (filters.status) payload.status = filters.status;
|
||||
const statuses = selectedStatuses(filters.status);
|
||||
if (statuses.length) payload.status = statuses;
|
||||
if (filters.orderType) payload.orderType = filters.orderType;
|
||||
if (filters.cityId) payload.cityId = filters.cityId;
|
||||
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
||||
@@ -273,6 +283,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();
|
||||
@@ -354,7 +365,7 @@ export default function OrdersPage() {
|
||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||
if (values.status) qs.set('status', values.status);
|
||||
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
||||
if (values.orderType) qs.set('orderType', values.orderType);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||
@@ -593,63 +604,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: '下单时间',
|
||||
@@ -695,18 +741,20 @@ export default function OrdersPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space size={12}>
|
||||
{settingsButton}
|
||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||
{canProxyOrder ? (
|
||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||
代下单
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="订单监控"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||
{canProxyOrder ? (
|
||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||
代下单
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!canDeleteOrders && profile ? (
|
||||
<Alert
|
||||
@@ -744,10 +792,12 @@ export default function OrdersPage() {
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="全部"
|
||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,10 @@ 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';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
@@ -176,14 +177,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 },
|
||||
@@ -262,15 +262,11 @@ export default function PartnerAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="合伙人子账号"
|
||||
settings={settingsButton}
|
||||
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,237 +1,250 @@
|
||||
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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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>
|
||||
{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>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="合伙人日志" settings={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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,410 +1,420 @@
|
||||
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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<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>
|
||||
);
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="开城合伙人"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
{settingsModal}
|
||||
<AdminListHeader title="待处理核销(弱网兜底)" settings={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 },
|
||||
|
||||
@@ -15,6 +15,8 @@ import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePick
|
||||
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
@@ -391,7 +393,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',
|
||||
@@ -502,11 +511,11 @@ export default function ProductsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="商品管理"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||
|
||||
@@ -15,6 +15,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
@@ -77,7 +79,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: '场景',
|
||||
@@ -167,11 +176,11 @@ export default function PromoCodesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="推广码管理"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -84,7 +86,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 }}>
|
||||
测试
|
||||
@@ -140,8 +142,7 @@ export default function RedeemRecordsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="核销记录" settings={settingsButton} />
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../lib/constants';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -121,15 +122,11 @@ export default function ResourcesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
OSS 资源库
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
登记资源
|
||||
</Button>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="OSS 资源库"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>登记资源</Button>}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||
@@ -83,7 +85,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>
|
||||
),
|
||||
@@ -145,24 +154,22 @@ export default function StoreAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void loadStores();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="门店账户"
|
||||
settings={settingsButton}
|
||||
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
||||
actions={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
void loadStores();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type CategoryNode = {
|
||||
@@ -130,7 +132,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>
|
||||
),
|
||||
},
|
||||
@@ -178,27 +180,27 @@ export default function StoreCategoriesPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="门店分类"
|
||||
settings={settingsButton}
|
||||
description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
|
||||
actions={
|
||||
<>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -103,7 +105,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,
|
||||
@@ -134,11 +152,11 @@ export default function StoreLogsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||
</Typography.Paragraph>
|
||||
<AdminListHeader
|
||||
title="商户日志"
|
||||
settings={settingsButton}
|
||||
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。"
|
||||
/>
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -43,7 +45,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,
|
||||
@@ -80,11 +97,11 @@ export default function StoreMediaPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="门店资源"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="mediaType" label="类型">
|
||||
|
||||
@@ -33,6 +33,8 @@ import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
@@ -199,7 +201,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 +481,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',
|
||||
@@ -528,8 +546,7 @@ export default function StorePackageAuditsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>审核通知</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="审核通知" settings={settingsButton} />
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -69,10 +70,7 @@ export default function StoreRatingsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店评价
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="门店评价" settings={settingsButton} />
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
|
||||
@@ -21,6 +21,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -156,7 +158,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>
|
||||
),
|
||||
@@ -236,10 +238,7 @@ export default function StoreWithdrawalsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店提现审
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="门店提现审" settings={settingsButton} />
|
||||
{overdueSummary ? (
|
||||
<Typography.Paragraph type="secondary">
|
||||
待审 {overdueSummary.pendingCount} 笔
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
} from '../lib/storeCreate';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
@@ -739,7 +741,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>
|
||||
);
|
||||
@@ -872,16 +874,14 @@ export default function StoresPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Typography.Text type="secondary">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{settingsButton}
|
||||
<AdminListHeader
|
||||
title="门店"
|
||||
settings={settingsButton}
|
||||
description={`共 ${data?.total ?? 0} 家门店(含合伙人录入)`}
|
||||
actions={
|
||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -21,6 +21,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
@@ -254,7 +256,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',
|
||||
@@ -394,10 +411,7 @@ export default function TestWhitelistPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
白名单管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="白名单管理" settings={settingsButton} />
|
||||
|
||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -95,7 +97,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,
|
||||
@@ -122,8 +138,7 @@ export default function ThirdPartyLogsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="第三方日志" settings={settingsButton} />
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -80,7 +82,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}` : '—'),
|
||||
@@ -105,8 +121,7 @@ export default function UserLogsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>用户日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<AdminListHeader title="用户日志" settings={settingsButton} />
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
|
||||
@@ -20,6 +20,8 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||
|
||||
type UserOrderRow = {
|
||||
@@ -332,7 +334,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: '备注',
|
||||
@@ -478,21 +482,21 @@ export default function UsersPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||
<Space>
|
||||
{settingsButton}
|
||||
{canDeleteUsers ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => void openBatchDeleteModal()}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<AdminListHeader
|
||||
title="用户监控"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
canDeleteUsers ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
onClick={() => void openBatchDeleteModal()}
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
|
||||
@@ -1,318 +1,322 @@
|
||||
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',
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
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 (
|
||||
<div>
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{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}
|
||||
/>
|
||||
</>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="微信绑定总览"
|
||||
settings={settingsButton}
|
||||
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
|
||||
/>
|
||||
|
||||
<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,8 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
export default function WecomBotLogsPage() {
|
||||
@@ -28,7 +30,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 || '—' },
|
||||
@@ -56,11 +65,11 @@ export default function WecomBotLogsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
||||
</Typography.Paragraph>
|
||||
<AdminListHeader
|
||||
title="企微机器人日志"
|
||||
settings={settingsButton}
|
||||
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -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,8 @@ import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
@@ -202,7 +204,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>
|
||||
),
|
||||
},
|
||||
@@ -271,9 +273,15 @@ function PushRoutesTab() {
|
||||
return (
|
||||
<>
|
||||
{settingsModal}
|
||||
<Typography.Paragraph type="secondary">
|
||||
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
||||
</Typography.Paragraph>
|
||||
<AdminListHeader
|
||||
settings={settingsButton}
|
||||
description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
|
||||
actions={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
@@ -308,10 +316,6 @@ function PushRoutesTab() {
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
{settingsButton}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -27,6 +27,8 @@ import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
@@ -238,7 +240,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',
|
||||
@@ -297,31 +306,18 @@ export default function WineryBillsPage() {
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||
无需打款(灰),可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<AdminListHeader
|
||||
title="酒厂对账单"
|
||||
settings={settingsButton}
|
||||
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
||||
actions={
|
||||
canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
|
||||
@@ -10,6 +10,8 @@ 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';
|
||||
import { AdminListHeader } from '../../components/AdminListHeader';
|
||||
|
||||
|
||||
export default function PromoCodeUsersPage() {
|
||||
@@ -25,7 +27,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: '验手机',
|
||||
@@ -74,20 +85,24 @@ export default function PromoCodeUsersPage() {
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
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); },
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{settingsModal}
|
||||
<AdminListHeader settings={settingsButton} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
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); },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@
|
||||
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
|
||||
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
|
||||
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
|
||||
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
||||
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
**HQ 订单导出**(`admin-web` 订单监控):
|
||||
|
||||
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
|
||||
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
|
||||
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
||||
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
||||
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
||||
@@ -137,7 +137,7 @@
|
||||
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
||||
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序,偏好挂当前 HQ 账号。
|
||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 |
|
||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
|
||||
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
||||
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
1. **门店列表**增加「累计核销好客权益」:该店历史核销券面额合计。
|
||||
2. **全 HQ 表格**去掉 `...` 截断,长文本完整展示,横向滚动。
|
||||
3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序,偏好存当前 HQ 账号。
|
||||
3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序;可拖表头分割线改列宽。偏好(含列宽)存当前 HQ 账号。主展示列(名称 / 单号 / 昵称等)带下划线,点击进入编辑或详情。
|
||||
4. **用户列表**:昵称只读(用户自己的);新增「备注」列,双击编辑 HQ 标记,离开即写入 `user_user.hq_remark`;手机号列表明文,不脱敏。
|
||||
|
||||
**不做**:C 端 / 合伙人 / 门店端;详情 Drawer 内嵌表不加列设置。
|
||||
@@ -38,10 +38,11 @@
|
||||
| 序号 | `(page-1)*pageSize+index+1`,锁定最左,不可隐藏 |
|
||||
| 操作列 | 锁定最右,不可隐藏 |
|
||||
| 弹窗 | 勾选显示、拖拽/上下移排序、重置默认 |
|
||||
| 主列 | 名称/单号/昵称等下划线,点击同「编辑」或「详情」 |
|
||||
| 存储 | `hq_account.list_column_prefs` JSON,按 `listKey` |
|
||||
|
||||
```ts
|
||||
{ stores: { order: ["name", "cityName"], hidden: ["intro"] } }
|
||||
{ stores: { order: ["name", "cityName"], hidden: ["intro"], widths: { name: 180 } } }
|
||||
```
|
||||
|
||||
新列按默认位置插入且默认显示;未知 key 忽略。重置即删除该 `listKey`。
|
||||
@@ -113,7 +114,24 @@
|
||||
- [ ] HQ 主表长字段不再 `...`,可横向滑完
|
||||
- [ ] 主列表最左序号跨页连续
|
||||
- [ ] 列设置可隐藏/排序;保存后刷新仍在;重置恢复默认
|
||||
- [ ] 主展示列带下划线,点击进入对应编辑或详情
|
||||
- [ ] 序号、操作列不能在弹窗关掉或拖走
|
||||
- [ ] 详情描述列表仍可省略
|
||||
- [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `user_user.hq_remark` 已更新;C 端看不到该字段
|
||||
- [ ] 用户列表手机号完整可见(详情仍脱敏)
|
||||
- [ ] 权益券列表:用户编号、订单号可点进对应用户/订单详情;来源含商品名、规格、数量、配送方式、实付金额
|
||||
- [ ] 订单列表「状态」可多选;导出按所选状态过滤;不选即全部
|
||||
|
||||
---
|
||||
|
||||
## 10. 权益券列表快链与来源
|
||||
|
||||
- **用户**、**订单**列(及券详情)用主列下划线,分别打开用户详情抽屉、订单详情抽屉。
|
||||
- **来源**:关联订单时拼 `商品名 / 规格 / 数量(瓶或箱) / 配送方式 / ¥实付`;无订单仍用 `sourceProduct`(如总部手动发放)。
|
||||
- 列表接口 `order` 增补 `productName`、`productSpec`、`quantity`、`saleUnit`、`deliveryType`、`payAmount`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 订单列表状态多选
|
||||
|
||||
`GET /admin/orders` 与 `POST /admin/orders/export` 的 `status` 支持多值(重复 query、逗号串、JSON 数组均可)。列表筛选为多选;空 = 全部。单值旧链接仍可用。
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
||||
|
||||
**HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。
|
||||
|
||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)存 `hq_account.list_column_prefs`。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||
|
||||
## 5. 验收用例(必过)
|
||||
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@
|
||||
| 交易 | 订单、权益券、核销记录、推广码+metrics |
|
||||
| 财务 | 门店/合伙人/酒厂/物流账单;打款确认 |
|
||||
| 工单 | 售后四类型 + 技术支持(ST) + 开发计划 |
|
||||
| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」按账号保存 |
|
||||
| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」与列宽按账号保存 |
|
||||
| 日志 | HQ/用户/门店/合伙人/企微 |
|
||||
|
||||
## 6. 商品与模板
|
||||
|
||||
@@ -60,6 +60,8 @@ export function isHqListColumnKey(value: string): value is HqListColumnKey {
|
||||
export type HqListColumnPref = {
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
/** 列宽(px);未出现的 key 用列默认 width */
|
||||
widths?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type HqListColumnPrefsMap = Partial<Record<HqListColumnKey, HqListColumnPref>>;
|
||||
@@ -68,4 +70,5 @@ export type SaveHqListColumnPrefsRequest = {
|
||||
reset?: boolean;
|
||||
order?: string[];
|
||||
hidden?: string[];
|
||||
widths?: Record<string, number>;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,21 @@ export interface AdminListQuery {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/** HQ 订单列表 / 导出筛选。`status` 可多选(单值仍兼容) */
|
||||
export interface AdminOrdersListQuery extends AdminListQuery {
|
||||
orderNo?: string;
|
||||
status?: string | string[];
|
||||
orderType?: string;
|
||||
userId?: string;
|
||||
cityId?: string;
|
||||
receiverPhone?: string;
|
||||
fulfillmentHold?: string | boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
excludeTest?: boolean;
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAdminUserRequest {
|
||||
hqRemark: string;
|
||||
}
|
||||
|
||||
@@ -55,15 +55,29 @@ type UserRow = Pick<
|
||||
avatar?: { url: string } | null;
|
||||
};
|
||||
|
||||
function parseColumnWidths(raw: unknown): Record<string, number> | undefined {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!key.trim()) continue;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) continue;
|
||||
out[key] = Math.min(960, Math.max(48, Math.round(n)));
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
function parseListColumnPrefs(raw: unknown): HqListColumnPrefsMap {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const out: HqListColumnPrefsMap = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!isHqListColumnKey(key) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const row = value as { order?: unknown; hidden?: unknown };
|
||||
const row = value as { order?: unknown; hidden?: unknown; widths?: unknown };
|
||||
const widths = parseColumnWidths(row.widths);
|
||||
out[key] = {
|
||||
order: Array.isArray(row.order) ? row.order.filter((v): v is string => typeof v === 'string') : [],
|
||||
hidden: Array.isArray(row.hidden) ? row.hidden.filter((v): v is string => typeof v === 'string') : [],
|
||||
...(widths ? { widths } : {}),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
@@ -1238,7 +1252,7 @@ export class AuthService {
|
||||
actorType: string,
|
||||
actorId: bigint,
|
||||
listKey: string,
|
||||
dto: { reset?: boolean; order?: string[]; hidden?: string[] },
|
||||
dto: { reset?: boolean; order?: string[]; hidden?: string[]; widths?: Record<string, number> },
|
||||
) {
|
||||
if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置');
|
||||
if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表');
|
||||
@@ -1251,9 +1265,19 @@ export class AuthService {
|
||||
if (dto.reset) {
|
||||
delete current[listKey];
|
||||
} else {
|
||||
const prev = current[listKey];
|
||||
const widths =
|
||||
dto.widths !== undefined ? parseColumnWidths(dto.widths) : prev?.widths;
|
||||
current[listKey] = {
|
||||
order: (dto.order ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
||||
hidden: (dto.hidden ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
||||
order:
|
||||
dto.order !== undefined
|
||||
? dto.order.filter((k) => typeof k === 'string' && k.trim())
|
||||
: (prev?.order ?? []),
|
||||
hidden:
|
||||
dto.hidden !== undefined
|
||||
? dto.hidden.filter((k) => typeof k === 'string' && k.trim())
|
||||
: (prev?.hidden ?? []),
|
||||
...(widths && Object.keys(widths).length ? { widths } : {}),
|
||||
};
|
||||
}
|
||||
const updated = await this.prisma.hqAccount.update({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
|
||||
export class SendSmsDto {
|
||||
@@ -144,4 +144,8 @@ export class SaveHqListColumnPrefsDto {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
hidden?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
widths?: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,20 @@ export class AdminBenefitService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
order: { select: { id: true, orderNo: true, status: true } },
|
||||
order: {
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
status: true,
|
||||
productName: true,
|
||||
productSpec: true,
|
||||
quantity: true,
|
||||
saleUnit: true,
|
||||
deliveryType: true,
|
||||
payAmount: true,
|
||||
benefitAmount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.benefitCoupon.count({ where }),
|
||||
@@ -46,7 +59,20 @@ export class AdminBenefitService {
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
||||
order: { select: { id: true, orderNo: true, status: true, payAmount: true } },
|
||||
order: {
|
||||
select: {
|
||||
id: true,
|
||||
orderNo: true,
|
||||
status: true,
|
||||
payAmount: true,
|
||||
productName: true,
|
||||
productSpec: true,
|
||||
quantity: true,
|
||||
saleUnit: true,
|
||||
deliveryType: true,
|
||||
benefitAmount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon) throw new NotFoundException('权益券不存在');
|
||||
|
||||
@@ -155,7 +155,9 @@ export class AdminOrdersService {
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
|
||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||
if (query.status?.length) {
|
||||
where.status = { in: query.status as Prisma.EnumOrderStatusFilter['in'] };
|
||||
}
|
||||
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { OrderStatus } from '@dukang/shared-types';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
const ORDER_STATUS_VALUES = Object.values(OrderStatus);
|
||||
|
||||
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (value === true || value === 'true' || value === '1' || value === 1) return true;
|
||||
@@ -8,6 +11,19 @@ function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Query/body:单值、逗号串、重复 key、数组均可 → string[] */
|
||||
function toOptionalStringList(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const raw = Array.isArray(value) ? value : [value];
|
||||
const items = [...new Set(
|
||||
raw
|
||||
.flatMap((v) => String(v).split(','))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
return items.length ? items : undefined;
|
||||
}
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -57,9 +73,12 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
orderNo?: string;
|
||||
|
||||
/** 单值或多项:`?status=PENDING_PAY` / `?status=A&status=B` / `?status=A,B` */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@Transform(({ value }) => toOptionalStringList(value))
|
||||
@IsArray()
|
||||
@IsIn(ORDER_STATUS_VALUES, { each: true })
|
||||
status?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['NORMAL', 'PROXY'])
|
||||
@@ -117,8 +136,10 @@ export class AdminOrdersExportDto {
|
||||
orderNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@Transform(({ value }) => toOptionalStringList(value))
|
||||
@IsArray()
|
||||
@IsIn(ORDER_STATUS_VALUES, { each: true })
|
||||
status?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['NORMAL', 'PROXY', 'RESHIPMENT'])
|
||||
|
||||
Reference in New Issue
Block a user