Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2ebbfef4 | |||
| ee1a3f4b6c | |||
| 52a7d3789d | |||
| f2fbf95c53 | |||
| 98d52ff173 | |||
| 797b20979d | |||
| 43de361e61 | |||
| b1511ecb92 | |||
| fed8ff3d3a | |||
| 0b548a2764 | |||
| 30f8d649cb | |||
| f4da71e952 |
@@ -46,6 +46,78 @@ body,
|
|||||||
text-overflow: clip;
|
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),
|
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||||
@@ -81,6 +153,23 @@ body,
|
|||||||
font-size: 12px;
|
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,
|
||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
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}`,
|
`/admin/me/list-columns/${listKey}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
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 ?? {});
|
setPrefs(res.listColumnPrefs ?? {});
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export type AdminOrderRow = {
|
|||||||
productName?: string;
|
productName?: string;
|
||||||
productSpec?: string;
|
productSpec?: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
|
saleUnit?: string;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
receiverProvince?: 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: '手动',
|
MANUAL: '手动',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
LOCAL: '同城',
|
||||||
|
CROSS_CITY: '跨城',
|
||||||
|
ON_SITE_PICKUP: '现场提货',
|
||||||
|
};
|
||||||
|
|
||||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '待发货',
|
PENDING_SHIP: '待发货',
|
||||||
|
|||||||
@@ -55,7 +55,20 @@ export function applyColumnPrefs<T>(
|
|||||||
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||||
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||||
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
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 = {
|
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 { Button, message } from 'antd';
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
ACTIONS_COLUMN_KEY,
|
ACTIONS_COLUMN_KEY,
|
||||||
SERIAL_COLUMN_KEY,
|
SERIAL_COLUMN_KEY,
|
||||||
applyColumnPrefs,
|
applyColumnPrefs,
|
||||||
|
columnKey,
|
||||||
settingItems,
|
settingItems,
|
||||||
type ListColumnSettingItem,
|
type ListColumnSettingItem,
|
||||||
} from './list-column-prefs';
|
} from './list-column-prefs';
|
||||||
|
import { beginColumnResize, withResizeTitle } from './column-resize';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -27,7 +29,13 @@ export function useAdminListColumns<T>(
|
|||||||
const { prefs, save } = useListColumnPrefs();
|
const { prefs, save } = useListColumnPrefs();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
|
||||||
const pref = prefs[listKey];
|
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(
|
const configured = useMemo(
|
||||||
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||||
@@ -38,6 +46,7 @@ export function useAdminListColumns<T>(
|
|||||||
() => settingItems(allColumns as ColumnType<T>[], pref),
|
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||||
[allColumns, pref],
|
[allColumns, pref],
|
||||||
);
|
);
|
||||||
|
itemsRef.current = items;
|
||||||
|
|
||||||
const serialCol: ColumnType<T> = useMemo(
|
const serialCol: ColumnType<T> = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -50,27 +59,30 @@ export function useAdminListColumns<T>(
|
|||||||
[page, pageSize],
|
[page, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo<ColumnsType<T>>(
|
const persistPref = useCallback(
|
||||||
() => [
|
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
|
||||||
serialCol,
|
if (!next) {
|
||||||
...configured.map((col) =>
|
setLocalWidths({});
|
||||||
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
await save(listKey, null);
|
||||||
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
return;
|
||||||
: col,
|
}
|
||||||
),
|
await save(listKey, next);
|
||||||
],
|
},
|
||||||
[configured, serialCol],
|
[listKey, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function persist(next: ListColumnSettingItem[] | null) {
|
const persist = useCallback(
|
||||||
|
async (next: ListColumnSettingItem[] | null) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
await save(listKey, null);
|
await persistPref(null);
|
||||||
} else {
|
} else {
|
||||||
await save(listKey, {
|
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
|
||||||
|
await persistPref({
|
||||||
order: next.map((i) => i.key),
|
order: next.map((i) => i.key),
|
||||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||||
|
...(Object.keys(widths).length ? { widths } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -80,12 +92,72 @@ export function useAdminListColumns<T>(
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
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 = (
|
const settingsButton = (
|
||||||
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
|
<span className="admin-list-settings-slot">
|
||||||
|
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
|
||||||
列设置
|
列设置
|
||||||
</Button>
|
</Button>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
const settingsModal = (
|
const settingsModal = (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -17,11 +19,23 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
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 RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
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 = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,10 +46,43 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
sourceProduct: string;
|
sourceProduct: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { userNo: string; phone: string | null };
|
user?: { id?: string; userNo: string; phone: string | null };
|
||||||
order?: { orderNo: 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 = {
|
type CouponRedeemRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
@@ -60,11 +107,10 @@ type CouponRedeemSummary = {
|
|||||||
type CouponDetail = Row & {
|
type CouponDetail = Row & {
|
||||||
redeemSummary?: CouponRedeemSummary | null;
|
redeemSummary?: CouponRedeemSummary | null;
|
||||||
redeemRecords?: CouponRedeemRecord[];
|
redeemRecords?: CouponRedeemRecord[];
|
||||||
user?: { userNo?: string; phone?: string | null };
|
|
||||||
order?: { orderNo?: string } | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BenefitCouponsPage() {
|
export default function BenefitCouponsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -102,20 +148,61 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '订单',
|
title: '订单',
|
||||||
dataIndex: ['order', 'orderNo'],
|
dataIndex: ['order', 'orderNo'],
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: false,
|
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: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '余额', dataIndex: 'balance', 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: '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: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -162,17 +249,15 @@ export default function BenefitCouponsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="好客权益券"
|
||||||
好客权益券
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
actions={
|
||||||
<Space>
|
|
||||||
{settingsButton}
|
|
||||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||||
手动发放
|
手动发放
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="couponNo" label="券号">
|
<Form.Item name="couponNo" label="券号">
|
||||||
@@ -276,15 +361,37 @@ export default function BenefitCouponsPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
<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.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.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
<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 { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -41,8 +42,7 @@ export default function BenefitLedgersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
<AdminListHeader title="权益流水" settings={settingsButton} />
|
||||||
{settingsButton}
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type WarehouseRow = {
|
type WarehouseRow = {
|
||||||
@@ -227,7 +229,14 @@ export default function CitiesPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
{
|
||||||
|
title: '城市',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 100,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||||
@@ -318,11 +327,11 @@ export default function CitiesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
title="城市"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={openCreateModal}>新建城市</Button>
|
actions={<Button type="primary" onClick={openCreateModal}>新建城市</Button>}
|
||||||
</Space>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="name" label="城市"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type SubRow = PartnerSubAccountRow;
|
type SubRow = PartnerSubAccountRow;
|
||||||
@@ -272,7 +274,14 @@ export default function CityPartnersPage() {
|
|||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
|
title: '公司名',
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '主账号姓名',
|
title: '主账号姓名',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
@@ -342,11 +351,10 @@ export default function CityPartnersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
return (
|
<AdminListHeader
|
||||||
<div>
|
title="城市合伙人"
|
||||||
{settingsModal}
|
settings={settingsButton}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
actions={
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -364,7 +372,8 @@ export default function CityPartnersPage() {
|
|||||||
>
|
>
|
||||||
新建合伙人
|
新建合伙人
|
||||||
</Button>
|
</Button>
|
||||||
>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -232,7 +233,14 @@ export default function CityWarehousesPage() {
|
|||||||
</span>
|
</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: 'address', width: 180 },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
@@ -308,9 +316,10 @@ export default function CityWarehousesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
title="仓库"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -328,7 +337,8 @@ export default function CityWarehousesPage() {
|
|||||||
>
|
>
|
||||||
新增仓库
|
新增仓库
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { request } from '../lib/api';
|
|||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -17,6 +19,8 @@ type Row = {
|
|||||||
trackingNo: string | null;
|
trackingNo: string | null;
|
||||||
providerOrderNo: string | null;
|
providerOrderNo: string | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** 当次应付物流费 */
|
||||||
|
logisticsFee?: number | null;
|
||||||
order?: {
|
order?: {
|
||||||
id: string;
|
id: string;
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
@@ -62,9 +66,31 @@ export default function DeliveriesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: 'provider', dataIndex: 'provider', width: 90 },
|
||||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
{ 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: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||||
@@ -90,8 +116,7 @@ export default function DeliveriesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||||
{settingsButton}
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></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 column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</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>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
<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 { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
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) => ({
|
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> = [
|
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||||
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
|
{
|
||||||
|
title: '任务号',
|
||||||
|
dataIndex: 'taskNo',
|
||||||
|
width: 160,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'type',
|
dataIndex: 'type',
|
||||||
@@ -301,12 +310,11 @@ export default function DevPlanTasksPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="开发计划 · 任务列表"
|
||||||
开发计划 · 任务列表
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
actions={
|
||||||
{settingsButton}
|
<>
|
||||||
<Space wrap>
|
|
||||||
<Select
|
<Select
|
||||||
value={exportFormat}
|
value={exportFormat}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
@@ -333,8 +341,9 @@ export default function DevPlanTasksPage() {
|
|||||||
<Button type="primary" onClick={openCreate}>
|
<Button type="primary" onClick={openCreate}>
|
||||||
新建任务
|
新建任务
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -23,6 +22,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
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> = [
|
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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -165,15 +168,15 @@ export default function DevPlanVersionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="开发计划 · 版本列表"
|
||||||
开发计划 · 版本列表
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
actions={
|
||||||
{settingsButton}
|
|
||||||
<Button type="primary" onClick={openCreate}>
|
<Button type="primary" onClick={openCreate}>
|
||||||
新建版本
|
新建版本
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -59,7 +60,20 @@ export default function DomainEventsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
{ 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: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
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]) => ({
|
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||||
@@ -67,7 +69,10 @@ export default function FulfillmentProvidersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||||
setRows(res);
|
setRows(Array.isArray(res) ? res : []);
|
||||||
|
} catch (e) {
|
||||||
|
setRows([]);
|
||||||
|
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -93,6 +98,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
deliveryHintHtml: '',
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
@@ -121,6 +127,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
deliveryHintHtml: row.deliveryHintHtml || '',
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
@@ -143,6 +150,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||||
},
|
},
|
||||||
|
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||||
@@ -175,9 +183,25 @@ export default function FulfillmentProvidersPage() {
|
|||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(row: FulfillmentProviderDto) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'type',
|
dataIndex: 'type',
|
||||||
@@ -223,11 +247,23 @@ export default function FulfillmentProvidersPage() {
|
|||||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认删除承运商「${row.name}」?`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void remove(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -240,20 +276,16 @@ export default function FulfillmentProvidersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<div>
|
title="仓配管理"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
||||||
仓配管理
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
actions={
|
||||||
{settingsButton}
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" onClick={openCreate}>
|
<Button type="primary" onClick={openCreate}>
|
||||||
注册承运商
|
注册承运商
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -285,6 +317,17 @@ export default function FulfillmentProvidersPage() {
|
|||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="deliveryHintHtml"
|
||||||
|
label="配送信息提示"
|
||||||
|
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider orientation="left">物流对账</Divider>
|
<Divider orientation="left">物流对账</Divider>
|
||||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||||
|
|||||||
@@ -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 { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -69,7 +71,31 @@ export default function HqAccountsPage() {
|
|||||||
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: 'loginName', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
@@ -120,10 +146,11 @@ export default function HqAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
title="HQ 账户"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
{isSuperAdmin && (
|
actions={
|
||||||
|
isSuperAdmin ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -134,8 +161,9 @@ export default function HqAccountsPage() {
|
|||||||
>
|
>
|
||||||
新建账户
|
新建账户
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null
|
||||||
</Space>
|
}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="phone" label="手机"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -70,7 +72,17 @@ export default function HqLogsPage() {
|
|||||||
title: '行为',
|
title: '行为',
|
||||||
dataIndex: 'actionLabel',
|
dataIndex: 'actionLabel',
|
||||||
width: 160,
|
width: 160,
|
||||||
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: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||||
@@ -98,11 +110,11 @@ export default function HqLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
{settingsModal}
|
<AdminListHeader
|
||||||
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
title="HQ 操作日志"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
<Typography.Paragraph type="secondary">
|
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
|
||||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
Upload,
|
Upload,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -31,6 +30,8 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -204,7 +205,13 @@ export default function InvoicesPage() {
|
|||||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||||
: '—',
|
: '—',
|
||||||
},
|
},
|
||||||
{ title: '名称', dataIndex: 'titleName' },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'titleName',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
width: 110,
|
width: 110,
|
||||||
@@ -234,18 +241,10 @@ export default function InvoicesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div
|
<AdminListHeader
|
||||||
style={{
|
title="发票管理"
|
||||||
display: 'flex',
|
settings={settingsButton}
|
||||||
justifyContent: 'space-between',
|
actions={
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
||||||
发票管理
|
|
||||||
</Typography.Title>
|
|
||||||
{settingsButton}
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -259,7 +258,8 @@ export default function InvoicesPage() {
|
|||||||
>
|
>
|
||||||
创建发票申请
|
创建发票申请
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -127,7 +129,13 @@ export default function KnowledgeBasesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '说明', dataIndex: 'description' },
|
{ title: '说明', dataIndex: 'description' },
|
||||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||||
{
|
{
|
||||||
@@ -257,15 +265,11 @@ export default function KnowledgeBasesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="知识库"
|
||||||
知识库
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
|
||||||
{settingsButton}
|
/>
|
||||||
<Typography.Text type="secondary">
|
|
||||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -156,7 +158,13 @@ export default function LlmConfigsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '提供商',
|
title: '提供商',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
@@ -264,15 +272,11 @@ export default function LlmConfigsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="语言模型配置"
|
||||||
语言模型配置
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
|
||||||
{settingsButton}
|
/>
|
||||||
<Typography.Text type="secondary">
|
|
||||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { downloadExcelCsv } from '../lib/exportExcel';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
type BillRow = {
|
type BillRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -246,7 +248,14 @@ export default function LogisticsBillsPage() {
|
|||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||||
|
|
||||||
const billColumns: ColumnsType<BillRow> = [
|
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: '承运商',
|
title: '承运商',
|
||||||
width: 140,
|
width: 140,
|
||||||
@@ -397,15 +406,11 @@ export default function LogisticsBillsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="物流对账"
|
||||||
物流对账
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
|
||||||
{settingsButton}
|
/>
|
||||||
<Typography.Text type="secondary">
|
|
||||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,10 +27,13 @@ import dayjs, { type Dayjs } from 'dayjs';
|
|||||||
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
||||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { downloadBase64File } from '../lib/exportExcel';
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
DELIVERY_TYPE_LABELS,
|
||||||
ORDER_STATUS_COLORS,
|
ORDER_STATUS_COLORS,
|
||||||
ORDER_STATUS_LABELS,
|
ORDER_STATUS_LABELS,
|
||||||
ORDER_STATUS_OPERATOR_LABELS,
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
@@ -142,7 +145,7 @@ type OrderExportFormat = 'xlsx' | 'pdf';
|
|||||||
|
|
||||||
type OrderExportFilters = {
|
type OrderExportFilters = {
|
||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
status?: string;
|
status?: string | string[];
|
||||||
orderType?: string;
|
orderType?: string;
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
receiverPhone?: string;
|
receiverPhone?: string;
|
||||||
@@ -217,6 +220,12 @@ function formatBenefitBrief(row: AdminOrderRow) {
|
|||||||
return '—';
|
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(
|
function buildExportPayload(
|
||||||
scope: OrderExportScope,
|
scope: OrderExportScope,
|
||||||
format: OrderExportFormat,
|
format: OrderExportFormat,
|
||||||
@@ -229,7 +238,8 @@ function buildExportPayload(
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
if (filters.orderNo) payload.orderNo = filters.orderNo;
|
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.orderType) payload.orderType = filters.orderType;
|
||||||
if (filters.cityId) payload.cityId = filters.cityId;
|
if (filters.cityId) payload.cityId = filters.cityId;
|
||||||
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
||||||
@@ -273,6 +283,7 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -354,7 +365,7 @@ export default function OrdersPage() {
|
|||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
||||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
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.orderType) qs.set('orderType', values.orderType);
|
||||||
if (values.cityId) qs.set('cityId', values.cityId);
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
@@ -592,64 +603,99 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<AdminOrderRow> = [
|
const baseColumns: ColumnsType<AdminOrderRow> = [
|
||||||
|
{
|
||||||
|
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: '用户',
|
||||||
|
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: '商品',
|
title: '商品',
|
||||||
width: 240,
|
key: 'productName',
|
||||||
|
width: 180,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div>
|
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<span>{row.productName || '—'}</span>
|
<span>{row.productName || '—'}</span>
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
|
||||||
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
||||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||||
<Tag color="purple">代下单</Tag>
|
<Tag color="purple">代下单</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</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: '状态 / 实付',
|
title: '规格',
|
||||||
width: 130,
|
dataIndex: 'productSpec',
|
||||||
render: (_, row) => (
|
width: 140,
|
||||||
<div>
|
render: (v: string | undefined) => v || '—',
|
||||||
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
|
},
|
||||||
{ORDER_STATUS_LABELS[row.status] || row.status}
|
{
|
||||||
</Tag>
|
title: '数量',
|
||||||
<div>¥{row.payAmount}</div>
|
dataIndex: 'quantity',
|
||||||
</div>
|
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: '好客权益',
|
title: '好客权益',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, row) => formatBenefitBrief(row),
|
render: (_, row) => formatBenefitBrief(row),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '收货信息',
|
title: '收货人',
|
||||||
width: 240,
|
dataIndex: 'receiverName',
|
||||||
render: (_, row) => {
|
width: 90,
|
||||||
const address = formatReceiverAddress(row);
|
render: (v: string | undefined) => v || '—',
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
{row.receiverName || '—'} {row.receiverPhone || ''}
|
|
||||||
</div>
|
|
||||||
{address ? (
|
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
{address}
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '电话',
|
||||||
|
dataIndex: 'receiverPhone',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string | undefined) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '地址',
|
||||||
|
key: 'receiverAddress',
|
||||||
|
width: 260,
|
||||||
|
render: (_, row) => formatReceiverAddress(row) || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '下单时间',
|
title: '下单时间',
|
||||||
@@ -695,18 +741,20 @@ export default function OrdersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
title="订单监控"
|
||||||
<Space size={12}>
|
settings={settingsButton}
|
||||||
{settingsButton}
|
actions={
|
||||||
|
<>
|
||||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||||
{canProxyOrder ? (
|
{canProxyOrder ? (
|
||||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
代下单
|
代下单
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</>
|
||||||
</Space>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{!canDeleteOrders && profile ? (
|
{!canDeleteOrders && profile ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -744,10 +792,12 @@ export default function OrdersPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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 }}>
|
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||||
<Select
|
<Select
|
||||||
|
mode="multiple"
|
||||||
allowClear
|
allowClear
|
||||||
|
maxTagCount="responsive"
|
||||||
placeholder="全部"
|
placeholder="全部"
|
||||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
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 { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
@@ -176,14 +177,13 @@ export default function PartnerAccountsPage() {
|
|||||||
title: '姓名 / 类型',
|
title: '姓名 / 类型',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<AdminCellLine
|
<span className="admin-cell-line">
|
||||||
primary={row.name}
|
<AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
|
||||||
secondary={
|
<span className="admin-cell-line-secondary">
|
||||||
row.parentAccountId
|
{' · '}
|
||||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
{row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
|
||||||
: '主账号'
|
</span>
|
||||||
}
|
</span>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
@@ -262,15 +262,11 @@ export default function PartnerAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<div>
|
title="合伙人子账号"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
settings={settingsButton}
|
||||||
{settingsButton}
|
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
|
||||||
<Typography.Text type="secondary">
|
/>
|
||||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -247,7 +249,14 @@ export default function PartnerBillsPage() {
|
|||||||
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
{
|
||||||
|
title: '账单号',
|
||||||
|
dataIndex: 'billNo',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 160,
|
width: 160,
|
||||||
@@ -341,15 +350,11 @@ export default function PartnerBillsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
return (
|
<AdminListHeader
|
||||||
<div>
|
title="合伙人账单"
|
||||||
{settingsModal}
|
settings={settingsButton}
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
description="每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
/>
|
||||||
合伙人账单
|
|
||||||
</Typography.Title>
|
|
||||||
{settingsButton}
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -108,7 +110,21 @@ export default function PartnerLogsPage() {
|
|||||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
<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: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -141,10 +157,7 @@ export default function PartnerLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
return (
|
<AdminListHeader title="合伙人日志" settings={settingsButton} />
|
||||||
<div>
|
|
||||||
{settingsModal}
|
|
||||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
|
||||||
<Segmented
|
<Segmented
|
||||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
value={category}
|
value={category}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -184,7 +186,13 @@ export default function PartnersPage() {
|
|||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
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: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -211,13 +219,15 @@ export default function PartnersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
{settingsModal}
|
<AdminListHeader
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
title="开城合伙人"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||||
新建城市合伙人
|
新建城市合伙人
|
||||||
</Button>
|
</Button>
|
||||||
</Button>
|
}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||||
@@ -97,7 +99,14 @@ export default function PendingRedeemPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
||||||
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
{
|
||||||
|
title: '待处理单号',
|
||||||
|
dataIndex: 'pendingNo',
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '核销码 ID',
|
title: '核销码 ID',
|
||||||
dataIndex: 'redeemToken',
|
dataIndex: 'redeemToken',
|
||||||
@@ -138,8 +147,7 @@ export default function PendingRedeemPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
{settingsModal}
|
<AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} />
|
||||||
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import DetailImageUrlList from '../components/DetailImageUrlList';
|
|||||||
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
||||||
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = ProductDetailTemplateDto;
|
type Row = ProductDetailTemplateDto;
|
||||||
@@ -140,7 +141,23 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
{ 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: 'description', width: 200 },
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
{ 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 },
|
{ 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 ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
@@ -391,7 +393,14 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
{ 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: '累计销售',
|
title: '累计销售',
|
||||||
dataIndex: 'soldBottles',
|
dataIndex: 'soldBottles',
|
||||||
@@ -502,11 +511,11 @@ export default function ProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
title="商品管理"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>}
|
||||||
</Space>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="名称">
|
<Form.Item name="name" label="名称">
|
||||||
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = PromoCodeItem;
|
type Row = PromoCodeItem;
|
||||||
@@ -77,7 +79,14 @@ export default function PromoCodesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: '码值', dataIndex: 'code', width: 110 },
|
||||||
{
|
{
|
||||||
title: '场景',
|
title: '场景',
|
||||||
@@ -167,11 +176,11 @@ export default function PromoCodesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
title="推广码管理"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -84,7 +86,7 @@ export default function RedeemRecordsPage() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<span>
|
<span>
|
||||||
{v}
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
{row.isTest ? (
|
{row.isTest ? (
|
||||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||||
测试
|
测试
|
||||||
@@ -140,8 +142,7 @@ export default function RedeemRecordsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
<AdminListHeader title="核销记录" settings={settingsButton} />
|
||||||
{settingsButton}
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -121,15 +122,11 @@ export default function ResourcesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="OSS 资源库"
|
||||||
OSS 资源库
|
settings={settingsButton}
|
||||||
</Typography.Title>
|
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>登记资源</Button>}
|
||||||
{settingsButton}
|
/>
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
|
||||||
登记资源
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
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 { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
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 };
|
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||||
@@ -83,7 +85,14 @@ export default function StoreAccountsPage() {
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space size={4}>
|
<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}
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -145,14 +154,11 @@ export default function StoreAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Space direction="vertical" size={0}>
|
title="门店账户"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
settings={settingsButton}
|
||||||
{settingsButton}
|
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
||||||
<Typography.Text type="secondary">
|
actions={
|
||||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -162,7 +168,8 @@ export default function StoreAccountsPage() {
|
|||||||
>
|
>
|
||||||
新建账户
|
新建账户
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
}
|
||||||
|
/>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -286,7 +288,7 @@ export default function StoreBillsPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Space>
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -380,21 +382,23 @@ export default function StoreBillsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
return (
|
<AdminListHeader
|
||||||
<div>
|
title="门店账单"
|
||||||
{settingsModal}
|
settings={settingsButton}
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
description={
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<>
|
||||||
门店账单
|
|
||||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||||
|
<div>
|
||||||
<Typography.Text type="danger">
|
<Typography.Text type="danger">
|
||||||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type CategoryNode = {
|
type CategoryNode = {
|
||||||
@@ -130,7 +132,7 @@ export default function StoreCategoriesPage() {
|
|||||||
render: (name, row) => (
|
render: (name, row) => (
|
||||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||||
{name}
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -178,13 +180,12 @@ export default function StoreCategoriesPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Space direction="vertical" size={0}>
|
title="门店分类"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
settings={settingsButton}
|
||||||
{settingsButton}
|
description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
|
||||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
actions={
|
||||||
</Space>
|
<>
|
||||||
<Space>
|
|
||||||
{canDelete ? (
|
{canDelete ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -197,8 +198,9 @@ export default function StoreCategoriesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||||
</Space>
|
</>
|
||||||
</Space>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -103,7 +105,23 @@ export default function StoreLogsPage() {
|
|||||||
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
<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,
|
title: '来源', dataIndex: 'source', width: 100,
|
||||||
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
||||||
@@ -134,11 +152,11 @@ export default function StoreLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>商户日志</Typography.Title>
|
<AdminListHeader
|
||||||
{settingsButton}
|
title="商户日志"
|
||||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
settings={settingsButton}
|
||||||
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。"
|
||||||
</Typography.Paragraph>
|
/>
|
||||||
<Segmented
|
<Segmented
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
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 { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -43,7 +45,22 @@ export default function StoreMediaPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||||
{
|
{
|
||||||
title: '预览', dataIndex: 'url', width: 100,
|
title: '预览', dataIndex: 'url', width: 100,
|
||||||
@@ -80,11 +97,11 @@ export default function StoreMediaPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
title="门店资源"
|
||||||
{settingsButton}
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>}
|
||||||
</Space>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="mediaType" label="类型">
|
<Form.Item name="mediaType" label="类型">
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import { notifyPackageAuditChanged } from '../lib/admin-events';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
PENDING: '待审核',
|
PENDING: '待审核',
|
||||||
@@ -199,7 +201,15 @@ function InfoChangeAuditPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -471,7 +481,15 @@ export default function StorePackageAuditsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -528,8 +546,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>审核通知</Typography.Title>
|
<AdminListHeader title="审核通知" settings={settingsButton} />
|
||||||
{settingsButton}
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -69,10 +70,7 @@ export default function StoreRatingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
<AdminListHeader title="门店评价" settings={settingsButton} />
|
||||||
门店评价
|
|
||||||
</Typography.Title>
|
|
||||||
{settingsButton}
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -156,7 +158,7 @@ export default function StoreWithdrawalsPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space>
|
<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}
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -236,10 +238,7 @@ export default function StoreWithdrawalsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
<AdminListHeader title="门店提现审" settings={settingsButton} />
|
||||||
门店提现审
|
|
||||||
</Typography.Title>
|
|
||||||
{settingsButton}
|
|
||||||
{overdueSummary ? (
|
{overdueSummary ? (
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
待审 {overdueSummary.pendingCount} 笔
|
待审 {overdueSummary.pendingCount} 笔
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
} from '../lib/storeCreate';
|
} from '../lib/storeCreate';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { resolveRegionBinding } from '../lib/china-region';
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
@@ -739,7 +741,7 @@ export default function StoresPage() {
|
|||||||
const name = v || '—';
|
const name = v || '—';
|
||||||
return (
|
return (
|
||||||
<Space size={4} wrap={false}>
|
<Space size={4} wrap={false}>
|
||||||
<span>{name}</span>
|
<AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
@@ -872,16 +874,14 @@ export default function StoresPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Space direction="vertical" size={0}>
|
title="门店"
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
description={`共 ${data?.total ?? 0} 家门店(含合伙人录入)`}
|
||||||
</Space>
|
actions={
|
||||||
<Space>
|
|
||||||
{settingsButton}
|
|
||||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||||
</Space>
|
}
|
||||||
</Space>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<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="name" label="名称"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="phone" 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 { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
||||||
@@ -505,7 +506,13 @@ export default function SupportTicketsPage() {
|
|||||||
</Space>
|
</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: 'creatorName', width: 100 },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ 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 { request, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
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;
|
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||||
|
|
||||||
@@ -254,7 +256,22 @@ export default function TestWhitelistPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phoneColumns: ColumnsType<PhoneRow> = [
|
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: '备注',
|
title: '备注',
|
||||||
dataIndex: 'note',
|
dataIndex: 'note',
|
||||||
@@ -394,10 +411,7 @@ export default function TestWhitelistPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
<AdminListHeader title="白名单管理" settings={settingsButton} />
|
||||||
白名单管理
|
|
||||||
</Typography.Title>
|
|
||||||
{settingsButton}
|
|
||||||
|
|
||||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -95,7 +97,21 @@ export default function ThirdPartyLogsPage() {
|
|||||||
title: '请求摘要',
|
title: '请求摘要',
|
||||||
render: (_, r) => summarizeJson(r.requestBody),
|
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: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -122,8 +138,7 @@ export default function ThirdPartyLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
<AdminListHeader title="第三方日志" settings={settingsButton} />
|
||||||
{settingsButton}
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -118,7 +119,21 @@ export default function TicketsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: '类型',
|
title: '类型',
|
||||||
dataIndex: 'ticketType',
|
dataIndex: 'ticketType',
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -80,7 +82,21 @@ export default function UserLogsPage() {
|
|||||||
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
<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,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
@@ -105,8 +121,7 @@ export default function UserLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>用户日志</Typography.Title>
|
<AdminListHeader title="用户日志" settings={settingsButton} />
|
||||||
{settingsButton}
|
|
||||||
<Segmented
|
<Segmented
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
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 { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
||||||
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||||
|
|
||||||
type UserOrderRow = {
|
type UserOrderRow = {
|
||||||
@@ -332,7 +334,9 @@ export default function UsersPage() {
|
|||||||
title: '昵称',
|
title: '昵称',
|
||||||
dataIndex: 'nickname',
|
dataIndex: 'nickname',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (v: string | null) => v || '—',
|
render: (v: string | null, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
@@ -478,11 +482,11 @@ export default function UsersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
title="用户监控"
|
||||||
<Space>
|
settings={settingsButton}
|
||||||
{settingsButton}
|
actions={
|
||||||
{canDeleteUsers ? (
|
canDeleteUsers ? (
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
disabled={!selectedRowKeys.length}
|
disabled={!selectedRowKeys.length}
|
||||||
@@ -490,9 +494,9 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null
|
||||||
</Space>
|
}
|
||||||
</Space>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="phone" label="手机号">
|
<Form.Item name="phone" label="手机号">
|
||||||
<Input placeholder="模糊搜索" allowClear />
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
|||||||
import { fmtTime, maskPhone } from '../lib/constants';
|
import { fmtTime, maskPhone } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
@@ -120,7 +122,9 @@ export default function WechatBindingsPage() {
|
|||||||
title: '手机号',
|
title: '手机号',
|
||||||
dataIndex: 'primaryPhone',
|
dataIndex: 'primaryPhone',
|
||||||
width: 140,
|
width: 140,
|
||||||
width: 140,
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '身份摘要',
|
title: '身份摘要',
|
||||||
@@ -207,11 +211,11 @@ export default function WechatBindingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
<AdminListHeader
|
||||||
|
title="微信绑定总览"
|
||||||
return (
|
settings={settingsButton}
|
||||||
<div>
|
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
|
||||||
{settingsModal}
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
export default function WecomBotLogsPage() {
|
export default function WecomBotLogsPage() {
|
||||||
@@ -28,7 +30,14 @@ export default function WecomBotLogsPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ 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: 'wecomUserId', width: 120 },
|
||||||
{ title: '动作', dataIndex: 'action', width: 160 },
|
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||||
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||||
@@ -56,11 +65,11 @@ export default function WecomBotLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
<AdminListHeader
|
||||||
{settingsButton}
|
title="企微机器人日志"
|
||||||
<Typography.Paragraph type="secondary">
|
settings={settingsButton}
|
||||||
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
|
||||||
</Typography.Paragraph>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -293,7 +294,14 @@ export default function WecomBotsPage() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '名称', dataIndex: 'name', width: 140 },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
@@ -202,7 +204,7 @@ function PushRoutesTab() {
|
|||||||
render: (name: string, row) => (
|
render: (name: string, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
||||||
<span>{name}</span>
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -271,9 +273,15 @@ function PushRoutesTab() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary">
|
<AdminListHeader
|
||||||
配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。
|
settings={settingsButton}
|
||||||
</Typography.Paragraph>
|
description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建推送
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
@@ -308,10 +316,6 @@ function PushRoutesTab() {
|
|||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" onClick={openCreate}>
|
|
||||||
新建推送
|
|
||||||
</Button>
|
|
||||||
{settingsButton}
|
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { downloadExcelCsv } from '../lib/exportExcel';
|
|||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -238,7 +240,14 @@ export default function WineryBillsPage() {
|
|||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
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: '账单日',
|
title: '账单日',
|
||||||
dataIndex: 'billDate',
|
dataIndex: 'billDate',
|
||||||
@@ -297,31 +306,18 @@ export default function WineryBillsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div
|
<AdminListHeader
|
||||||
style={{
|
title="酒厂对账单"
|
||||||
display: 'flex',
|
settings={settingsButton}
|
||||||
justifyContent: 'space-between',
|
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
||||||
alignItems: 'flex-start',
|
actions={
|
||||||
marginBottom: 16,
|
canEditWineryBank ? (
|
||||||
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 type="default" onClick={() => void openBankModal()}>
|
||||||
酒厂银行账户信息配置
|
酒厂银行账户信息配置
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { fmtTime } from '../../lib/constants';
|
|||||||
import { useAdminList } from '../../lib/useAdminList';
|
import { useAdminList } from '../../lib/useAdminList';
|
||||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
|
||||||
|
import { AdminListHeader } from '../../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
export default function PromoCodeUsersPage() {
|
export default function PromoCodeUsersPage() {
|
||||||
@@ -25,7 +27,16 @@ export default function PromoCodeUsersPage() {
|
|||||||
|
|
||||||
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
|
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
|
||||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
{ 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: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '验手机',
|
title: '验手机',
|
||||||
@@ -74,6 +85,9 @@ export default function PromoCodeUsersPage() {
|
|||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader settings={settingsButton} />
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
className="admin-table-nowrap"
|
className="admin-table-nowrap"
|
||||||
@@ -89,5 +103,6 @@ export default function PromoCodeUsersPage() {
|
|||||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Text } from '@tarojs/components';
|
import { Button, Text } from '@tarojs/components';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { toast } from '../lib/api';
|
||||||
|
import { getBrandAssetsSync, loadBrandAssets } from '../lib/brand-assets';
|
||||||
|
import { openWecomCustomerServiceChat } from '../lib/wecom-cs';
|
||||||
|
|
||||||
export type ContactCsSessionContext = {
|
export type ContactCsSessionContext = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
@@ -16,7 +20,7 @@ type ContactCsButtonProps = {
|
|||||||
|
|
||||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
/** 组装 session-from(微信限制约 1000 字符) */
|
/** 组装 session-from(微信限制约 1000 字符;原生小程序客服兜底用) */
|
||||||
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
||||||
if (!session) return 'dukang|from=mini-user';
|
if (!session) return 'dukang|from=mini-user';
|
||||||
const parts = ['dukang'];
|
const parts = ['dukang'];
|
||||||
@@ -26,16 +30,59 @@ export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
|||||||
return parts.join('|');
|
return parts.join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canOpenWecom(url: string, corpId: string) {
|
||||||
|
return !!url.trim() && !!corpId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 微信小程序客服入口(open-type=contact)。
|
* C 端在线客服:
|
||||||
* 非 weapp 环境不渲染,由调用方走电话等兜底。
|
* - weapp 且已配置 CorpID + kfid:调起企业微信「微信客服」
|
||||||
|
* - weapp 未配 CorpID:回退 open-type=contact
|
||||||
|
* - H5:有 kfid 则打开企微客服链接
|
||||||
*/
|
*/
|
||||||
export default function ContactCsButton({
|
export default function ContactCsButton({
|
||||||
className = '',
|
className = '',
|
||||||
children = '联系在线客服',
|
children = '联系在线客服',
|
||||||
session,
|
session,
|
||||||
}: ContactCsButtonProps) {
|
}: ContactCsButtonProps) {
|
||||||
if (!isWeapp) return null;
|
const [cs, setCs] = useState(() => getBrandAssetsSync());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadBrandAssets().then(setCs);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const wecomChatReady = canOpenWecom(cs.customerServiceWecomUrl, cs.wecomCorpId);
|
||||||
|
const wecomWebReady = !!cs.customerServiceWecomUrl.trim();
|
||||||
|
|
||||||
|
if (!isWeapp && !wecomWebReady) return null;
|
||||||
|
|
||||||
|
async function openWecom() {
|
||||||
|
try {
|
||||||
|
if (isWeapp && wecomChatReady) {
|
||||||
|
await openWecomCustomerServiceChat({
|
||||||
|
url: cs.customerServiceWecomUrl,
|
||||||
|
corpId: cs.wecomCorpId,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && cs.customerServiceWecomUrl) {
|
||||||
|
window.location.href = cs.customerServiceWecomUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('请在微信内打开后联系客服');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : '无法打开客服');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wecomChatReady || (!isWeapp && wecomWebReady)) {
|
||||||
|
return (
|
||||||
|
<Button className={className} hoverClass="none" onClick={() => void openWecom()}>
|
||||||
|
{typeof children === 'string' ? <Text>{children}</Text> : children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { RichText, Text } from '@tarojs/components';
|
||||||
|
import { deliveryHintHtmlToRichNodes } from '@dukang/shared-types';
|
||||||
|
import { DEFAULT_LOCAL_DELIVERY_HINT } from '../lib/local-delivery';
|
||||||
|
|
||||||
|
type DeliveryHintHtmlProps = {
|
||||||
|
html?: string | null;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 承运商配送提示:HTML 用 RichText;textarea 里的换行转成 br */
|
||||||
|
export default function DeliveryHintHtml({
|
||||||
|
html,
|
||||||
|
className = '',
|
||||||
|
}: DeliveryHintHtmlProps) {
|
||||||
|
const raw = (html || '').trim() || DEFAULT_LOCAL_DELIVERY_HINT;
|
||||||
|
const nodes = deliveryHintHtmlToRichNodes(raw);
|
||||||
|
const looksHtml = /<[a-z][\s\S]*>/i.test(nodes);
|
||||||
|
if (!looksHtml) {
|
||||||
|
return <Text className={className}>{nodes}</Text>;
|
||||||
|
}
|
||||||
|
return <RichText className={className} nodes={nodes} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { LocalDeliveryDto } from '@dukang/shared-types';
|
||||||
|
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
export { DEFAULT_LOCAL_DELIVERY_HINT };
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
let cached: LocalDeliveryDto[] | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
let inflight: Promise<LocalDeliveryDto[]> | null = null;
|
||||||
|
|
||||||
|
function cityAliases(name: string): string[] {
|
||||||
|
const raw = name.trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
const noSuffix = raw.replace(/市$/, '');
|
||||||
|
const withSuffix = raw.endsWith('市') ? raw : `${raw}市`;
|
||||||
|
return [raw, noSuffix, withSuffix];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLocalDeliveries(force = false): Promise<LocalDeliveryDto[]> {
|
||||||
|
if (!force && cached && Date.now() - cachedAt < CACHE_TTL_MS) return cached;
|
||||||
|
if (!force && inflight) return inflight;
|
||||||
|
inflight = request<LocalDeliveryDto[]>('/catalog/local-deliveries')
|
||||||
|
.then((list) => {
|
||||||
|
cached = Array.isArray(list) ? list : [];
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return cached;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cached = cached ?? [];
|
||||||
|
return cached;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
});
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchLocalDelivery(
|
||||||
|
list: LocalDeliveryDto[],
|
||||||
|
opts: { cityCode?: string | null; cityName?: string | null },
|
||||||
|
): LocalDeliveryDto | null {
|
||||||
|
const code = String(opts.cityCode || '').trim();
|
||||||
|
if (code) {
|
||||||
|
const byCode = list.find((row) => row.city.code === code);
|
||||||
|
if (byCode) return byCode;
|
||||||
|
}
|
||||||
|
const names = new Set(cityAliases(String(opts.cityName || '')));
|
||||||
|
if (!names.size) return null;
|
||||||
|
return list.find((row) => cityAliases(row.city.name).some((n) => names.has(n))) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocalDeliveryHintHtml(row: LocalDeliveryDto | null): string {
|
||||||
|
const html = row?.hintHtml?.trim();
|
||||||
|
if (html) return html;
|
||||||
|
return DEFAULT_LOCAL_DELIVERY_HINT;
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { DEFAULT_LOCAL_DELIVERY_HINT } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
/** @deprecated 使用 DEFAULT_LOCAL_DELIVERY_HINT;空配置回退 */
|
||||||
|
export const LOCAL_DELIVERY_ETA_HINT = DEFAULT_LOCAL_DELIVERY_HINT;
|
||||||
|
|
||||||
/** 商品履约能力(与 HQ / 交易硬闸一致) */
|
/** 商品履约能力(与 HQ / 交易硬闸一致) */
|
||||||
|
|
||||||
export type FulfillmentFlags = {
|
export type FulfillmentFlags = {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
|
||||||
|
export type WecomCsSessionContext = {
|
||||||
|
orderId?: string;
|
||||||
|
orderNo?: string;
|
||||||
|
from?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenCsChatOption = {
|
||||||
|
extInfo: { url: string };
|
||||||
|
corpId: string;
|
||||||
|
showMessageCard?: boolean;
|
||||||
|
sendMessageTitle?: string;
|
||||||
|
sendMessagePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenCsChatFn = (option: OpenCsChatOption) => Promise<unknown>;
|
||||||
|
|
||||||
|
function getTaroOpenCsChat(): OpenCsChatFn | null {
|
||||||
|
const api = (Taro as unknown as { openCustomerServiceChat?: OpenCsChatFn }).openCustomerServiceChat;
|
||||||
|
return typeof api === 'function' ? api : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWxOpenCsChat(): ((option: OpenCsChatOption & {
|
||||||
|
success?: () => void;
|
||||||
|
fail?: (err: { errMsg?: string }) => void;
|
||||||
|
}) => void) | null {
|
||||||
|
const wxApi = (
|
||||||
|
globalThis as {
|
||||||
|
wx?: {
|
||||||
|
openCustomerServiceChat?: (option: OpenCsChatOption & {
|
||||||
|
success?: () => void;
|
||||||
|
fail?: (err: { errMsg?: string }) => void;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).wx?.openCustomerServiceChat;
|
||||||
|
return typeof wxApi === 'function' ? wxApi : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 小程序调起企业微信「微信客服」会话 */
|
||||||
|
export async function openWecomCustomerServiceChat(params: {
|
||||||
|
url: string;
|
||||||
|
corpId: string;
|
||||||
|
session?: WecomCsSessionContext;
|
||||||
|
}): Promise<void> {
|
||||||
|
const url = params.url.trim();
|
||||||
|
const corpId = params.corpId.trim();
|
||||||
|
if (!url || !corpId) {
|
||||||
|
throw new Error('企微客服未配置');
|
||||||
|
}
|
||||||
|
|
||||||
|
const option: OpenCsChatOption = {
|
||||||
|
extInfo: { url },
|
||||||
|
corpId,
|
||||||
|
};
|
||||||
|
if (params.session?.orderNo || params.session?.orderId) {
|
||||||
|
option.showMessageCard = true;
|
||||||
|
option.sendMessageTitle = params.session.orderNo
|
||||||
|
? `订单 ${params.session.orderNo}`
|
||||||
|
: '订单咨询';
|
||||||
|
if (params.session.orderId) {
|
||||||
|
option.sendMessagePath = `pages/order-detail/index?id=${params.session.orderId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taroApi = getTaroOpenCsChat();
|
||||||
|
if (taroApi) {
|
||||||
|
await taroApi(option);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wxApi = getWxOpenCsChat();
|
||||||
|
if (!wxApi) {
|
||||||
|
throw new Error('当前微信版本不支持企业微信客服');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
wxApi({
|
||||||
|
...option,
|
||||||
|
success: () => resolve(),
|
||||||
|
fail: (err) => reject(new Error(err?.errMsg || '无法打开企业微信客服')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,35 +16,43 @@ function dialPhone(phone: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomerServicePage() {
|
export default function CustomerServicePage() {
|
||||||
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
|
const [brand, setBrand] = useState(() => getBrandAssetsSync());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadBrandAssets().then((b) => setPhone(b.customerServicePhone));
|
void loadBrandAssets().then(setBrand);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const phone = brand.customerServicePhone;
|
||||||
|
const wecomReady = !!brand.customerServiceWecomUrl.trim() && !!brand.wecomCorpId.trim();
|
||||||
|
const wecomUrlReady = !!brand.customerServiceWecomUrl.trim();
|
||||||
|
|
||||||
|
const hint = isWeapp
|
||||||
|
? wecomReady
|
||||||
|
? '点击下方按钮,进入企业微信客服会话'
|
||||||
|
: '点击下方按钮,进入在线客服会话'
|
||||||
|
: wecomUrlReady
|
||||||
|
? '点击下方按钮进入企业微信客服,或拨打客服电话'
|
||||||
|
: '请在微信小程序内打开以使用在线客服,或拨打客服电话';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="cs-page">
|
<PageShell variant="sub" className="cs-page">
|
||||||
<SubPageHeader title="联系客服" />
|
<SubPageHeader title="联系客服" />
|
||||||
<View className="sub-page-body inset-page cs-body">
|
<View className="sub-page-body inset-page cs-body">
|
||||||
<Text className="cs-brand">杜康好客客服</Text>
|
<Text className="cs-brand">杜康好客客服</Text>
|
||||||
<Text className="cs-hint">
|
<Text className="cs-hint">{hint}</Text>
|
||||||
{isWeapp
|
|
||||||
? '点击下方按钮,进入小程序在线客服会话'
|
|
||||||
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
|
|
||||||
</Text>
|
|
||||||
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
||||||
|
|
||||||
{isWeapp ? (
|
{isWeapp || wecomUrlReady ? (
|
||||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
|
<View className={isWeapp || wecomUrlReady ? 'cs-phone-link' : 'cs-call-btn'} onClick={() => dialPhone(phone)}>
|
||||||
<Text>
|
<Text>
|
||||||
{isWeapp ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
{isWeapp || wecomUrlReady ? `或拨打客服电话 ${phone}` : '拨打客服电话'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!isWeapp ? (
|
{!isWeapp && !wecomUrlReady ? (
|
||||||
<Text className="cs-phone-display">{phone}</Text>
|
<Text className="cs-phone-display">{phone}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { maskPhone } from '../../lib/phone';
|
|||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||||
|
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||||
import { getProductMainImage } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
import BenefitFigure from '../../components/BenefitFigure';
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ export default function OrderConfirmPage() {
|
|||||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||||
|
const [localHintHtml, setLocalHintHtml] = useState('');
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
@@ -151,6 +154,18 @@ export default function OrderConfirmPage() {
|
|||||||
[addresses, addressId],
|
[addresses, addressId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cityName = selectedAddress?.city;
|
||||||
|
if (!cityName) {
|
||||||
|
setLocalHintHtml('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadLocalDeliveries().then((list) => {
|
||||||
|
const row = matchLocalDelivery(list, { cityName });
|
||||||
|
setLocalHintHtml(row ? resolveLocalDeliveryHintHtml(row) : '');
|
||||||
|
});
|
||||||
|
}, [selectedAddress?.city]);
|
||||||
|
|
||||||
const allowCross =
|
const allowCross =
|
||||||
preview?.allowCrossCityDelivery !== undefined
|
preview?.allowCrossCityDelivery !== undefined
|
||||||
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
|
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
|
||||||
@@ -339,6 +354,12 @@ export default function OrderConfirmPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!isCross && addressOk && addressId && localHintHtml ? (
|
||||||
|
<View className="order-card">
|
||||||
|
<DeliveryHintHtml className="u-muted" html={localHintHtml} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<>
|
<>
|
||||||
<View className="order-card">
|
<View className="order-card">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import ContactCsButton from '../../components/ContactCsButton';
|
|||||||
import LogisticsRichText from '../../components/LogisticsRichText';
|
import LogisticsRichText from '../../components/LogisticsRichText';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||||
|
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||||
|
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||||
import {
|
import {
|
||||||
formatEstimatedArrival,
|
formatEstimatedArrival,
|
||||||
isLogisticsNotArrived,
|
isLogisticsNotArrived,
|
||||||
@@ -83,6 +85,22 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
REFUNDED: '已退款',
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function applyLocalDeliveryHint(
|
||||||
|
data: OrderDetail,
|
||||||
|
setLocalHintHtml: (html: string) => void,
|
||||||
|
) {
|
||||||
|
if (data.deliveryType !== 'LOCAL') {
|
||||||
|
setLocalHintHtml('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadLocalDeliveries().then((list) => {
|
||||||
|
const row = data.receiverCity
|
||||||
|
? matchLocalDelivery(list, { cityName: data.receiverCity })
|
||||||
|
: null;
|
||||||
|
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function fullReceiverAddress(order: OrderDetail) {
|
function fullReceiverAddress(order: OrderDetail) {
|
||||||
const detail = (order.receiverAddress || '').trim();
|
const detail = (order.receiverAddress || '').trim();
|
||||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||||
@@ -102,6 +120,7 @@ export default function OrderDetailPage() {
|
|||||||
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
const [latestTrack, setLatestTrack] = useState<OrderTrackNode | null>(null);
|
||||||
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
|
const [estimatedArrival, setEstimatedArrival] = useState<OrderTrackEstimatedArrival | null>(null);
|
||||||
const [trackLoading, setTrackLoading] = useState(false);
|
const [trackLoading, setTrackLoading] = useState(false);
|
||||||
|
const [localHintHtml, setLocalHintHtml] = useState('');
|
||||||
|
|
||||||
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
|
async function loadOrderTrack(delivery: OrderDetail['delivery'], orderStatus?: string) {
|
||||||
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
if (!orderId || !shouldLoadOrderTrack(delivery)) {
|
||||||
@@ -133,6 +152,7 @@ export default function OrderDetailPage() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setOrder(data);
|
setOrder(data);
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
void loadOrderTrack(data.delivery, data.status);
|
||||||
|
applyLocalDeliveryHint(data, setLocalHintHtml);
|
||||||
})
|
})
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
}, [orderId]);
|
}, [orderId]);
|
||||||
@@ -144,6 +164,7 @@ export default function OrderDetailPage() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setOrder(data);
|
setOrder(data);
|
||||||
void loadOrderTrack(data.delivery, data.status);
|
void loadOrderTrack(data.delivery, data.status);
|
||||||
|
applyLocalDeliveryHint(data, setLocalHintHtml);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -361,6 +382,12 @@ export default function OrderDetailPage() {
|
|||||||
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{order.deliveryType === 'LOCAL' && localHintHtml ? (
|
||||||
|
<View className="order-row">
|
||||||
|
<Text className="order-row-label">配送时效</Text>
|
||||||
|
<DeliveryHintHtml className="order-row-value" html={localHintHtml} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import PageNavBar from '../../components/PageNavBar';
|
|||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import BenefitFigure from '../../components/BenefitFigure';
|
import BenefitFigure from '../../components/BenefitFigure';
|
||||||
|
import DeliveryHintHtml from '../../components/DeliveryHintHtml';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
@@ -28,6 +29,8 @@ import {
|
|||||||
canPickupOnSite,
|
canPickupOnSite,
|
||||||
normalizeFulfillmentFlags,
|
normalizeFulfillmentFlags,
|
||||||
} from '../../lib/product-fulfillment';
|
} from '../../lib/product-fulfillment';
|
||||||
|
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||||
|
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||||
import {
|
import {
|
||||||
buildSceneSharePayload,
|
buildSceneSharePayload,
|
||||||
toWeappShareMessage,
|
toWeappShareMessage,
|
||||||
@@ -90,6 +93,7 @@ export default function ProductDetailPage() {
|
|||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const [selected, setSelected] = useState<Record<string, string>>({});
|
const [selected, setSelected] = useState<Record<string, string>>({});
|
||||||
|
const [localHintHtml, setLocalHintHtml] = useState('');
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
@@ -136,6 +140,17 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
loadProduct();
|
loadProduct();
|
||||||
|
void resolveUserCity()
|
||||||
|
.then((city) =>
|
||||||
|
loadLocalDeliveries().then((list) => {
|
||||||
|
const row = matchLocalDelivery(list, {
|
||||||
|
cityCode: getCityCodeForCatalog(city),
|
||||||
|
cityName: city.cityName || city.displayCity || city.city,
|
||||||
|
});
|
||||||
|
setLocalHintHtml(resolveLocalDeliveryHintHtml(row));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => setLocalHintHtml(resolveLocalDeliveryHintHtml(null)));
|
||||||
});
|
});
|
||||||
|
|
||||||
const attrs = product?.specAttrs ?? [];
|
const attrs = product?.specAttrs ?? [];
|
||||||
@@ -317,6 +332,10 @@ export default function ProductDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{allowOnline && localHintHtml ? (
|
||||||
|
<DeliveryHintHtml className="product-detail-fulfillment" html={localHintHtml} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View className="product-detail-promo">
|
<View className="product-detail-promo">
|
||||||
<View className="product-detail-promo-glow" />
|
<View className="product-detail-promo-glow" />
|
||||||
<View className="product-detail-promo-head">
|
<View className="product-detail-promo-head">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import Taro, {
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
import ShareNavButton from '../../components/ShareNavButton';
|
|
||||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
@@ -356,7 +355,6 @@ export default function StoreDetailPage() {
|
|||||||
solid={headerSolid}
|
solid={headerSolid}
|
||||||
titleVisible={headerSolid}
|
titleVisible={headerSolid}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
right={<ShareNavButton payload={sharePayload} />}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="store-detail-hero full-bleed">
|
<View className="store-detail-hero full-bleed">
|
||||||
|
|||||||
@@ -128,6 +128,14 @@
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-fulfillment {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
.product-detail-promo {
|
.product-detail-promo {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
+4
-3
@@ -28,7 +28,8 @@
|
|||||||
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
|
| 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.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.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) |
|
||||||
|
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
|
|
||||||
**HQ 订单导出**(`admin-web` 订单监控):
|
**HQ 订单导出**(`admin-web` 订单监控):
|
||||||
|
|
||||||
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
|
- 支持按筛选(下单日期、状态可多选、类型、城市、配送、收货手机等)或勾选订单导出
|
||||||
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
||||||
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
||||||
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
||||||
@@ -137,7 +138,7 @@
|
|||||||
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
||||||
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
||||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序,偏好挂当前 HQ 账号。
|
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
||||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||||
|
|||||||
@@ -64,10 +64,12 @@
|
|||||||
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
|
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
|
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
|
||||||
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
|
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
|
||||||
|
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 |
|
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
|
||||||
|
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
|
||||||
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||||
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
||||||
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
|
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 杜康好客 · v3.5.10 开发文档
|
||||||
|
|
||||||
|
> **2026-08-25** · mini-user / API
|
||||||
|
> **主题**:门店详情去掉分享按钮;同城配送提示 24 小时内送到;小程序客服接通企业微信
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | DPT-20260824-896 | BUG | C 端门店详情去掉顶栏「分享」按钮 |
|
||||||
|
| 2 | DPT-20260824-601 | 优化 | 同城送提示改承运商「配送信息提示」(HTML);C 端 `GET /catalog/local-deliveries` |
|
||||||
|
| 3 | DPT-20260822-651 | 需求 | 小程序在线客服调起企业微信「微信客服」 |
|
||||||
|
|
||||||
|
**不做**:改配送规则 / 改 SLA 计算;合伙人/门店端客服;禁用微信右上角「···」分享菜单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 门店详情去掉分享按钮
|
||||||
|
|
||||||
|
`pages/store-detail` 顶栏不再渲染 `ShareNavButton`。
|
||||||
|
|
||||||
|
右上角微信原生菜单仍可分享(`enableShareAppMessage` / `WechatShareReady` 保留)。只要去掉页面上的分享按钮。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 同城送提示(承运商 HTML)
|
||||||
|
|
||||||
|
不再写死文案。以用户**收货地址城市**为准:该市 `common_city.status=ACTIVE`(已开城)→ 同城,展示对应仓配承运商的「配送信息提示」;未开城 → 跨城,只展示「总部物流、运费到付」。
|
||||||
|
|
||||||
|
```
|
||||||
|
收货市 → 开城仓库(ACTIVE,优先 API_AUTO 且已绑承运商)→ 承运商.delivery_hint_html
|
||||||
|
```
|
||||||
|
|
||||||
|
空字段时 C 端回退纯文本 `同城配送,预计24小时内送到`。不改下单 / 推单 / 起购。
|
||||||
|
|
||||||
|
### 3.1 承运商字段
|
||||||
|
|
||||||
|
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。允许 `span/p/br/b/strong/i/em/font`,style 仅 `color` / `font-weight` / `font-size` / `font-style`。保存与下发前消毒。HQ 文本框里的回车在 C 端转成换行(不必手写 `<br/>`)。
|
||||||
|
|
||||||
|
HQ「仓配管理 → 承运商」多行输入,例如:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 API
|
||||||
|
|
||||||
|
`GET /catalog/local-deliveries`(公开)。可选 `cityCode` / `cityName`。
|
||||||
|
|
||||||
|
每项:`city` · `warehouse` · `provider` · `hintHtml`。未开城 / 无仓 / 无承运商返回空列表或 `hintHtml=null`,不报错。
|
||||||
|
|
||||||
|
### 3.3 C 端
|
||||||
|
|
||||||
|
| 页面 | 何时展示 |
|
||||||
|
|------|----------|
|
||||||
|
| 商品详情 | 可线上购;用当前选城预览 |
|
||||||
|
| 确认订单 | 收货市已开城且地址校验通过;`RichText` |
|
||||||
|
| 订单详情 | `deliveryType === LOCAL`;按收货市匹配 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 小程序客服接通企业微信
|
||||||
|
|
||||||
|
原先 `open-type=contact` 进入**小程序原生客服**。本版在已配置企微参数时改为 `wx.openCustomerServiceChat`,进入企业微信「微信客服」(与 H5 kfid 同一套)。
|
||||||
|
|
||||||
|
### 配置(HQ → 系统设置 → 微信小程序配置)
|
||||||
|
|
||||||
|
| 键 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| `CUSTOMER_SERVICE_WECOM_URL` | 微信客服 kfid 链接;缺省用代码常量 |
|
||||||
|
| `WECOM_CORP_ID` | 企业 ID(`ww` 开头)。企微「我的企业」可查 |
|
||||||
|
|
||||||
|
`GET /common/client-config` 下发 `customerServiceWecomUrl`、`wecomCorpId`。
|
||||||
|
|
||||||
|
### 行为
|
||||||
|
|
||||||
|
| 环境 | 条件 | 行为 |
|
||||||
|
|------|------|------|
|
||||||
|
| 小程序 | 链接 + CorpID 都有 | `openCustomerServiceChat`;订单详情可带订单卡片 |
|
||||||
|
| 小程序 | 未填 CorpID | 回退 `open-type=contact` |
|
||||||
|
| H5 | 有 kfid 链接 | 打开企微客服网页 |
|
||||||
|
|
||||||
|
### 企微侧前置(运营)
|
||||||
|
|
||||||
|
1. 开通企业微信「微信客服」,拿到 kfid 链接。
|
||||||
|
2. 把 C 端小程序关联到该企业的微信客服。
|
||||||
|
3. 把 CorpID 填进系统设置。未填则用户仍走小程序原生客服。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.1 Prisma
|
||||||
|
|
||||||
|
`common_fulfillment_provider.delivery_hint_html` TEXT NULL。脚本:[`migrate-fulfillment-delivery-hint-v3510.sql`](../server/dukang-api/prisma/migrate-fulfillment-delivery-hint-v3510.sql)。
|
||||||
|
|
||||||
|
## 5. 验收清单
|
||||||
|
|
||||||
|
- [ ] 门店详情顶栏没有分享按钮;返回/导航/拨打不受影响
|
||||||
|
- [ ] HQ 承运商可编辑「配送信息提示」HTML(颜色/粗细/字号);非法标签被去掉
|
||||||
|
- [ ] `GET /catalog/local-deliveries` 按开城列出仓库+承运商+hintHtml;`cityName` 可筛收货市
|
||||||
|
- [ ] 商品详情(可线上购)按当前选城展示承运商提示(空则回退「同城配送,预计24小时内送到」)
|
||||||
|
- [ ] 确认订单:收货市已开城显示 HTML 提示;未开城只显示「总部物流、运费到付」
|
||||||
|
- [ ] 同城订单详情「配送时效」为该市承运商提示
|
||||||
|
- [ ] 系统设置可改客服链接与 CorpID;保存后 `client-config` 立即带出
|
||||||
|
- [ ] 小程序已填 CorpID:联系客服进入企微微信客服(非小程序原生客服后台)
|
||||||
|
- [ ] 未填 CorpID:小程序仍能打开原生客服,不白屏
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键路径
|
||||||
|
|
||||||
|
| 域 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| 门店详情 | `apps/mini-user/src/pages/store-detail/index.tsx` |
|
||||||
|
| 同城提示 | `fulfillment-provider.service.ts` · `GET /catalog/local-deliveries` · `DeliveryHintHtml` |
|
||||||
|
| 客服按钮 | `apps/mini-user/src/components/ContactCsButton.tsx` · `lib/wecom-cs.ts` |
|
||||||
|
| 下发 | `client-config.controller.ts` · `packages/shared-types` `config.ts` / `wechat.ts` |
|
||||||
|
| HQ 配置 | `system-config.registry.ts` |
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
1. **门店列表**增加「累计核销好客权益」:该店历史核销券面额合计。
|
1. **门店列表**增加「累计核销好客权益」:该店历史核销券面额合计。
|
||||||
2. **全 HQ 表格**去掉 `...` 截断,长文本完整展示,横向滚动。
|
2. **全 HQ 表格**去掉 `...` 截断,长文本完整展示,横向滚动。
|
||||||
3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序,偏好存当前 HQ 账号。
|
3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序;可拖表头分割线改列宽。偏好(含列宽)存当前 HQ 账号。主展示列(名称 / 单号 / 昵称等)带下划线,点击进入编辑或详情。
|
||||||
4. **用户列表**:昵称只读(用户自己的);新增「备注」列,双击编辑 HQ 标记,离开即写入 `user_user.hq_remark`;手机号列表明文,不脱敏。
|
4. **用户列表**:昵称只读(用户自己的);新增「备注」列,双击编辑 HQ 标记,离开即写入 `user_user.hq_remark`;手机号列表明文,不脱敏。
|
||||||
|
|
||||||
**不做**:C 端 / 合伙人 / 门店端;详情 Drawer 内嵌表不加列设置。
|
**不做**:C 端 / 合伙人 / 门店端;详情 Drawer 内嵌表不加列设置。
|
||||||
@@ -38,10 +38,11 @@
|
|||||||
| 序号 | `(page-1)*pageSize+index+1`,锁定最左,不可隐藏 |
|
| 序号 | `(page-1)*pageSize+index+1`,锁定最左,不可隐藏 |
|
||||||
| 操作列 | 锁定最右,不可隐藏 |
|
| 操作列 | 锁定最右,不可隐藏 |
|
||||||
| 弹窗 | 勾选显示、拖拽/上下移排序、重置默认 |
|
| 弹窗 | 勾选显示、拖拽/上下移排序、重置默认 |
|
||||||
|
| 主列 | 名称/单号/昵称等下划线,点击同「编辑」或「详情」 |
|
||||||
| 存储 | `hq_account.list_column_prefs` JSON,按 `listKey` |
|
| 存储 | `hq_account.list_column_prefs` JSON,按 `listKey` |
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{ stores: { order: ["name", "cityName"], hidden: ["intro"] } }
|
{ stores: { order: ["name", "cityName"], hidden: ["intro"], widths: { name: 180 } } }
|
||||||
```
|
```
|
||||||
|
|
||||||
新列按默认位置插入且默认显示;未知 key 忽略。重置即删除该 `listKey`。
|
新列按默认位置插入且默认显示;未知 key 忽略。重置即删除该 `listKey`。
|
||||||
@@ -113,7 +114,24 @@
|
|||||||
- [ ] HQ 主表长字段不再 `...`,可横向滑完
|
- [ ] HQ 主表长字段不再 `...`,可横向滑完
|
||||||
- [ ] 主列表最左序号跨页连续
|
- [ ] 主列表最左序号跨页连续
|
||||||
- [ ] 列设置可隐藏/排序;保存后刷新仍在;重置恢复默认
|
- [ ] 列设置可隐藏/排序;保存后刷新仍在;重置恢复默认
|
||||||
|
- [ ] 主展示列带下划线,点击进入对应编辑或详情
|
||||||
- [ ] 序号、操作列不能在弹窗关掉或拖走
|
- [ ] 序号、操作列不能在弹窗关掉或拖走
|
||||||
- [ ] 详情描述列表仍可省略
|
- [ ] 详情描述列表仍可省略
|
||||||
- [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `user_user.hq_remark` 已更新;C 端看不到该字段
|
- [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `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 数组均可)。列表筛选为多选;空 = 全部。单值旧链接仍可用。
|
||||||
|
|||||||
+3
-1
@@ -43,7 +43,9 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。
|
**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`);列表手机号不脱敏。
|
||||||
|
|
||||||
|
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
|
||||||
|
|
||||||
## 5. 验收用例(必过)
|
## 5. 验收用例(必过)
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -22,10 +22,11 @@
|
|||||||
|
|
||||||
- 四 Tab:首页/权益/门店/我的;微信登录+7天会话
|
- 四 Tab:首页/权益/门店/我的;微信登录+7天会话
|
||||||
- 下单:选城→商品→地址→起购校验→微信支付→权益1:1
|
- 下单:选城→商品→地址→起购校验→微信支付→权益1:1
|
||||||
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟
|
- 权益:直接核销(≤总余额) / 单据核销(≤单据);出码3分钟。二维码内容为门店 H5 URL(`{SHOP_H5_URL}/redeem?token=`),微信扫一扫直达核销确认页
|
||||||
- 门店:仅 OPEN;详情含套餐/电话(脱敏可拨打)/两段营业时间
|
- 门店:仅 OPEN;详情含套餐/电话(脱敏可拨打)/两段营业时间
|
||||||
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
|
- 订单 Tab:待付款/已付款/已完成;物流详情(签收照/拨号/ETA)
|
||||||
- 售后:客服入口;发票/四类型工单按 PRD Wave 进度
|
- 售后:客服入口(小程序优先企微微信客服,未配 CorpID 回退原生客服);发票/四类型工单按 PRD Wave 进度
|
||||||
|
- 同城送:收货市已开城则展示承运商「配送信息提示」(HTML);未开城走跨城到付;门店详情无顶栏分享按钮
|
||||||
- 版本:`minClientVersion` 过低强制更新或退出
|
- 版本:`minClientVersion` 过低强制更新或退出
|
||||||
- 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」)
|
- 「我的」头像昵称:`chooseAvatar` + `input type=nickname`(见下「踩坑」)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
|
|
||||||
## 3. 门店端(h5-shop)
|
## 3. 门店端(h5-shop)
|
||||||
|
|
||||||
- 登录绑定门店;首页扫码核销(微信 JSSDK)
|
- 登录绑定门店;首页扫码核销(微信 JSSDK);也可微信扫一扫用户核销码直达确认页(仍需点「确认核销」)
|
||||||
- 核销记录;今日汇总;到账金额×60%展示
|
- 核销记录;今日汇总;到账金额×60%展示
|
||||||
- 营业状态开关;Mine 门店信息
|
- 营业状态开关;Mine 门店信息
|
||||||
- 套餐:列表编辑→提交 HQ 审核(v3.4.10)
|
- 套餐:列表编辑→提交 HQ 审核(v3.4.10)
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
| 规则 | 说明 |
|
| 规则 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| iOS 登录/选店后 | 用 `location.replace(path)`(`hardNavigateInWechat`),禁止仅 React Router navigate |
|
| iOS 登录/选店后 | 用 `location.replace(path)`(`hardNavigateInWechat`),禁止仅 React Router navigate |
|
||||||
|
| 微信扫一扫落地核销页 | 成功后须 `hardNavigateInWechat('/')` 回首页,否则入场 URL 仍是 `/redeem?token=`,下次首页扫码验签失败 |
|
||||||
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL,**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
|
| iOS 签名 URL | `getJssdkSignUrl()` = 入场 URL,**保留** OAuth `code/state`;后端 `jssdk-config` 勿剔除 |
|
||||||
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
|
| 已绑定微信 | 短信登录后**不要**再强制 OAuth(避免反复重置入场 URL) |
|
||||||
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
|
| 扫码仍失败 | 弹窗引导「刷新页面」/「重新授权微信」,勿只提示再点一次 |
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
| 交易 | 订单、权益券、核销记录、推广码+metrics |
|
| 交易 | 订单、权益券、核销记录、推广码+metrics |
|
||||||
| 财务 | 门店/合伙人/酒厂/物流账单;打款确认 |
|
| 财务 | 门店/合伙人/酒厂/物流账单;打款确认 |
|
||||||
| 工单 | 售后四类型 + 技术支持(ST) + 开发计划 |
|
| 工单 | 售后四类型 + 技术支持(ST) + 开发计划 |
|
||||||
| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」按账号保存 |
|
| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」与列宽按账号保存 |
|
||||||
| 日志 | HQ/用户/门店/合伙人/企微 |
|
| 日志 | HQ/用户/门店/合伙人/企微 |
|
||||||
|
|
||||||
## 6. 商品与模板
|
## 6. 商品与模板
|
||||||
@@ -150,6 +152,7 @@ HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑
|
|||||||
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
| 智能机器人 | `/wecom/bots` 长连接指令 |
|
||||||
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
| 消息推送 | `/wecom/pushes` Webhook+eventKey |
|
||||||
| 日志 | `/logs/wecom-bots` |
|
| 日志 | `/logs/wecom-bots` |
|
||||||
|
| C 端微信客服 | 系统设置 `CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`;小程序须已关联该企业微信客服 |
|
||||||
|
|
||||||
eventKey:`alert.ops` · `support_ticket.created` · `dev_plan.task_dispatch` · 支付/核销/结算告警。
|
eventKey:`alert.ops` · `support_ticket.created` · `dev_plan.task_dispatch` · 支付/核销/结算告警。
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ export interface AppConfig {
|
|||||||
tencentLbsSecretKey: string;
|
tencentLbsSecretKey: string;
|
||||||
/** C 端 H5 落地页(推广码二维码链接前缀) */
|
/** C 端 H5 落地页(推广码二维码链接前缀) */
|
||||||
userH5Url: string;
|
userH5Url: string;
|
||||||
|
/** 门店 H5 落地页(用户核销码二维码链接前缀) */
|
||||||
|
shopH5Url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
|
/** 推广码 / C 端 H5 默认落地页(系统设置 USER_H5_URL 未配时回退;HQ 可改) */
|
||||||
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
|
||||||
|
|
||||||
|
/** 门店 H5 默认落地页(系统设置 SHOP_H5_URL 未配时回退;HQ 可改) */
|
||||||
|
export const DEFAULT_SHOP_H5_URL = 'https://shop.dukanghaoke.com';
|
||||||
|
|
||||||
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
|
/** 品牌 Logo OSS 根路径(默认;系统设置 BRAND_LOGO_OSS_BASE 可覆盖) */
|
||||||
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
|
||||||
|
|
||||||
@@ -58,12 +63,15 @@ export const QUALIFICATION_DISCLOSURE_URL = `${MINI_USER_STATIC_OSS_BASE}qualifi
|
|||||||
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
export const CUSTOMER_SERVICE_PHONE = '13203801799';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
|
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页 / 小程序 openCustomerServiceChat)
|
||||||
* 可在 h5-user 用 VITE_CS_WECOM_URL 覆盖
|
* 可在系统设置 CUSTOMER_SERVICE_WECOM_URL 覆盖
|
||||||
*/
|
*/
|
||||||
export const CUSTOMER_SERVICE_WECOM_URL =
|
export const CUSTOMER_SERVICE_WECOM_URL =
|
||||||
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
||||||
|
|
||||||
|
/** 企业微信 CorpID(小程序 wx.openCustomerServiceChat 必填;系统设置 WECOM_CORP_ID) */
|
||||||
|
export const WECOM_CORP_ID = '';
|
||||||
|
|
||||||
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
|
/** 从 env / 系统设置解析的 C 端品牌与客服展示配置(缺省回退常量) */
|
||||||
export type ClientBrandRuntime = {
|
export type ClientBrandRuntime = {
|
||||||
userH5Url: string;
|
userH5Url: string;
|
||||||
@@ -74,6 +82,8 @@ export type ClientBrandRuntime = {
|
|||||||
miniUserStaticOssBase: string;
|
miniUserStaticOssBase: string;
|
||||||
qualificationDisclosureUrl: string;
|
qualificationDisclosureUrl: string;
|
||||||
customerServicePhone: string;
|
customerServicePhone: string;
|
||||||
|
customerServiceWecomUrl: string;
|
||||||
|
wecomCorpId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveClientBrandRuntime(
|
export function resolveClientBrandRuntime(
|
||||||
@@ -95,6 +105,8 @@ export function resolveClientBrandRuntime(
|
|||||||
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
|
(e.QUALIFICATION_DISCLOSURE_URL || '').trim() ||
|
||||||
`${staticBase}qualification-disclosure.png`,
|
`${staticBase}qualification-disclosure.png`,
|
||||||
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
|
customerServicePhone: (e.CUSTOMER_SERVICE_PHONE || CUSTOMER_SERVICE_PHONE).trim(),
|
||||||
|
customerServiceWecomUrl: (e.CUSTOMER_SERVICE_WECOM_URL || CUSTOMER_SERVICE_WECOM_URL).trim(),
|
||||||
|
wecomCorpId: (e.WECOM_CORP_ID || WECOM_CORP_ID).trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +277,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
|
|||||||
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
tencentLbsKey: e.TENCENT_LBS_KEY ?? '',
|
||||||
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
|
tencentLbsSecretKey: e.TENCENT_LBS_SECRET_KEY ?? '',
|
||||||
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
userH5Url: (e.USER_H5_URL || DEFAULT_USER_H5_URL).replace(/\/$/, ''),
|
||||||
|
shopH5Url: (e.SHOP_H5_URL || DEFAULT_SHOP_H5_URL).replace(/\/$/, ''),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface FulfillmentProviderDto {
|
|||||||
settlementMethod: LogisticsSettlementMethod;
|
settlementMethod: LogisticsSettlementMethod;
|
||||||
pricingRules?: LogisticsPricingRuleDto | null;
|
pricingRules?: LogisticsPricingRuleDto | null;
|
||||||
prepaidBalance: number;
|
prepaidBalance: number;
|
||||||
|
/** C 端同城配送提示(已消毒 HTML) */
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,7 @@ export interface CreateFulfillmentProviderInput {
|
|||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
settlementMethod?: LogisticsSettlementMethod;
|
settlementMethod?: LogisticsSettlementMethod;
|
||||||
pricingRules?: LogisticsPricingRuleDto | null;
|
pricingRules?: LogisticsPricingRuleDto | null;
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFulfillmentProviderInput {
|
export interface UpdateFulfillmentProviderInput {
|
||||||
@@ -89,6 +92,7 @@ export interface UpdateFulfillmentProviderInput {
|
|||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
settlementMethod?: LogisticsSettlementMethod;
|
settlementMethod?: LogisticsSettlementMethod;
|
||||||
pricingRules?: LogisticsPricingRuleDto | null;
|
pricingRules?: LogisticsPricingRuleDto | null;
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DEFAULT_XFX_LOGISTICS_PRICING };
|
export { DEFAULT_XFX_LOGISTICS_PRICING };
|
||||||
@@ -113,3 +117,83 @@ export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const;
|
|||||||
export function isXfxProviderCode(code: string): boolean {
|
export function isXfxProviderCode(code: string): boolean {
|
||||||
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
|
return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 承运商未配置提示时 C 端回退文案 */
|
||||||
|
export const DEFAULT_LOCAL_DELIVERY_HINT = '同城配送,预计24小时内送到';
|
||||||
|
|
||||||
|
export const LOCAL_DELIVERY_HINT_MAX_LEN = 2000;
|
||||||
|
|
||||||
|
const HINT_ALLOWED_TAGS = new Set(['span', 'p', 'br', 'b', 'strong', 'i', 'em', 'font']);
|
||||||
|
const HINT_ALLOWED_STYLES = new Set(['color', 'font-weight', 'font-size', 'font-style']);
|
||||||
|
|
||||||
|
export type LocalDeliveryDto = {
|
||||||
|
city: { id: string; code: string; name: string };
|
||||||
|
warehouse: { id: string; name: string; fulfillmentMode: string } | null;
|
||||||
|
provider: { id: string; code: string; name: string } | null;
|
||||||
|
hintHtml: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeHintStyle(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.split(';')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => {
|
||||||
|
const idx = part.indexOf(':');
|
||||||
|
if (idx <= 0) return '';
|
||||||
|
const key = part.slice(0, idx).trim().toLowerCase();
|
||||||
|
const value = part.slice(idx + 1).trim();
|
||||||
|
if (!HINT_ALLOWED_STYLES.has(key)) return '';
|
||||||
|
if (/url\s*\(|expression\s*\(|javascript\s*:/i.test(value)) return '';
|
||||||
|
return `${key}:${value}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本换行转成 br,供 C 端 RichText 使用(不改 HQ 存盘原文) */
|
||||||
|
export function deliveryHintHtmlToRichNodes(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/\r\n|\r|\n/g, '<br/>')
|
||||||
|
.replace(/(?:<br\s*\/?>){3,}/gi, '<br/><br/>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 承运商配送提示 HTML:去掉脚本/事件,只保留字号颜色粗细 */
|
||||||
|
export function sanitizeDeliveryHintHtml(raw?: string | null): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
let html = String(raw).trim();
|
||||||
|
if (!html) return null;
|
||||||
|
if (html.length > LOCAL_DELIVERY_HINT_MAX_LEN) {
|
||||||
|
html = html.slice(0, LOCAL_DELIVERY_HINT_MAX_LEN);
|
||||||
|
}
|
||||||
|
html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
|
||||||
|
html = html.replace(/on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||||||
|
html = html.replace(/javascript\s*:/gi, '');
|
||||||
|
html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (_full, tag: string, attrs: string) => {
|
||||||
|
const name = String(tag).toLowerCase();
|
||||||
|
const closing = String(_full).startsWith('</');
|
||||||
|
if (!HINT_ALLOWED_TAGS.has(name)) return '';
|
||||||
|
if (name === 'br') return closing ? '' : '<br/>';
|
||||||
|
if (closing) return `</${name}>`;
|
||||||
|
let style = '';
|
||||||
|
const styleMatch = String(attrs).match(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/i);
|
||||||
|
if (styleMatch) {
|
||||||
|
style = sanitizeHintStyle(styleMatch[2] ?? styleMatch[3] ?? '');
|
||||||
|
}
|
||||||
|
let color = '';
|
||||||
|
let size = '';
|
||||||
|
if (name === 'font') {
|
||||||
|
const colorMatch = String(attrs).match(/\scolor\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
|
if (colorMatch) color = (colorMatch[2] ?? colorMatch[3] ?? colorMatch[4] ?? '').trim();
|
||||||
|
const sizeMatch = String(attrs).match(/\ssize\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
||||||
|
if (sizeMatch) size = (sizeMatch[2] ?? sizeMatch[3] ?? sizeMatch[4] ?? '').trim();
|
||||||
|
}
|
||||||
|
const extra: string[] = [];
|
||||||
|
if (style) extra.push(`style="${style}"`);
|
||||||
|
if (color) extra.push(`color="${color.replace(/"/g, '')}"`);
|
||||||
|
if (size) extra.push(`size="${size.replace(/"/g, '')}"`);
|
||||||
|
return extra.length ? `<${name} ${extra.join(' ')}>` : `<${name}>`;
|
||||||
|
});
|
||||||
|
const cleaned = html.replace(/ /g, ' ').trim();
|
||||||
|
return cleaned || null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export function isHqListColumnKey(value: string): value is HqListColumnKey {
|
|||||||
export type HqListColumnPref = {
|
export type HqListColumnPref = {
|
||||||
order: string[];
|
order: string[];
|
||||||
hidden: string[];
|
hidden: string[];
|
||||||
|
/** 列宽(px);未出现的 key 用列默认 width */
|
||||||
|
widths?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HqListColumnPrefsMap = Partial<Record<HqListColumnKey, HqListColumnPref>>;
|
export type HqListColumnPrefsMap = Partial<Record<HqListColumnKey, HqListColumnPref>>;
|
||||||
@@ -68,4 +70,5 @@ export type SaveHqListColumnPrefsRequest = {
|
|||||||
reset?: boolean;
|
reset?: boolean;
|
||||||
order?: string[];
|
order?: string[];
|
||||||
hidden?: string[];
|
hidden?: string[];
|
||||||
|
widths?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ export interface AdminListQuery {
|
|||||||
pageSize?: number;
|
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 {
|
export interface UpdateAdminUserRequest {
|
||||||
hqRemark: string;
|
hqRemark: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ export type ClientRuntimeConfig = {
|
|||||||
qualificationDisclosureUrl?: string;
|
qualificationDisclosureUrl?: string;
|
||||||
/** 总部客服电话 */
|
/** 总部客服电话 */
|
||||||
customerServicePhone?: string;
|
customerServicePhone?: string;
|
||||||
|
/** 企业微信「微信客服」链接(kfid) */
|
||||||
|
customerServiceWecomUrl?: string;
|
||||||
|
/** 企业微信 CorpID(小程序调起企微客服) */
|
||||||
|
wecomCorpId?: string;
|
||||||
/** 合伙人入驻:企微客服二维码图片 URL */
|
/** 合伙人入驻:企微客服二维码图片 URL */
|
||||||
partnerOnboardCsQrUrl?: string | null;
|
partnerOnboardCsQrUrl?: string | null;
|
||||||
/** 合伙人入驻:企微客服提示文案 */
|
/** 合伙人入驻:企微客服提示文案 */
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- v3.5.10:承运商「配送信息提示」(C 端同城送 HTML)
|
||||||
|
ALTER TABLE `common_fulfillment_provider`
|
||||||
|
ADD COLUMN `delivery_hint_html` TEXT NULL AFTER `prepaid_balance`;
|
||||||
@@ -1057,6 +1057,8 @@ model FulfillmentProvider {
|
|||||||
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
|
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
|
||||||
pricingRulesJson String? @map("pricing_rules_json") @db.Text
|
pricingRulesJson String? @map("pricing_rules_json") @db.Text
|
||||||
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
|
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
|
||||||
|
/// C 端同城配送提示(HTML,消毒后下发)
|
||||||
|
deliveryHintHtml String? @map("delivery_hint_html") @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const HqOperationAction = {
|
|||||||
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
|
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
|
||||||
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
|
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
|
||||||
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
|
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
|
||||||
|
LOGISTICS_PROVIDER_DELETE: 'LOGISTICS_PROVIDER_DELETE',
|
||||||
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
|
||||||
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
|
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
|
||||||
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
|
||||||
@@ -202,6 +203,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
|
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
|
||||||
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
|
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
|
||||||
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
|
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
|
||||||
|
[HqOperationAction.LOGISTICS_PROVIDER_DELETE]: '删除物流承运商',
|
||||||
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
|
||||||
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
|
||||||
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
BRAND_LOGO_URL,
|
BRAND_LOGO_URL,
|
||||||
BRAND_LOGO_WIDE_URL,
|
BRAND_LOGO_WIDE_URL,
|
||||||
CUSTOMER_SERVICE_PHONE,
|
CUSTOMER_SERVICE_PHONE,
|
||||||
|
CUSTOMER_SERVICE_WECOM_URL,
|
||||||
DEFAULT_SHARE_BENEFIT_TITLE,
|
DEFAULT_SHARE_BENEFIT_TITLE,
|
||||||
DEFAULT_SHARE_DESC,
|
DEFAULT_SHARE_DESC,
|
||||||
DEFAULT_SHARE_HINT,
|
DEFAULT_SHARE_HINT,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
DEFAULT_SHARE_ORDER_TITLE,
|
DEFAULT_SHARE_ORDER_TITLE,
|
||||||
DEFAULT_SHARE_STORES_TITLE,
|
DEFAULT_SHARE_STORES_TITLE,
|
||||||
DEFAULT_SHARE_TITLE,
|
DEFAULT_SHARE_TITLE,
|
||||||
|
DEFAULT_SHOP_H5_URL,
|
||||||
DEFAULT_USER_H5_URL,
|
DEFAULT_USER_H5_URL,
|
||||||
MINI_USER_STATIC_OSS_BASE,
|
MINI_USER_STATIC_OSS_BASE,
|
||||||
MOCK_SMS_FIXED_CODE,
|
MOCK_SMS_FIXED_CODE,
|
||||||
@@ -154,6 +156,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
placeholder: 'https://user.example.com/user',
|
placeholder: 'https://user.example.com/user',
|
||||||
description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)',
|
description: '推广码二维码 / 未配置时的默认落地页前缀(无末尾斜杠)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'SHOP_H5_URL',
|
||||||
|
label: '门店 H5 落地页',
|
||||||
|
group: G.wechat_mini,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
placeholder: 'https://shop.dukanghaoke.com',
|
||||||
|
description: '用户核销码二维码链接前缀;扫码直达门店核销确认页(无末尾斜杠)',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'BRAND_LOGO_OSS_BASE',
|
key: 'BRAND_LOGO_OSS_BASE',
|
||||||
label: '品牌 Logo OSS 根路径',
|
label: '品牌 Logo OSS 根路径',
|
||||||
@@ -213,6 +224,24 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
placeholder: '13203801799',
|
placeholder: '13203801799',
|
||||||
description: 'C 端联系客服拨号号码',
|
description: 'C 端联系客服拨号号码',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'CUSTOMER_SERVICE_WECOM_URL',
|
||||||
|
label: '企微微信客服链接',
|
||||||
|
group: G.wechat_mini,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
placeholder: 'https://work.weixin.qq.com/kfid/kfc…',
|
||||||
|
description: 'C 端在线客服 kfid 链接;小程序需同时配置下方企业 ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WECOM_CORP_ID',
|
||||||
|
label: '企微企业 ID(CorpID)',
|
||||||
|
group: G.wechat_mini,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
placeholder: 'wwxxxxxxxxxxxx',
|
||||||
|
description: '企业微信「我的企业」企业 ID。小程序须已关联该企业的微信客服;未填则回退小程序原生客服',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'PARTNER_ONBOARD_CS_QR_URL',
|
key: 'PARTNER_ONBOARD_CS_QR_URL',
|
||||||
label: '合伙人入驻 · 企微客服二维码',
|
label: '合伙人入驻 · 企微客服二维码',
|
||||||
@@ -584,6 +613,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
|
|||||||
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
||||||
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
||||||
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
||||||
|
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
|
||||||
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
||||||
BRAND_LOGO_URL: BRAND_LOGO_URL,
|
BRAND_LOGO_URL: BRAND_LOGO_URL,
|
||||||
BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL,
|
BRAND_LOGO_WIDE_URL: BRAND_LOGO_WIDE_URL,
|
||||||
@@ -591,6 +621,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
|||||||
MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE,
|
MINI_USER_STATIC_OSS_BASE: MINI_USER_STATIC_OSS_BASE,
|
||||||
QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL,
|
QUALIFICATION_DISCLOSURE_URL: QUALIFICATION_DISCLOSURE_URL,
|
||||||
CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE,
|
CUSTOMER_SERVICE_PHONE: CUSTOMER_SERVICE_PHONE,
|
||||||
|
CUSTOMER_SERVICE_WECOM_URL: CUSTOMER_SERVICE_WECOM_URL,
|
||||||
MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE,
|
MOCK_SMS_FIXED_CODE: MOCK_SMS_FIXED_CODE,
|
||||||
SHARE_HINT: DEFAULT_SHARE_HINT,
|
SHARE_HINT: DEFAULT_SHARE_HINT,
|
||||||
SHARE_DEFAULT_TITLE: DEFAULT_SHARE_TITLE,
|
SHARE_DEFAULT_TITLE: DEFAULT_SHARE_TITLE,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export class ClientConfigController {
|
|||||||
brandLogoMarkUrl: brand.brandLogoMarkUrl,
|
brandLogoMarkUrl: brand.brandLogoMarkUrl,
|
||||||
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
|
qualificationDisclosureUrl: brand.qualificationDisclosureUrl,
|
||||||
customerServicePhone: brand.customerServicePhone,
|
customerServicePhone: brand.customerServicePhone,
|
||||||
|
customerServiceWecomUrl: brand.customerServiceWecomUrl || null,
|
||||||
|
wecomCorpId: brand.wecomCorpId || null,
|
||||||
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
|
partnerOnboardCsQrUrl: (env.PARTNER_ONBOARD_CS_QR_URL ?? '').trim() || null,
|
||||||
partnerOnboardCsHint:
|
partnerOnboardCsHint:
|
||||||
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||||
isXfxProviderCode,
|
isXfxProviderCode,
|
||||||
|
sanitizeDeliveryHintHtml,
|
||||||
type LogisticsPricingRuleDto,
|
type LogisticsPricingRuleDto,
|
||||||
type XiaofeixiaProviderConfig,
|
type XiaofeixiaProviderConfig,
|
||||||
type XiaofeixiaProviderConfigPublic,
|
type XiaofeixiaProviderConfigPublic,
|
||||||
@@ -30,6 +31,7 @@ export type CreateFulfillmentProviderInput = {
|
|||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
settlementMethod?: LogisticsSettlementMethod | string;
|
settlementMethod?: LogisticsSettlementMethod | string;
|
||||||
pricingRules?: LogisticsPricingRuleDto | null;
|
pricingRules?: LogisticsPricingRuleDto | null;
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
||||||
@@ -149,6 +151,7 @@ export class FulfillmentProviderService {
|
|||||||
bankAccountNo: this.normOptional(input.bankAccountNo),
|
bankAccountNo: this.normOptional(input.bankAccountNo),
|
||||||
settlementMethod,
|
settlementMethod,
|
||||||
pricingRulesJson,
|
pricingRulesJson,
|
||||||
|
deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
@@ -195,11 +198,43 @@ export class FulfillmentProviderService {
|
|||||||
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
|
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
|
||||||
: {}),
|
: {}),
|
||||||
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
|
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
|
||||||
|
...(input.deliveryHintHtml !== undefined
|
||||||
|
? { deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml) }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.toDto(row);
|
return this.toDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async remove(id: bigint) {
|
||||||
|
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
||||||
|
if (!current) throw new NotFoundException('仓配承运商不存在');
|
||||||
|
|
||||||
|
const [billCount, ledgerCount] = await Promise.all([
|
||||||
|
this.prisma.logisticsBill.count({ where: { fulfillmentProviderId: id } }),
|
||||||
|
this.prisma.logisticsPrepaidLedger.count({ where: { fulfillmentProviderId: id } }),
|
||||||
|
]);
|
||||||
|
if (billCount > 0) {
|
||||||
|
throw new BadRequestException(`该承运商已有 ${billCount} 笔物流对账单,不能删除`);
|
||||||
|
}
|
||||||
|
if (ledgerCount > 0) {
|
||||||
|
throw new BadRequestException('该承运商已有充值/扣款流水,不能删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.cityWarehouse.updateMany({
|
||||||
|
where: { fulfillmentProviderId: id },
|
||||||
|
data: { fulfillmentProviderId: null, fulfillmentMode: 'MANUAL' },
|
||||||
|
});
|
||||||
|
await tx.orderDelivery.updateMany({
|
||||||
|
where: { fulfillmentProviderId: id },
|
||||||
|
data: { fulfillmentProviderId: null },
|
||||||
|
});
|
||||||
|
await tx.fulfillmentProvider.delete({ where: { id } });
|
||||||
|
});
|
||||||
|
return { ok: true, id: id.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
/** 充值(结算模块可复用) */
|
/** 充值(结算模块可复用) */
|
||||||
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
|
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
|
||||||
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
|
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
|
||||||
@@ -413,6 +448,7 @@ export class FulfillmentProviderService {
|
|||||||
settlementMethod?: string;
|
settlementMethod?: string;
|
||||||
pricingRulesJson?: string | null;
|
pricingRulesJson?: string | null;
|
||||||
prepaidBalance?: Prisma.Decimal | number;
|
prepaidBalance?: Prisma.Decimal | number;
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}) {
|
}) {
|
||||||
@@ -434,8 +470,61 @@ export class FulfillmentProviderService {
|
|||||||
settlementMethod: row.settlementMethod ?? 'PREPAID',
|
settlementMethod: row.settlementMethod ?? 'PREPAID',
|
||||||
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
|
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
|
||||||
prepaidBalance: Number(row.prepaidBalance ?? 0),
|
prepaidBalance: Number(row.prepaidBalance ?? 0),
|
||||||
|
deliveryHintHtml: sanitizeDeliveryHintHtml(row.deliveryHintHtml ?? null),
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listLocalDeliveries(filter?: { cityCode?: string; cityName?: string }) {
|
||||||
|
const cityCode = filter?.cityCode?.trim() || '';
|
||||||
|
const cityName = filter?.cityName?.trim() || '';
|
||||||
|
const cities = await this.prisma.commonCity.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'ACTIVE',
|
||||||
|
...(cityCode ? { code: cityCode } : {}),
|
||||||
|
...(cityName && !cityCode
|
||||||
|
? { name: { in: this.cityNameAliases(cityName) } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
warehouses: {
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
include: { fulfillmentProvider: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return cities.map((city) => {
|
||||||
|
const preferred =
|
||||||
|
city.warehouses.find((w) => w.fulfillmentMode === 'API_AUTO' && w.fulfillmentProviderId) ??
|
||||||
|
city.warehouses.find((w) => w.fulfillmentProviderId) ??
|
||||||
|
null;
|
||||||
|
const provider = preferred?.fulfillmentProvider ?? null;
|
||||||
|
return {
|
||||||
|
city: { id: city.id.toString(), code: city.code, name: city.name },
|
||||||
|
warehouse: preferred
|
||||||
|
? {
|
||||||
|
id: preferred.id.toString(),
|
||||||
|
name: preferred.name,
|
||||||
|
fulfillmentMode: preferred.fulfillmentMode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
provider: provider
|
||||||
|
? { id: provider.id.toString(), code: provider.code, name: provider.name }
|
||||||
|
: null,
|
||||||
|
hintHtml: sanitizeDeliveryHintHtml(provider?.deliveryHintHtml ?? null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cityNameAliases(name: string): string[] {
|
||||||
|
const raw = name.trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
const noSuffix = raw.replace(/市$/, '');
|
||||||
|
const withSuffix = raw.endsWith('市') ? raw : `${raw}市`;
|
||||||
|
return Array.from(new Set([raw, noSuffix, withSuffix]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { IntegrationsModule } from '../../integrations/integrations.module';
|
|||||||
import { TradeModule } from '../trade/trade.module';
|
import { TradeModule } from '../trade/trade.module';
|
||||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||||
import { FulfillmentService } from './fulfillment.service';
|
import { FulfillmentService } from './fulfillment.service';
|
||||||
|
import { LocalDeliveriesController } from './local-deliveries.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
|
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
|
||||||
|
controllers: [LocalDeliveriesController],
|
||||||
providers: [FulfillmentProviderService, FulfillmentService],
|
providers: [FulfillmentProviderService, FulfillmentService],
|
||||||
exports: [FulfillmentProviderService, FulfillmentService],
|
exports: [FulfillmentProviderService, FulfillmentService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||||
|
|
||||||
|
@Controller('catalog')
|
||||||
|
export class LocalDeliveriesController {
|
||||||
|
constructor(private readonly providers: FulfillmentProviderService) {}
|
||||||
|
|
||||||
|
@Get('local-deliveries')
|
||||||
|
list(@Query('cityCode') cityCode?: string, @Query('cityName') cityName?: string) {
|
||||||
|
return this.providers.listLocalDeliveries({ cityCode, cityName });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,15 +55,29 @@ type UserRow = Pick<
|
|||||||
avatar?: { url: string } | null;
|
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 {
|
function parseListColumnPrefs(raw: unknown): HqListColumnPrefsMap {
|
||||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||||
const out: HqListColumnPrefsMap = {};
|
const out: HqListColumnPrefsMap = {};
|
||||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
if (!isHqListColumnKey(key) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
|
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] = {
|
out[key] = {
|
||||||
order: Array.isArray(row.order) ? row.order.filter((v): v is string => typeof v === 'string') : [],
|
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') : [],
|
hidden: Array.isArray(row.hidden) ? row.hidden.filter((v): v is string => typeof v === 'string') : [],
|
||||||
|
...(widths ? { widths } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -1238,7 +1252,7 @@ export class AuthService {
|
|||||||
actorType: string,
|
actorType: string,
|
||||||
actorId: bigint,
|
actorId: bigint,
|
||||||
listKey: string,
|
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 (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置');
|
||||||
if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表');
|
if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表');
|
||||||
@@ -1251,9 +1265,19 @@ export class AuthService {
|
|||||||
if (dto.reset) {
|
if (dto.reset) {
|
||||||
delete current[listKey];
|
delete current[listKey];
|
||||||
} else {
|
} else {
|
||||||
|
const prev = current[listKey];
|
||||||
|
const widths =
|
||||||
|
dto.widths !== undefined ? parseColumnWidths(dto.widths) : prev?.widths;
|
||||||
current[listKey] = {
|
current[listKey] = {
|
||||||
order: (dto.order ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
order:
|
||||||
hidden: (dto.hidden ?? []).filter((k) => typeof k === 'string' && k.trim()),
|
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({
|
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';
|
import { SmsScene } from '@dukang/shared-types';
|
||||||
|
|
||||||
export class SendSmsDto {
|
export class SendSmsDto {
|
||||||
@@ -144,4 +144,8 @@ export class SaveHqListColumnPrefsDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
hidden?: string[];
|
hidden?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
widths?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,20 @@ export class AdminBenefitService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
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 }),
|
this.prisma.benefitCoupon.count({ where }),
|
||||||
@@ -46,7 +59,20 @@ export class AdminBenefitService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
|
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('权益券不存在');
|
if (!coupon) throw new NotFoundException('权益券不存在');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
@@ -47,6 +47,7 @@ export class AdminFulfillmentProvidersController {
|
|||||||
bankAccountNo: dto.bankAccountNo,
|
bankAccountNo: dto.bankAccountNo,
|
||||||
settlementMethod: dto.settlementMethod,
|
settlementMethod: dto.settlementMethod,
|
||||||
pricingRules: dto.pricingRules,
|
pricingRules: dto.pricingRules,
|
||||||
|
deliveryHintHtml: dto.deliveryHintHtml,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export class AdminFulfillmentProvidersController {
|
|||||||
bankAccountNo: dto.bankAccountNo,
|
bankAccountNo: dto.bankAccountNo,
|
||||||
settlementMethod: dto.settlementMethod,
|
settlementMethod: dto.settlementMethod,
|
||||||
pricingRules: dto.pricingRules,
|
pricingRules: dto.pricingRules,
|
||||||
|
deliveryHintHtml: dto.deliveryHintHtml,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,4 +91,14 @@ export class AdminFulfillmentProvidersController {
|
|||||||
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
|
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
|
||||||
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
|
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.LOGISTICS_PROVIDER_DELETE,
|
||||||
|
refType: 'FULFILLMENT_PROVIDER',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.remove(BigInt(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ export class AdminOrdersService {
|
|||||||
const where: Prisma.OrderWhereInput = {};
|
const where: Prisma.OrderWhereInput = {};
|
||||||
|
|
||||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
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.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
||||||
if (query.userId) where.userId = BigInt(query.userId);
|
if (query.userId) where.userId = BigInt(query.userId);
|
||||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||||
|
|||||||
@@ -797,6 +797,11 @@ export class CreateFulfillmentProviderDto {
|
|||||||
boxBottles?: number;
|
boxBottles?: number;
|
||||||
boxFee?: number;
|
boxFee?: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateFulfillmentProviderDto {
|
export class UpdateFulfillmentProviderDto {
|
||||||
@@ -859,6 +864,11 @@ export class UpdateFulfillmentProviderDto {
|
|||||||
boxBottles?: number;
|
boxBottles?: number;
|
||||||
boxFee?: number;
|
boxFee?: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
deliveryHintHtml?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RechargeFulfillmentProviderDto {
|
export class RechargeFulfillmentProviderDto {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { OrderStatus } from '@dukang/shared-types';
|
||||||
import { Type, Transform } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
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 {
|
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||||
if (value === undefined || value === null || value === '') return undefined;
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
if (value === true || value === 'true' || value === '1' || value === 1) return true;
|
if (value === true || value === 'true' || value === '1' || value === 1) return true;
|
||||||
@@ -8,6 +11,19 @@ function toOptionalBoolean(value: unknown): boolean | undefined {
|
|||||||
return 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 {
|
export class PaginationQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@@ -57,9 +73,12 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
|
|
||||||
|
/** 单值或多项:`?status=PENDING_PAY` / `?status=A&status=B` / `?status=A,B` */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Transform(({ value }) => toOptionalStringList(value))
|
||||||
status?: string;
|
@IsArray()
|
||||||
|
@IsIn(ORDER_STATUS_VALUES, { each: true })
|
||||||
|
status?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['NORMAL', 'PROXY'])
|
@IsIn(['NORMAL', 'PROXY'])
|
||||||
@@ -117,8 +136,10 @@ export class AdminOrdersExportDto {
|
|||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Transform(({ value }) => toOptionalStringList(value))
|
||||||
status?: string;
|
@IsArray()
|
||||||
|
@IsIn(ORDER_STATUS_VALUES, { each: true })
|
||||||
|
status?: string[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['NORMAL', 'PROXY', 'RESHIPMENT'])
|
@IsIn(['NORMAL', 'PROXY', 'RESHIPMENT'])
|
||||||
|
|||||||
Reference in New Issue
Block a user