From f4da71e952a7dd88605ec146e986108f8d6c4200 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 25 Aug 2026 12:38:21 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.9=E7=89=88=E6=9C=AC=E8=BF=AD=E4=BB=A3?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/admin.css | 45 + .../src/components/AdminPrimaryLink.tsx | 25 + .../src/lib/ListColumnPrefsContext.tsx | 6 +- apps/admin-web/src/lib/api.ts | 1 + apps/admin-web/src/lib/column-resize.tsx | 67 + apps/admin-web/src/lib/constants.ts | 6 + apps/admin-web/src/lib/list-column-prefs.ts | 15 +- .../admin-web/src/lib/useAdminListColumns.tsx | 122 +- .../src/pages/BenefitCouponsPage.tsx | 132 +- apps/admin-web/src/pages/CitiesPage.tsx | 1358 ++++++++-------- apps/admin-web/src/pages/CityPartnersPage.tsx | 1446 +++++++++-------- .../src/pages/CityWarehousesPage.tsx | 10 +- apps/admin-web/src/pages/DeliveriesPage.tsx | 19 +- apps/admin-web/src/pages/DevPlanTasksPage.tsx | 10 +- .../src/pages/DevPlanVersionsPage.tsx | 5 +- apps/admin-web/src/pages/DomainEventsPage.tsx | 16 +- .../src/pages/FulfillmentProvidersPage.tsx | 9 +- apps/admin-web/src/pages/HqAccountsPage.tsx | 27 +- apps/admin-web/src/pages/HqLogsPage.tsx | 407 ++--- apps/admin-web/src/pages/InvoicesPage.tsx | 9 +- .../src/pages/KnowledgeBasesPage.tsx | 9 +- apps/admin-web/src/pages/LlmConfigsPage.tsx | 9 +- .../src/pages/LogisticsBillsPage.tsx | 10 +- apps/admin-web/src/pages/OrdersPage.tsx | 130 +- .../src/pages/PartnerAccountsPage.tsx | 17 +- apps/admin-web/src/pages/PartnerBillsPage.tsx | 1154 ++++++------- apps/admin-web/src/pages/PartnerLogsPage.tsx | 477 +++--- apps/admin-web/src/pages/PartnersPage.tsx | 815 +++++----- .../admin-web/src/pages/PendingRedeemPage.tsx | 538 +++--- .../src/pages/ProductDetailTemplatesPage.tsx | 19 +- apps/admin-web/src/pages/ProductsPage.tsx | 10 +- apps/admin-web/src/pages/PromoCodesPage.tsx | 10 +- .../admin-web/src/pages/RedeemRecordsPage.tsx | 3 +- .../admin-web/src/pages/StoreAccountsPage.tsx | 10 +- apps/admin-web/src/pages/StoreBillsPage.tsx | 1365 ++++++++-------- .../src/pages/StoreCategoriesPage.tsx | 3 +- apps/admin-web/src/pages/StoreLogsPage.tsx | 19 +- apps/admin-web/src/pages/StoreMediaPage.tsx | 18 +- .../src/pages/StorePackageAuditsPage.tsx | 21 +- .../src/pages/StoreWithdrawalsPage.tsx | 3 +- apps/admin-web/src/pages/StoresPage.tsx | 3 +- .../src/pages/SupportTicketsPage.tsx | 9 +- .../admin-web/src/pages/TestWhitelistPage.tsx | 18 +- .../src/pages/ThirdPartyLogsPage.tsx | 17 +- apps/admin-web/src/pages/TicketsPage.tsx | 17 +- apps/admin-web/src/pages/UserLogsPage.tsx | 17 +- apps/admin-web/src/pages/UsersPage.tsx | 5 +- .../src/pages/WechatBindingsPage.tsx | 627 +++---- apps/admin-web/src/pages/WecomBotLogsPage.tsx | 10 +- apps/admin-web/src/pages/WecomBotsPage.tsx | 10 +- .../src/pages/WecomMessagePushesPage.tsx | 3 +- apps/admin-web/src/pages/WineryBillsPage.tsx | 10 +- .../src/pages/promo/PromoCodeUsersPage.tsx | 12 +- docs/杜康好客-v3-PRD.md | 4 +- docs/杜康好客-v3-现状对照.md | 2 +- docs/杜康好客-v3.5.9-开发文档.md | 15 +- docs/杜康好客-v3编码手册.md | 2 +- docs/杜康好客-知识库.md | 2 +- packages/shared-types/src/hq-list-columns.ts | 3 + .../src/modules/iam/auth.service.ts | 32 +- .../src/modules/iam/dto/auth.dto.ts | 6 +- .../src/modules/ops/admin-benefit.service.ts | 30 +- 62 files changed, 5027 insertions(+), 4202 deletions(-) create mode 100644 apps/admin-web/src/components/AdminPrimaryLink.tsx create mode 100644 apps/admin-web/src/lib/column-resize.tsx diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index 171213b..b8bc382 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -46,6 +46,34 @@ body, text-overflow: clip; } +/* 拖表头分割线改列宽 */ +.admin-layout .ant-table-thead > tr > th.admin-th-resizable { + position: relative; +} + +.admin-col-resize-handle { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 10px; + cursor: col-resize; + z-index: 3; + user-select: none; + touch-action: none; +} + +.admin-col-resize-handle:hover, +.admin-col-resizing .admin-col-resize-handle { + background: rgba(22, 119, 255, 0.18); +} + +body.admin-col-resizing, +body.admin-col-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + /* 操作列保持可见 */ .admin-layout .ant-table-cell:has(.ant-btn), .ant-drawer .ant-table-cell:has(.ant-btn), @@ -81,6 +109,23 @@ body, font-size: 12px; } +.admin-primary-link { + display: inline; + margin: 0; + padding: 0; + border: 0; + background: none; + font: inherit; + color: inherit; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.admin-primary-link:hover { + color: #1677ff; +} + .admin-table-nowrap .ant-table-cell, .admin-table-nowrap .ant-table-cell-ellipsis { white-space: nowrap; diff --git a/apps/admin-web/src/components/AdminPrimaryLink.tsx b/apps/admin-web/src/components/AdminPrimaryLink.tsx new file mode 100644 index 0000000..3502d78 --- /dev/null +++ b/apps/admin-web/src/components/AdminPrimaryLink.tsx @@ -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 ( + + ); +} diff --git a/apps/admin-web/src/lib/ListColumnPrefsContext.tsx b/apps/admin-web/src/lib/ListColumnPrefsContext.tsx index 83dd744..c6eb3fc 100644 --- a/apps/admin-web/src/lib/ListColumnPrefsContext.tsx +++ b/apps/admin-web/src/lib/ListColumnPrefsContext.tsx @@ -35,7 +35,11 @@ export function ListColumnPrefsProvider({ `/admin/me/list-columns/${listKey}`, { method: 'PUT', - body: JSON.stringify(next ? { order: next.order, hidden: next.hidden } : { reset: true }), + body: JSON.stringify( + next + ? { order: next.order, hidden: next.hidden, widths: next.widths } + : { reset: true }, + ), }, ); setPrefs(res.listColumnPrefs ?? {}); diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index fd37d14..96c661d 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -202,6 +202,7 @@ export type AdminOrderRow = { productName?: string; productSpec?: string; quantity?: number; + saleUnit?: string; receiverName: string; receiverPhone: string; receiverProvince?: string; diff --git a/apps/admin-web/src/lib/column-resize.tsx b/apps/admin-web/src/lib/column-resize.tsx new file mode 100644 index 0000000..efb916c --- /dev/null +++ b/apps/admin-web/src/lib/column-resize.tsx @@ -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( + title: ColumnType['title'], + onMouseDown: (e: MouseEvent) => void, +): ColumnType['title'] { + const handle: ReactNode = ( + ev.stopPropagation()} + onMouseDown={onMouseDown} + /> + ); + if (typeof title === 'function') { + return (props) => ( + <> + {title(props)} + {handle} + + ); + } + return ( + <> + {title} + {handle} + + ); +} diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 53ccfe6..95fc60b 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record = { MANUAL: '手动', }; +export const DELIVERY_TYPE_LABELS: Record = { + LOCAL: '同城', + CROSS_CITY: '跨城', + ON_SITE_PICKUP: '现场提货', +}; + export const ORDER_STATUS_LABELS: Record = { PENDING_PAY: '待付款', PENDING_SHIP: '待发货', diff --git a/apps/admin-web/src/lib/list-column-prefs.ts b/apps/admin-web/src/lib/list-column-prefs.ts index 6283cfb..a983334 100644 --- a/apps/admin-web/src/lib/list-column-prefs.ts +++ b/apps/admin-web/src/lib/list-column-prefs.ts @@ -55,7 +55,20 @@ export function applyColumnPrefs( const visible = new Set(visibleColumnKeys(defaultKeys, pref)); const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k)); const byKey = new Map(configurable.map((c) => [c.key, c.col])); - return [...orderedKeys.map((k) => byKey.get(k)!).filter(Boolean), ...actions.map((c) => c.col)]; + return [ + ...orderedKeys + .map((k) => { + const col = byKey.get(k); + if (!col) return null; + const w = pref?.widths?.[k]; + return w != null ? { ...col, width: w } : col; + }) + .filter((c): c is ColumnType => !!c), + ...actions.map((c) => { + const w = pref?.widths?.[c.key]; + return w != null ? { ...c.col, width: w } : c.col; + }), + ]; } export type ListColumnSettingItem = { diff --git a/apps/admin-web/src/lib/useAdminListColumns.tsx b/apps/admin-web/src/lib/useAdminListColumns.tsx index 7f9ece4..46e3f1c 100644 --- a/apps/admin-web/src/lib/useAdminListColumns.tsx +++ b/apps/admin-web/src/lib/useAdminListColumns.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { Button, message } from 'antd'; import { SettingOutlined } from '@ant-design/icons'; import type { ColumnsType, ColumnType } from 'antd/es/table'; @@ -9,9 +9,11 @@ import { ACTIONS_COLUMN_KEY, SERIAL_COLUMN_KEY, applyColumnPrefs, + columnKey, settingItems, type ListColumnSettingItem, } from './list-column-prefs'; +import { beginColumnResize, withResizeTitle } from './column-resize'; type Options = { page?: number; @@ -27,7 +29,13 @@ export function useAdminListColumns( const { prefs, save } = useListColumnPrefs(); const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); + const [localWidths, setLocalWidths] = useState>({}); const pref = prefs[listKey]; + const prefRef = useRef(pref); + prefRef.current = pref; + const localWidthsRef = useRef(localWidths); + localWidthsRef.current = localWidths; + const itemsRef = useRef([]); const configured = useMemo( () => applyColumnPrefs(allColumns as ColumnType[], pref), @@ -38,6 +46,7 @@ export function useAdminListColumns( () => settingItems(allColumns as ColumnType[], pref), [allColumns, pref], ); + itemsRef.current = items; const serialCol: ColumnType = useMemo( () => ({ @@ -50,37 +59,98 @@ export function useAdminListColumns( [page, pageSize], ); - const columns = useMemo>( - () => [ + const persistPref = useCallback( + async (next: { order: string[]; hidden: string[]; widths?: Record } | null) => { + if (!next) { + setLocalWidths({}); + await save(listKey, null); + return; + } + await save(listKey, next); + }, + [listKey, save], + ); + + const persist = useCallback( + async (next: ListColumnSettingItem[] | null) => { + setSaving(true); + try { + if (!next) { + await persistPref(null); + } else { + const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current }; + await persistPref({ + order: next.map((i) => i.key), + hidden: next.filter((i) => !i.visible).map((i) => i.key), + ...(Object.keys(widths).length ? { widths } : {}), + }); + } + setOpen(false); + message.success(next ? '列设置已保存' : '已恢复默认列'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + }, + [persistPref], + ); + + const persistWidth = useCallback( + async (key: string, width: number) => { + const current = prefRef.current; + const prevW = current?.widths?.[key]; + if (prevW === width) return; + const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key); + const hidden = current?.hidden ?? []; + const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width }; + try { + await persistPref({ order, hidden, widths }); + } catch (e) { + message.error(e instanceof Error ? e.message : '列宽保存失败'); + } + }, + [persistPref], + ); + + const columns = useMemo>(() => { + const merged: ColumnType[] = [ serialCol, ...configured.map((col) => col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions' ? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY } : col, ), - ], - [configured, serialCol], - ); - - async function persist(next: ListColumnSettingItem[] | null) { - setSaving(true); - try { - if (!next) { - await save(listKey, null); - } else { - await save(listKey, { - order: next.map((i) => i.key), - hidden: next.filter((i) => !i.visible).map((i) => i.key), - }); - } - setOpen(false); - message.success(next ? '列设置已保存' : '已恢复默认列'); - } catch (e) { - message.error(e instanceof Error ? e.message : '保存失败'); - } finally { - setSaving(false); - } - } + ]; + const widths = { ...(pref?.widths ?? {}), ...localWidths }; + return merged.map((col, i) => { + const key = columnKey(col, i); + const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined); + const prevHeader = col.onHeaderCell; + return { + ...col, + key, + width, + title: withResizeTitle(col.title, (e) => { + const th = (e.currentTarget as HTMLElement).closest('th'); + const startWidth = width ?? th?.getBoundingClientRect().width ?? 120; + beginColumnResize( + e, + startWidth, + (next) => setLocalWidths((prev) => ({ ...prev, [key]: next })), + (next) => void persistWidth(key, next), + ); + }), + onHeaderCell: (column) => { + const extra = typeof prevHeader === 'function' ? prevHeader(column) : {}; + return { + ...extra, + className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '), + }; + }, + }; + }); + }, [configured, serialCol, pref?.widths, localWidths, persistWidth]); const settingsButton = ( - {canDeleteCities ? ( - - ) : null} - - ), - }, - ]; - - const warehouseColumns: ColumnsType = [ - { title: '仓库名', dataIndex: 'name' }, - { title: '地址', dataIndex: 'address' }, - { title: '联系人', dataIndex: 'contactName', width: 90 }, - { title: '电话', dataIndex: 'contactPhone', width: 120 }, - { - title: '管仓', - dataIndex: 'managerType', - width: 100, - render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v, - }, - { title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' }, - { title: '状态', dataIndex: 'status', width: 80, render: (s) => {s} }, - { - title: '操作', - width: 120, - render: (_, row) => ( - - - - - ), - }, - ]; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type WarehouseRow = { + id: string; + name: string; + address: string; + contactName: string; + contactPhone: string; + managerType: WarehouseManagerType; + partnerAccountId: string | null; + partnerCompanyName?: string | null; + status: string; +}; + +type Row = { + id: string; + code: string; + name: string; + province: string; + status: string; + storeCount: number; + orderCount: number; + partnerBindingCount?: number; + createdAt: string; +}; + +type PartnerOption = { id: string; companyName: string; cityId?: string | null }; + +type CityDeletePreview = { + city: { id: string; code: string; name: string; province: string; status: string }; + canDelete: boolean; + blockers: string[]; + warnings: string[]; + summary: { + primaryPartnerCount: number; + staffCount: number; + storeCount: number; + warehouseCount: number; + orderCount: number; + redeemCount: number; + partnerBillCount: number; + }; + partners: Array<{ + id: string; + phone: string; + name: string; + companyName?: string | null; + status: string; + staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>; + }>; + orphanStaff: Array<{ id: string; phone: string; name: string }>; + stores: Array<{ + id: string; + name: string; + phone: string; + status: string; + address: string; + partnerAccount?: { companyName?: string | null; phone?: string } | null; + }>; + warehouses: Array<{ id: string; name: string; status: string; address: string }>; +}; + +const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); + +export default function CitiesPage() { + const [form] = Form.useForm(); + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [warehouseForm] = Form.useForm(); + const [warehouseEditForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/cities', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.code) qs.set('code', filters.code); + if (filters.status) qs.set('status', filters.status); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [warehouses, setWarehouses] = useState([]); + const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [warehouseOpen, setWarehouseOpen] = useState(false); + const [warehouseEditOpen, setWarehouseEditOpen] = useState(false); + const [warehouseEditId, setWarehouseEditId] = useState(null); + const [partners, setPartners] = useState([]); + const [createRegionPreview, setCreateRegionPreview] = useState(null); + const createRegionCodes = Form.useWatch('regionCodes', createForm); + const [warehouseManagerType, setWarehouseManagerType] = useState(WarehouseManagerType.HQ); + const [editWarehouseManagerType, setEditWarehouseManagerType] = useState(WarehouseManagerType.HQ); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteSubmitting, setDeleteSubmitting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletePreview, setDeletePreview] = useState(null); + const [confirmName, setConfirmName] = useState(''); + const [profile, setProfile] = useState(null); + const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete'); + + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); + + const loadPartners = useCallback(async (cityId: string) => { + const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); + setPartners(res.items); + }, []); + + const loadWarehouses = useCallback(async (cityId: string) => { + const wh = await request(`/admin/cities/${cityId}/warehouses`); + setWarehouses(wh); + }, []); + + useEffect(() => { + if (!createRegionCodes?.length) { + setCreateRegionPreview(null); + return; + } + const parsed = parseProvinceCityCodes(createRegionCodes as string[]); + createForm.setFieldsValue({ + code: parsed?.cityCode, + name: parsed?.city, + province: parsed?.province, + }); + setCreateRegionPreview(parsed); + }, [createRegionCodes, createForm]); + + function openCreateModal() { + createForm.resetFields(); + createForm.setFieldsValue({ status: 'PENDING' }); + setCreateRegionPreview(null); + setCreateOpen(true); + } + + const openDetail = async (row: Row) => { + const d = await request>(`/admin/cities/${row.id}`); + setDetail(d); + editForm.setFieldsValue({ + ...d, + maxPartnerCommissionPercent: + d.maxPartnerCommissionRate != null + ? Number(d.maxPartnerCommissionRate) * 100 + : 5, + }); + await loadPartners(row.id); + await loadWarehouses(row.id); + setDrawerOpen(true); + }; + + const openDelete = async (row: Row) => { + setDeleteTarget(row); + setDeletePreview(null); + setConfirmName(''); + setDeleteOpen(true); + setDeleteLoading(true); + try { + const preview = await request(`/admin/cities/${row.id}/delete-preview`); + setDeletePreview(preview); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载删除预览失败'); + setDeleteOpen(false); + } finally { + setDeleteLoading(false); + } + }; + + const confirmDelete = async () => { + if (!deleteTarget || !deletePreview) return; + if (!deletePreview.canDelete) { + message.error(deletePreview.blockers.join(';') || '当前城市不可删除'); + return; + } + if (confirmName.trim() !== deletePreview.city.name) { + message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`); + return; + } + setDeleteSubmitting(true); + try { + await request(`/admin/cities/${deleteTarget.id}`, { + method: 'DELETE', + body: JSON.stringify({ confirmName: confirmName.trim() }), + }); + message.success(`已删除城市「${deletePreview.city.name}」`); + setDeleteOpen(false); + setDeleteTarget(null); + setDeletePreview(null); + setConfirmName(''); + if (detail?.id === deleteTarget.id) setDrawerOpen(false); + void reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + } finally { + setDeleteSubmitting(false); + } + }; + + const baseColumns: ColumnsType = [ + { title: '编码', dataIndex: 'code', width: 90 }, + { + title: '城市', + dataIndex: 'name', + width: 100, + render: (v, row) => ( + void openDetail(row)}>{v} + ), + }, + { title: '省份', dataIndex: 'province', width: 90 }, + { title: '状态', dataIndex: 'status', width: 90, render: (s) => {CITY_STATUS_LABELS[s] || s} }, + { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, + { + title: '门店', + dataIndex: 'storeCount', + width: 70, + render: (n: number, row) => ( + + {n ?? 0} + + ), + }, + { title: '订单', dataIndex: 'orderCount', width: 70 }, + { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 140, + render: (_, row) => ( + + + {canDeleteCities ? ( + + ) : null} + + ), + }, + ]; + + const warehouseColumns: ColumnsType = [ + { title: '仓库名', dataIndex: 'name' }, + { title: '地址', dataIndex: 'address' }, + { title: '联系人', dataIndex: 'contactName', width: 90 }, + { title: '电话', dataIndex: 'contactPhone', width: 120 }, + { + title: '管仓', + dataIndex: 'managerType', + width: 100, + render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v, + }, + { title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' }, + { title: '状态', dataIndex: 'status', width: 80, render: (s) => {s} }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + + + + ), + }, + ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} - + {settingsModal} + 城市 - {settingsButton} - - -
{ setFilters(v); setPage(1); }}> - - - - - - - - - - - - - - - - ({ value: p.id, label: p.companyName }))} /> - - )} - - - - - - - ({ value: p.id, label: p.companyName }))} /> - - )} - - setConfirmName(e.target.value)} - disabled={deleteSubmitting} - /> - - ) : ( - 存在阻断项,无法删除。请先处理订单等关联数据。 - )} - - )} - -
- ); -} + {settingsButton} + + + { setFilters(v); setPage(1); }}> + + + + + + + + + + + + + + + + ({ value: p.id, label: p.companyName }))} /> + + )} + + + + + + + ({ value: p.id, label: p.companyName }))} /> + + )} + + setConfirmName(e.target.value)} + disabled={deleteSubmitting} + /> + + ) : ( + 存在阻断项,无法删除。请先处理订单等关联数据。 + )} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/CityPartnersPage.tsx b/apps/admin-web/src/pages/CityPartnersPage.tsx index def7467..24b10b7 100644 --- a/apps/admin-web/src/pages/CityPartnersPage.tsx +++ b/apps/admin-web/src/pages/CityPartnersPage.tsx @@ -1,725 +1,733 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { - Alert, - Button, - Checkbox, - Descriptions, - Drawer, - Form, - Input, - InputNumber, - Modal, - Popconfirm, - Select, - Space, - Table, - Tabs, - Tag, - Typography, - message, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { - CITY_PARTNER_SCOPE_LABELS, - CITY_PARTNER_STATUS_LABELS, - CityPartnerScopeType, - CityPartnerStatus, - PARTNER_PERMISSION_KEYS, - PARTNER_PERMISSION_LABELS, - PARTNER_STAFF_ROLE_LABELS, - type PartnerPermissionKey, -} from '@dukang/shared-types'; -import { request, type Paginated } from '../lib/api'; -import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; -import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; -import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Alert, + Button, + Checkbox, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Space, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + CITY_PARTNER_SCOPE_LABELS, + CITY_PARTNER_STATUS_LABELS, + CityPartnerScopeType, + CityPartnerStatus, + PARTNER_PERMISSION_KEYS, + PARTNER_PERMISSION_LABELS, + PARTNER_STAFF_ROLE_LABELS, + type PartnerPermissionKey, +} from '@dukang/shared-types'; +import { request, type Paginated } from '../lib/api'; +import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; +import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; - - -type SubRow = PartnerSubAccountRow; - -type Row = { - id: string; - companyName: string; - phone: string; - name: string; - contactPhone?: string | null; - cityId?: string | null; - cityName?: string | null; - scopeType?: string; - districtCodes?: string[] | null; - orderCommissionRate?: number; - redeemCommissionRate?: number; - bindingStatus?: string; - managedWarehouseId?: string | null; - managedWarehouseName?: string | null; - maxPartnerCommissionRate?: number | null; - storeCount: number; - accountCount: number; - subAccounts?: SubRow[]; - createdAt: string; - isTest?: boolean; -}; - -type PartnerDetail = Row & { - address?: string; - districtCodes?: string[] | null; - bankAccountName?: string | null; - bankAccountNo?: string | null; - bankBranch?: string | null; - /** 详情接口仍返回 children */ - children?: SubRow[]; -}; - -function toTableRows(items: Row[]): Row[] { - return items.map((row) => { - const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] }; - return { ...rest, subAccounts: subAccounts ?? children ?? [] }; - }); -} - -type CityOption = { id: string; name: string; code: string }; - -const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); -const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label })); -const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ - value: k, - label: PARTNER_PERMISSION_LABELS[k], -})); -const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS) - .filter(([value]) => value !== 'PARTNER') - .map(([value, label]) => ({ value, label })); - -function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { - if (!values?.length) return []; - if (Array.isArray(values[0])) { - return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); - } - return values as string[]; -} - -function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null { - // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 - const max = Number(maxRate); - const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; - if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效'; - if (sum > max + 1e-9) { - return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`; - } - return null; -} - -function formatApiError(err: unknown): string | null { - if (err && typeof err === 'object' && 'errorFields' in err) return null; - if (!(err instanceof Error)) return '操作失败'; - return err.message.replace(/\b(\d{6})\b/g, (code) => { - const label = districtCodeLabel(code); - return label !== code ? `${label}(${code})` : code; - }); -} - -export default function CityPartnersPage() { - const [filterForm] = Form.useForm(); - const [editForm] = Form.useForm(); - const [createForm] = Form.useForm(); - const [subForm] = Form.useForm(); - const [subEditForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/partners', - () => { - const qs = new URLSearchParams(); - if (filters.companyName) qs.set('companyName', String(filters.companyName)); - if (filters.phone) qs.set('phone', String(filters.phone)); - if (filters.cityId) qs.set('cityId', String(filters.cityId)); - if (filters.excludeTest) qs.set('excludeTest', 'true'); - return qs; - }, - [filters], - ); - const [detail, setDetail] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); - const [subOpen, setSubOpen] = useState(false); - const [subEditOpen, setSubEditOpen] = useState(false); - const [subEditId, setSubEditId] = useState(null); - const [subEditParentId, setSubEditParentId] = useState(null); - const [cities, setCities] = useState([]); - const [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); - const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); - const [maxCommissionRate, setMaxCommissionRate] = useState(0.05); - const [createMaxRate, setCreateMaxRate] = useState(0.05); - const [createCityCode, setCreateCityCode] = useState(); - - const loadCities = useCallback(async () => { - const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); - setCities(res.items); - }, []); - - useEffect(() => { - void loadCities(); - }, [loadCities]); - - const editCityCode = detail?.cityId - ? cities.find((c) => c.id === detail.cityId)?.code - : undefined; - - async function openPartner(id: string) { - const d = await request(`/admin/partners/${id}`); - setDetail(d); - setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); - setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05)); - editForm.setFieldsValue({ - name: d.name, - phone: d.phone, - companyName: d.companyName, - contactPhone: d.contactPhone ?? d.phone, - address: d.address ?? '', - scopeType: d.scopeType, - districtCodes: d.districtCodes ?? [], - orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, - redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, - bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE, - bankAccountName: d.bankAccountName ?? '', - bankAccountNo: d.bankAccountNo ?? '', - bankBranch: d.bankBranch ?? '', - }); - setDrawerOpen(true); - return d; - } - - async function savePartner() { - if (!detail) return; - try { - const v = await editForm.validateFields(); - const err = commissionSumError( - Number(v.orderCommissionRate ?? 0), - Number(v.redeemCommissionRate ?? 0), - maxCommissionRate, - ); - if (err) { - message.error(err); - return; - } - await request(`/admin/partners/${detail.id}`, { - method: 'PUT', - body: JSON.stringify({ - ...v, - orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, - redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, - districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, - }), - }); - message.success('已保存'); - setDrawerOpen(false); - void reload(); - } catch (e) { - const msg = formatApiError(e); - if (msg) message.error(msg); - } - } - - async function refreshPartnerContext(parentId: string) { - if (detail?.id === parentId) { - await openPartner(parentId); - } - void reload(); - } - - async function deleteSubAccount(subId: string, parentId: string) { - await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' }); - message.success('子账号已删除'); - await refreshPartnerContext(parentId); - } - - function openSubEdit(row: SubRow, parentId: string) { - setSubEditId(row.id); - setSubEditParentId(parentId); - subEditForm.setFieldsValue({ - name: row.name, - phone: row.phone, - staffRole: row.staffRole ?? 'INTERNAL', - permissions: row.permissions ?? [], - status: row.status, - }); - setSubEditOpen(true); - } - - async function openAddSubAccount(parentId: string) { - await openPartner(parentId); - subForm.resetFields(); - subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] }); - setSubOpen(true); - } - - async function onCreateCityChange(cityId: string) { - createForm.setFieldsValue({ districtCodes: undefined }); - const matched = cities.find((c) => c.id === cityId); - setCreateCityCode(matched?.code); - const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`); - setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); - } - - const baseColumns: ColumnsType = [ - { title: '城市', dataIndex: 'cityName', width: 90 }, - { - title: '区县', - dataIndex: 'districtCodes', - width: 160, - render: (codes: string[] | null | undefined, row) => - row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), - }, - { title: '公司名', dataIndex: 'companyName', width: 140 }, - { - title: '主账号姓名', - dataIndex: 'name', - width: 120, - render: (v, row) => ( - - {v} - {row.isTest ? 测试 : null} - - ), - }, - { title: '登录手机', dataIndex: 'phone', width: 120 }, - { - title: '管辖', - dataIndex: 'scopeType', - width: 90, - render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), - }, - { - title: '佣金', - width: 110, - render: (_, row) => - `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`, - }, - { - title: '绑定状态', - dataIndex: 'bindingStatus', - width: 80, - render: (v) => ( - - {CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'} - - ), - }, - { - title: '管仓仓库', - dataIndex: 'managedWarehouseName', - width: 110, - render: (v) => v || '—', - }, - { - title: '门店', - dataIndex: 'storeCount', - width: 60, - render: (n: number, row) => ( - - {n ?? 0} - - ), - }, - { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, - { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, - { - title: '操作', - width: 80, - fixed: 'right', - render: (_, row) => ( - - ), - }, - ]; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type SubRow = PartnerSubAccountRow; + +type Row = { + id: string; + companyName: string; + phone: string; + name: string; + contactPhone?: string | null; + cityId?: string | null; + cityName?: string | null; + scopeType?: string; + districtCodes?: string[] | null; + orderCommissionRate?: number; + redeemCommissionRate?: number; + bindingStatus?: string; + managedWarehouseId?: string | null; + managedWarehouseName?: string | null; + maxPartnerCommissionRate?: number | null; + storeCount: number; + accountCount: number; + subAccounts?: SubRow[]; + createdAt: string; + isTest?: boolean; +}; + +type PartnerDetail = Row & { + address?: string; + districtCodes?: string[] | null; + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + /** 详情接口仍返回 children */ + children?: SubRow[]; +}; + +function toTableRows(items: Row[]): Row[] { + return items.map((row) => { + const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] }; + return { ...rest, subAccounts: subAccounts ?? children ?? [] }; + }); +} + +type CityOption = { id: string; name: string; code: string }; + +const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); +const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label })); +const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ + value: k, + label: PARTNER_PERMISSION_LABELS[k], +})); +const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS) + .filter(([value]) => value !== 'PARTNER') + .map(([value, label]) => ({ value, label })); + +function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { + if (!values?.length) return []; + if (Array.isArray(values[0])) { + return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); + } + return values as string[]; +} + +function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null { + // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限 + const max = Number(maxRate); + const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100; + if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效'; + if (sum > max + 1e-9) { + return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`; + } + return null; +} + +function formatApiError(err: unknown): string | null { + if (err && typeof err === 'object' && 'errorFields' in err) return null; + if (!(err instanceof Error)) return '操作失败'; + return err.message.replace(/\b(\d{6})\b/g, (code) => { + const label = districtCodeLabel(code); + return label !== code ? `${label}(${code})` : code; + }); +} + +export default function CityPartnersPage() { + const [filterForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [subForm] = Form.useForm(); + const [subEditForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/partners', + () => { + const qs = new URLSearchParams(); + if (filters.companyName) qs.set('companyName', String(filters.companyName)); + if (filters.phone) qs.set('phone', String(filters.phone)); + if (filters.cityId) qs.set('cityId', String(filters.cityId)); + if (filters.excludeTest) qs.set('excludeTest', 'true'); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [subOpen, setSubOpen] = useState(false); + const [subEditOpen, setSubEditOpen] = useState(false); + const [subEditId, setSubEditId] = useState(null); + const [subEditParentId, setSubEditParentId] = useState(null); + const [cities, setCities] = useState([]); + const [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + const [maxCommissionRate, setMaxCommissionRate] = useState(0.05); + const [createMaxRate, setCreateMaxRate] = useState(0.05); + const [createCityCode, setCreateCityCode] = useState(); + + const loadCities = useCallback(async () => { + const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); + setCities(res.items); + }, []); + + useEffect(() => { + void loadCities(); + }, [loadCities]); + + const editCityCode = detail?.cityId + ? cities.find((c) => c.id === detail.cityId)?.code + : undefined; + + async function openPartner(id: string) { + const d = await request(`/admin/partners/${id}`); + setDetail(d); + setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); + setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05)); + editForm.setFieldsValue({ + name: d.name, + phone: d.phone, + companyName: d.companyName, + contactPhone: d.contactPhone ?? d.phone, + address: d.address ?? '', + scopeType: d.scopeType, + districtCodes: d.districtCodes ?? [], + orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, + redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, + bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE, + bankAccountName: d.bankAccountName ?? '', + bankAccountNo: d.bankAccountNo ?? '', + bankBranch: d.bankBranch ?? '', + }); + setDrawerOpen(true); + return d; + } + + async function savePartner() { + if (!detail) return; + try { + const v = await editForm.validateFields(); + const err = commissionSumError( + Number(v.orderCommissionRate ?? 0), + Number(v.redeemCommissionRate ?? 0), + maxCommissionRate, + ); + if (err) { + message.error(err); + return; + } + await request(`/admin/partners/${detail.id}`, { + method: 'PUT', + body: JSON.stringify({ + ...v, + orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, + redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, + districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, + }), + }); + message.success('已保存'); + setDrawerOpen(false); + void reload(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); + } + } + + async function refreshPartnerContext(parentId: string) { + if (detail?.id === parentId) { + await openPartner(parentId); + } + void reload(); + } + + async function deleteSubAccount(subId: string, parentId: string) { + await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' }); + message.success('子账号已删除'); + await refreshPartnerContext(parentId); + } + + function openSubEdit(row: SubRow, parentId: string) { + setSubEditId(row.id); + setSubEditParentId(parentId); + subEditForm.setFieldsValue({ + name: row.name, + phone: row.phone, + staffRole: row.staffRole ?? 'INTERNAL', + permissions: row.permissions ?? [], + status: row.status, + }); + setSubEditOpen(true); + } + + async function openAddSubAccount(parentId: string) { + await openPartner(parentId); + subForm.resetFields(); + subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] }); + setSubOpen(true); + } + + async function onCreateCityChange(cityId: string) { + createForm.setFieldsValue({ districtCodes: undefined }); + const matched = cities.find((c) => c.id === cityId); + setCreateCityCode(matched?.code); + const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`); + setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); + } + + const baseColumns: ColumnsType = [ + { title: '城市', dataIndex: 'cityName', width: 90 }, + { + title: '区县', + dataIndex: 'districtCodes', + width: 160, + render: (codes: string[] | null | undefined, row) => + row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), + }, + { + title: '公司名', + dataIndex: 'companyName', + width: 140, + render: (v, row) => ( + void openPartner(row.id)}>{v} + ), + }, + { + title: '主账号姓名', + dataIndex: 'name', + width: 120, + render: (v, row) => ( + + {v} + {row.isTest ? 测试 : null} + + ), + }, + { title: '登录手机', dataIndex: 'phone', width: 120 }, + { + title: '管辖', + dataIndex: 'scopeType', + width: 90, + render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), + }, + { + title: '佣金', + width: 110, + render: (_, row) => + `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`, + }, + { + title: '绑定状态', + dataIndex: 'bindingStatus', + width: 80, + render: (v) => ( + + {CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'} + + ), + }, + { + title: '管仓仓库', + dataIndex: 'managedWarehouseName', + width: 110, + render: (v) => v || '—', + }, + { + title: '门店', + dataIndex: 'storeCount', + width: 60, + render: (n: number, row) => ( + + {n ?? 0} + + ), + }, + { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, + { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, + { + title: '操作', + width: 80, + fixed: 'right', + render: (_, row) => ( + + ), + }, + ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} - - - 城市合伙人 + {settingsModal} + + + 城市合伙人 - {settingsButton} - - - - - 账号结构仅两级:主账号 → 子账号(子账号不可再添加下级)。管仓仓库请在{' '} - 仓库管理 中分配。 - - } - /> - - { - setFilters(v); - setPage(1); - }} - > - - - - - - - - 过滤测试账号 - - - - - - - - - - ( - void openAddSubAccount(record.id)} - onEdit={(sub) => openSubEdit(sub, record.id)} - onDelete={(subId) => void deleteSubAccount(subId, record.id)} - /> - ), - rowExpandable: () => true, - columnWidth: 40, - }} - pagination={{ - current: page, - pageSize, - total: data?.total ?? 0, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, - }} - /> - - setDrawerOpen(false)} - extra={ - - } - > - {detail && ( - <> - - {detail.cityName ?? '—'} - - - {detail.storeCount ?? 0} - - - - {detail.managedWarehouseName ?? ( - - 未分配(请前往 仓库管理 设置) - - )} - - - - - - - - - - - - - - - - - - - - setEditScopeType(v)} /> - - {editScopeType === CityPartnerScopeType.DISTRICT && ( - - - - )} - - - - - - - - - - 订单 + 核销合计不得超过 {(maxCommissionRate * 100).toFixed(2)}% - - - - - - - - - - - - ), - }, - { - key: 'staff', - label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`, - children: ( - { - subForm.resetFields(); - subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] }); - setSubOpen(true); - }} - onEdit={(sub) => openSubEdit(sub, detail.id)} - onDelete={(subId) => void deleteSubAccount(subId, detail.id)} - /> - ), - }, - ]} - /> - - )} - - - setCreateOpen(false)} - onOk={async () => { - try { - const v = await createForm.validateFields(); - const err = commissionSumError( - Number(v.orderCommissionRate ?? 0), - Number(v.redeemCommissionRate ?? 3), - createMaxRate, - ); - if (err) { - message.error(err); - return; - } - await request('/admin/partners', { - method: 'POST', - body: JSON.stringify({ - ...v, - orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, - redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, - districtCodes: - createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, - }), - }); - message.success('已创建'); - setCreateOpen(false); - void reload(); - } catch (e) { - const msg = formatApiError(e); - if (msg) message.error(msg); - } - }} - > -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - ); -} + {settingsButton} + + + + + 账号结构仅两级:主账号 → 子账号(子账号不可再添加下级)。管仓仓库请在{' '} + 仓库管理 中分配。 + + } + /> + +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + + 过滤测试账号 + + + + + + + + + +
( + void openAddSubAccount(record.id)} + onEdit={(sub) => openSubEdit(sub, record.id)} + onDelete={(subId) => void deleteSubAccount(subId, record.id)} + /> + ), + rowExpandable: () => true, + columnWidth: 40, + }} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + extra={ + + } + > + {detail && ( + <> + + {detail.cityName ?? '—'} + + + {detail.storeCount ?? 0} + + + + {detail.managedWarehouseName ?? ( + + 未分配(请前往 仓库管理 设置) + + )} + + + + + + + + + + + + + + + + + + + + setEditScopeType(v)} /> + + {editScopeType === CityPartnerScopeType.DISTRICT && ( + + + + )} + + + + + + + + + + 订单 + 核销合计不得超过 {(maxCommissionRate * 100).toFixed(2)}% + + + + + + + + + + + + ), + }, + { + key: 'staff', + label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`, + children: ( + { + subForm.resetFields(); + subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] }); + setSubOpen(true); + }} + onEdit={(sub) => openSubEdit(sub, detail.id)} + onDelete={(subId) => void deleteSubAccount(subId, detail.id)} + /> + ), + }, + ]} + /> + + )} + + + setCreateOpen(false)} + onOk={async () => { + try { + const v = await createForm.validateFields(); + const err = commissionSumError( + Number(v.orderCommissionRate ?? 0), + Number(v.redeemCommissionRate ?? 3), + createMaxRate, + ); + if (err) { + message.error(err); + return; + } + await request('/admin/partners', { + method: 'POST', + body: JSON.stringify({ + ...v, + orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, + redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, + districtCodes: + createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, + }), + }); + message.success('已创建'); + setCreateOpen(false); + void reload(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); + } + }} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/CityWarehousesPage.tsx b/apps/admin-web/src/pages/CityWarehousesPage.tsx index b970206..ffdbff7 100644 --- a/apps/admin-web/src/pages/CityWarehousesPage.tsx +++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx @@ -31,6 +31,7 @@ import { request, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -232,7 +233,14 @@ export default function CityWarehousesPage() { ), }, - { title: '仓库', dataIndex: 'name', width: 140 }, + { + title: '仓库', + dataIndex: 'name', + width: 140, + render: (v, row) => ( + void openEdit(row)}>{v} + ), + }, { title: '地址', dataIndex: 'address', width: 180 }, { title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '电话', dataIndex: 'contactPhone', width: 120 }, diff --git a/apps/admin-web/src/pages/DeliveriesPage.tsx b/apps/admin-web/src/pages/DeliveriesPage.tsx index ed28749..5dbde9f 100644 --- a/apps/admin-web/src/pages/DeliveriesPage.tsx +++ b/apps/admin-web/src/pages/DeliveriesPage.tsx @@ -8,6 +8,7 @@ import { request } from '../lib/api'; import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -62,7 +63,23 @@ export default function DeliveriesPage() { } const baseColumns: ColumnsType = [ - { title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, + { + title: '订单号', + dataIndex: ['order', 'orderNo'], + width: 170, + render: (v, row) => ( + { + const d = await request(`/admin/deliveries/${row.id}`); + setDetail(d); + editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo }); + setDrawerOpen(true); + }} + > + {v} + + ), + }, { title: 'provider', dataIndex: 'provider', width: 90 }, { title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' }, { title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' }, diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index d214eec..498de0e 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -31,6 +31,7 @@ import { downloadBase64File } from '../lib/exportExcel'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({ @@ -243,7 +244,14 @@ export default function DevPlanTasksPage() { } const baseColumns: ColumnsType = [ - { title: '任务号', dataIndex: 'taskNo', width: 160 }, + { + title: '任务号', + dataIndex: 'taskNo', + width: 160, + render: (v, row) => ( + openEdit(row)}>{v} + ), + }, { title: '类型', dataIndex: 'type', diff --git a/apps/admin-web/src/pages/DevPlanVersionsPage.tsx b/apps/admin-web/src/pages/DevPlanVersionsPage.tsx index 57840fc..101c903 100644 --- a/apps/admin-web/src/pages/DevPlanVersionsPage.tsx +++ b/apps/admin-web/src/pages/DevPlanVersionsPage.tsx @@ -23,6 +23,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; @@ -122,7 +123,9 @@ export default function DevPlanVersionsPage() { } const baseColumns: ColumnsType = [ - { title: '版本号', dataIndex: 'versionNo', width: 120 }, + { title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => ( + void openEdit(row)}>{v} + ) }, { title: '状态', dataIndex: 'status', diff --git a/apps/admin-web/src/pages/DomainEventsPage.tsx b/apps/admin-web/src/pages/DomainEventsPage.tsx index c977d09..baa5a37 100644 --- a/apps/admin-web/src/pages/DomainEventsPage.tsx +++ b/apps/admin-web/src/pages/DomainEventsPage.tsx @@ -5,6 +5,7 @@ import type { ColumnsType } from 'antd/es/table'; import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -59,7 +60,20 @@ export default function DomainEventsPage() { const baseColumns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) }, - { title: '类型', dataIndex: 'eventType', width: 130, render: (v) => {v} }, + { + title: '类型', + dataIndex: 'eventType', + width: 130, + render: (v, r) => ( + { + setDetail(await request(`/admin/logs/domain-events/${r.id}`)); + }} + > + {v} + + ), + }, { title: '关联', render: (_, r) => `${r.refType} #${r.refId}` }, { title: '状态', dataIndex: 'status', width: 100 }, { title: '摘要', render: (_, r) => r.param1 || r.remark || '—' }, diff --git a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx index 1f6ebd2..055932d 100644 --- a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx +++ b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx @@ -29,6 +29,7 @@ import { import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({ @@ -177,7 +178,13 @@ export default function FulfillmentProvidersPage() { const baseColumns: ColumnsType = [ { title: '编码', dataIndex: 'code', width: 100 }, - { title: '名称', dataIndex: 'name' }, + { + title: '名称', + dataIndex: 'name', + render: (v, row) => ( + openEdit(row)}>{v} + ), + }, { title: '类型', dataIndex: 'type', diff --git a/apps/admin-web/src/pages/HqAccountsPage.tsx b/apps/admin-web/src/pages/HqAccountsPage.tsx index 13e7668..9ca2e65 100644 --- a/apps/admin-web/src/pages/HqAccountsPage.tsx +++ b/apps/admin-web/src/pages/HqAccountsPage.tsx @@ -8,6 +8,7 @@ import { request, type HqProfile, type Paginated } from '../lib/api'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -69,7 +70,31 @@ export default function HqAccountsPage() { const cityOptions = cities.map((c) => ({ value: c.id, label: c.name })); const baseColumns: ColumnsType = [ - { title: '姓名', dataIndex: 'name' }, + { + title: '姓名', + dataIndex: 'name', + render: (v, row) => + isSuperAdmin ? ( + { + 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} + + ) : ( + v + ), + }, { title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 130 }, { diff --git a/apps/admin-web/src/pages/HqLogsPage.tsx b/apps/admin-web/src/pages/HqLogsPage.tsx index fe3203d..2e7c3a9 100644 --- a/apps/admin-web/src/pages/HqLogsPage.tsx +++ b/apps/admin-web/src/pages/HqLogsPage.tsx @@ -1,204 +1,215 @@ -import { useEffect, useState } from 'react'; -import { - Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { useSearchParams } from 'react-router-dom'; -import { request } from '../lib/api'; -import { AdminCellLine } from '../components/AdminCellLine'; -import { fmtTime } from '../lib/constants'; -import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log'; +import { useEffect, useState } from 'react'; +import { + Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useSearchParams } from 'react-router-dom'; +import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; +import { fmtTime } from '../lib/constants'; +import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; - - -type Row = { - id: string; - hqAccountId: string | null; - hqName: string | null; - hqPhone: string | null; - hqRole: string | null; - action: string | null; - actionLabel: string; - refType: string | null; - refId: string | null; - status: string | null; - remark: string | null; - detail: Record | null; - createdAt: string; -}; - -export default function HqLogsPage() { - const [searchParams, setSearchParams] = useSearchParams(); - const [form] = Form.useForm(); - const [filters, setFilters] = useState>(() => ({ - hqAccountId: searchParams.get('hqAccountId') ?? '', - action: searchParams.get('action') ?? '', - refType: searchParams.get('refType') ?? '', - })); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/logs/hq', - () => { - const qs = new URLSearchParams(); - if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId); - if (filters.action) qs.set('action', filters.action); - if (filters.refType) qs.set('refType', filters.refType); - return qs; - }, - [filters], - ); - const [detail, setDetail] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - - useEffect(() => { - form.setFieldsValue(filters); - }, [form, filters]); - - const baseColumns: ColumnsType = [ - { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, - { - title: '操作人', - width: 200, - render: (_, r) => ( - - ), - }, - { - title: '行为', - dataIndex: 'actionLabel', - width: 160, - render: (v, r) => {v || resolveHqOperationLabel(r.action)}, - }, - { title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' }, - { title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' }, - { - title: '操作', - width: 80, - render: (_, row) => ( - - ), - }, - ]; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type Row = { + id: string; + hqAccountId: string | null; + hqName: string | null; + hqPhone: string | null; + hqRole: string | null; + action: string | null; + actionLabel: string; + refType: string | null; + refId: string | null; + status: string | null; + remark: string | null; + detail: Record | null; + createdAt: string; +}; + +export default function HqLogsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>(() => ({ + hqAccountId: searchParams.get('hqAccountId') ?? '', + action: searchParams.get('action') ?? '', + refType: searchParams.get('refType') ?? '', + })); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/logs/hq', + () => { + const qs = new URLSearchParams(); + if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId); + if (filters.action) qs.set('action', filters.action); + if (filters.refType) qs.set('refType', filters.refType); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + form.setFieldsValue(filters); + }, [form, filters]); + + const baseColumns: ColumnsType = [ + { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作人', + width: 200, + render: (_, r) => ( + + ), + }, + { + title: '行为', + dataIndex: 'actionLabel', + width: 160, + render: (v, r) => ( + { + const res = await request(`/admin/logs/hq/${r.id}`); + setDetail(res); + setDrawerOpen(true); + }} + > + {v || resolveHqOperationLabel(r.action)} + + ), + }, + { title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' }, + { title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} + {settingsModal} HQ 操作日志 - {settingsButton} - - 记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。 - - -
{ - setFilters(values); - setPage(1); - const qs = new URLSearchParams(); - if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId); - if (values.action) qs.set('action', values.action); - if (values.refType) qs.set('refType', values.refType); - setSearchParams(qs); - }} - > - - - - - - - - - - - - - - -
{ - setPage(p); - setPageSize(ps); - }, - }} - /> - - setDrawerOpen(false)}> - {detail && ( - <> - - {detail.id} - {fmtTime(detail.createdAt)} - - - - {detail.hqRole || '—'} - - {detail.actionLabel || resolveHqOperationLabel(detail.action)} - - {detail.refType} / {detail.refId} - {detail.status || '—'} - {detail.remark || '—'} - - 请求/响应快照 -
-              {JSON.stringify(detail.detail, null, 2)}
-            
- - )} -
- - ); -} + {settingsButton} + + 记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。 + + + { + setFilters(values); + setPage(1); + const qs = new URLSearchParams(); + if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId); + if (values.action) qs.set('action', values.action); + if (values.refType) qs.set('refType', values.refType); + setSearchParams(qs); + }} + > + + + + + + + + + + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)}> + {detail && ( + <> + + {detail.id} + {fmtTime(detail.createdAt)} + + + + {detail.hqRole || '—'} + + {detail.actionLabel || resolveHqOperationLabel(detail.action)} + + {detail.refType} / {detail.refId} + {detail.status || '—'} + {detail.remark || '—'} + + 请求/响应快照 +
+              {JSON.stringify(detail.detail, null, 2)}
+            
+ + )} +
+ + ); +} diff --git a/apps/admin-web/src/pages/InvoicesPage.tsx b/apps/admin-web/src/pages/InvoicesPage.tsx index 354ab69..efdf893 100644 --- a/apps/admin-web/src/pages/InvoicesPage.tsx +++ b/apps/admin-web/src/pages/InvoicesPage.tsx @@ -31,6 +31,7 @@ import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { uploadFileToOss } from '../lib/upload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -204,7 +205,13 @@ export default function InvoicesPage() { ? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory : '—', }, - { title: '名称', dataIndex: 'titleName' }, + { + title: '名称', + dataIndex: 'titleName', + render: (v, row) => ( + openDetail(row.id)}>{v} + ), + }, { title: '状态', width: 110, diff --git a/apps/admin-web/src/pages/KnowledgeBasesPage.tsx b/apps/admin-web/src/pages/KnowledgeBasesPage.tsx index 3cd9fc1..78083d8 100644 --- a/apps/admin-web/src/pages/KnowledgeBasesPage.tsx +++ b/apps/admin-web/src/pages/KnowledgeBasesPage.tsx @@ -26,6 +26,7 @@ import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { uploadFileToOss } from '../lib/upload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type FormValues = { @@ -127,7 +128,13 @@ export default function KnowledgeBasesPage() { }; const baseColumns: ColumnsType = [ - { title: '名称', dataIndex: 'name' }, + { + title: '名称', + dataIndex: 'name', + render: (v, row) => ( + openEdit(row)}>{v} + ), + }, { title: '说明', dataIndex: 'description' }, { title: '文档数', dataIndex: 'documentCount', width: 90 }, { diff --git a/apps/admin-web/src/pages/LlmConfigsPage.tsx b/apps/admin-web/src/pages/LlmConfigsPage.tsx index 3643382..dc6dedf 100644 --- a/apps/admin-web/src/pages/LlmConfigsPage.tsx +++ b/apps/admin-web/src/pages/LlmConfigsPage.tsx @@ -26,6 +26,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type FormValues = { @@ -156,7 +157,13 @@ export default function LlmConfigsPage() { }; const baseColumns: ColumnsType = [ - { title: '名称', dataIndex: 'name' }, + { + title: '名称', + dataIndex: 'name', + render: (v, row) => ( + openEdit(row)}>{v} + ), + }, { title: '提供商', dataIndex: 'provider', diff --git a/apps/admin-web/src/pages/LogisticsBillsPage.tsx b/apps/admin-web/src/pages/LogisticsBillsPage.tsx index c1c55ec..cf4c94a 100644 --- a/apps/admin-web/src/pages/LogisticsBillsPage.tsx +++ b/apps/admin-web/src/pages/LogisticsBillsPage.tsx @@ -30,6 +30,7 @@ import { downloadExcelCsv } from '../lib/exportExcel'; import { request } from '../lib/api'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type BillRow = { id: string; @@ -246,7 +247,14 @@ export default function LogisticsBillsPage() { const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0); const billColumns: ColumnsType = [ - { title: '账单号', dataIndex: 'billNo', width: 170 }, + { + title: '账单号', + dataIndex: 'billNo', + width: 170, + render: (v, row) => ( + void openDetail(row.id)}>{v} + ), + }, { title: '承运商', width: 140, diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 1c4eb68..6c1a9fd 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, Button, @@ -27,10 +27,12 @@ import dayjs, { type Dayjs } from 'dayjs'; import { ORDER_TYPE_LABELS } from '@dukang/shared-types'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { downloadBase64File } from '../lib/exportExcel'; import { ADMIN_OPTIONS_PAGE_SIZE, DELIVERY_PROVIDER_LABELS, + DELIVERY_TYPE_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, ORDER_STATUS_OPERATOR_LABELS, @@ -273,6 +275,7 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S } export default function OrdersPage() { + const navigate = useNavigate(); const [searchParams] = useSearchParams(); const initialOrderNo = searchParams.get('orderNo')?.trim() || ''; const [form] = Form.useForm(); @@ -593,63 +596,98 @@ export default function OrdersPage() { const baseColumns: ColumnsType = [ { - title: '商品', - width: 240, - render: (_, row) => ( -
- - {row.productName || '—'} - {row.isTest ? 测试 : null} - {row.fulfillmentHold ? 大单 : null} - {row.orderType === 'PROXY' || row.isProxyOrder ? ( - 代下单 - ) : null} - -
- - {[row.productSpec, row.quantity != null ? `×${row.quantity}` : null] - .filter(Boolean) - .join(' ')} - -
-
+ title: '订单号', + dataIndex: 'orderNo', + width: 180, + render: (v, row) => ( + + openDetail(row.id)}>{v} + {row.isTest ? 测试 : null} + ), }, { - title: '状态 / 实付', - width: 130, + title: '用户', + key: 'user', + width: 120, + render: (_, row) => + row.user?.id ? ( + navigate('/users', { state: { openUserId: String(row.user!.id) } })}> + {row.user.userNo} + + ) : ( + (row.user?.userNo || '—') + ), + }, + { + title: '商品', + key: 'productName', + width: 180, render: (_, row) => ( -
- - {ORDER_STATUS_LABELS[row.status] || row.status} - -
¥{row.payAmount}
-
+ + {row.productName || '—'} + {row.fulfillmentHold ? 大单 : null} + {row.orderType === 'PROXY' || row.isProxyOrder ? ( + 代下单 + ) : null} + ), }, + { + title: '规格', + dataIndex: 'productSpec', + width: 140, + render: (v: string | undefined) => v || '—', + }, + { + title: '数量', + dataIndex: 'quantity', + width: 80, + render: (v: number | undefined, row) => + v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`, + }, + { + title: '配送方式', + dataIndex: 'deliveryType', + width: 100, + render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (s: string) => ( + {ORDER_STATUS_LABELS[s] || s} + ), + }, + { + title: '实付', + dataIndex: 'payAmount', + width: 90, + render: (v: number) => `¥${v}`, + }, { title: '好客权益', width: 200, render: (_, row) => formatBenefitBrief(row), }, { - title: '收货信息', - width: 240, - render: (_, row) => { - const address = formatReceiverAddress(row); - return ( -
-
- {row.receiverName || '—'} {row.receiverPhone || ''} -
- {address ? ( - - {address} - - ) : null} -
- ); - }, + title: '收货人', + dataIndex: 'receiverName', + width: 90, + render: (v: string | undefined) => v || '—', + }, + { + title: '电话', + dataIndex: 'receiverPhone', + width: 120, + render: (v: string | undefined) => v || '—', + }, + { + title: '地址', + key: 'receiverAddress', + width: 260, + render: (_, row) => formatReceiverAddress(row) || '—', }, { title: '下单时间', diff --git a/apps/admin-web/src/pages/PartnerAccountsPage.tsx b/apps/admin-web/src/pages/PartnerAccountsPage.tsx index 9e387cc..6aa68fd 100644 --- a/apps/admin-web/src/pages/PartnerAccountsPage.tsx +++ b/apps/admin-web/src/pages/PartnerAccountsPage.tsx @@ -5,7 +5,7 @@ import { import type { ColumnsType } from 'antd/es/table'; import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; -import { AdminCellLine } from '../components/AdminCellLine'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminListColumns } from '../lib/useAdminListColumns'; @@ -176,14 +176,13 @@ export default function PartnerAccountsPage() { title: '姓名 / 类型', width: 200, render: (_, row) => ( - + + void openAccount(row.id)}>{row.name} + + {' · '} + {row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'} + + ), }, { title: '手机', dataIndex: 'phone', width: 120 }, diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index cb97582..0643692 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -1,579 +1,587 @@ -import { useEffect, useState } from 'react'; -import { - Button, - Card, - DatePicker, - Descriptions, - Drawer, - Form, - Input, - Modal, - Select, - Space, - Statistic, - Table, - Tag, - Typography, - message, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import dayjs, { type Dayjs } from 'dayjs'; -import { request, type Paginated } from '../lib/api'; -import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; -import { downloadExcelCsv } from '../lib/exportExcel'; +import { useEffect, useState } from 'react'; +import { + Button, + Card, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + Modal, + Select, + Space, + Statistic, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs, { type Dayjs } from 'dayjs'; +import { request, type Paginated } from '../lib/api'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { downloadExcelCsv } from '../lib/exportExcel'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; - - -type Row = { - id: string; - billNo: string; - orderCommission: number; - redeemCommission: number; - totalAmount: number; - status: string; - periodStart: string; - periodEnd: string; - rejectReason?: string | null; - paymentRef?: string | null; - paidAt?: string | null; - partner?: { - companyName?: string; - phone?: string; - bankAccountName?: string | null; - bankAccountNo?: string | null; - bankBranch?: string | null; - }; - partnerAccount?: { - companyName?: string; - phone?: string; - bankAccountName?: string | null; - bankAccountNo?: string | null; - bankBranch?: string | null; - }; -}; - -type PartnerOption = { id: string; companyName: string; phone?: string }; - -const STATUS_LABELS: Record = { - PENDING_REVIEW: '待审核', - AWAITING_CONFIRM: '待合伙人确认', - UNPAID: '未打款', - PAID: '已打款', - REJECTED: '已驳回', -}; - -const STATUS_COLORS: Record = { - PENDING_REVIEW: 'gold', - AWAITING_CONFIRM: 'blue', - UNPAID: 'red', - PAID: 'green', - REJECTED: 'red', -}; - -export default function PartnerBillsPage() { - const [form] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const [partners, setPartners] = useState([]); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/partner-bills', - () => { - const qs = new URLSearchParams(); - if (filters.status) qs.set('status', filters.status); - if (filters.partnerId) qs.set('partnerId', filters.partnerId); - if (filters.year) qs.set('year', filters.year); - if (filters.month) qs.set('month', filters.month); - return qs; - }, - [filters], - ); - const [genOpen, setGenOpen] = useState(false); - const [genForm] = Form.useForm(); - const [exporting, setExporting] = useState(false); - const [rejectOpen, setRejectOpen] = useState(false); - const [rejectTarget, setRejectTarget] = useState(null); - const [rejectForm] = Form.useForm(); - const [rejecting, setRejecting] = useState(false); - const [selectedKeys, setSelectedKeys] = useState([]); - const [detail, setDetail] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - - useEffect(() => { - void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) - .then((res) => setPartners(res.items)) - .catch(() => {}); - }, []); - - async function generateBill(values: { partnerId?: string; month: Dayjs }) { - const year = values.month.year(); - const month = values.month.month() + 1; - if (!values.partnerId) { - const result = await request<{ success: number; failed: number; total: number }>( - '/admin/partner-bills/generate-all', - { method: 'POST', body: JSON.stringify({ year, month }) }, - ); - message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`); - } else { - await request('/admin/partner-bills/generate', { - method: 'POST', - body: JSON.stringify({ partnerId: values.partnerId, year, month }), - }); - message.success('账单已生成(待审核)'); - } - setGenOpen(false); - reload(); - } - - function sendBills(ids: string[]) { - Modal.confirm({ - title: '发送给合伙人?', - content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`, - okText: '确认发送', - cancelText: '取消', - onOk: async () => { - if (ids.length === 1) { - await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' }); - } else { - await request('/admin/partner-bills/batch-send', { - method: 'POST', - body: JSON.stringify({ ids }), - }); - } - message.success('已发送'); - setSelectedKeys([]); - reload(); - }, - }); - } - - function markPaid(ids: string[], amountHint?: number) { - let paymentRef = ''; - Modal.confirm({ - title: '确认打款?', - content: ( -
-
- 将标记 {ids.length} 笔账单为已打款 - {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。 -
- { - paymentRef = e.target.value; - }} - /> -
- ), - okText: '确认打款', - cancelText: '取消', - onOk: async () => { - const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined }); - if (ids.length === 1) { - await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body }); - } else { - await request('/admin/partner-bills/batch-mark-paid', { - method: 'POST', - body: JSON.stringify({ ids }), - }); - } - message.success('已标记打款'); - setSelectedKeys([]); - reload(); - }, - }); - } - - function openReject(row: Row) { - Modal.confirm({ - title: '驳回该账单?', - content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。', - okText: '继续填写理由', - cancelText: '取消', - onOk: () => { - setRejectTarget(row); - rejectForm.resetFields(); - setRejectOpen(true); - }, - }); - } - - async function submitReject(values: { reason: string }) { - if (!rejectTarget) return; - setRejecting(true); - try { - await request(`/admin/partner-bills/${rejectTarget.id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason: values.reason.trim() }), - }); - message.success('已驳回'); - setRejectOpen(false); - setRejectTarget(null); - reload(); - } finally { - setRejecting(false); - } - } - - async function openDetail(id: string) { - const d = await request(`/admin/partner-bills/${id}`); - setDetail(d); - setDrawerOpen(true); - } - - async function exportExcel() { - setExporting(true); - try { - const qs = new URLSearchParams(); - if (filters.status) qs.set('status', filters.status); - if (filters.partnerId) qs.set('partnerId', filters.partnerId); - if (filters.year) qs.set('year', filters.year); - if (filters.month) qs.set('month', filters.month); - const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`); - const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; - downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`); - message.success(`已导出 ${result.count} 条`); - } finally { - setExporting(false); - } - } - - const summary = data?.summary; - const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; - const partnerBank = (r: Row) => r.partnerAccount ?? r.partner; - const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); - const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED'); - const canPay = selectedRows.filter((r) => r.status === 'UNPAID'); - const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0); - - const baseColumns: ColumnsType = [ - { title: '账单号', dataIndex: 'billNo', width: 180 }, - { - title: '合伙人', - width: 160, - render: (_, r) => partnerName(r), - }, - { - title: '账期', - width: 200, - render: (_, r) => - `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`, - }, - { - title: '酒单佣金', - dataIndex: 'orderCommission', - width: 110, - render: (v) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '核销佣金', - dataIndex: 'redeemCommission', - width: 110, - render: (v) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '合计应付', - dataIndex: 'totalAmount', - width: 110, - render: (v) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '收款户名', - width: 100, - render: (_, r) => partnerBank(r)?.bankAccountName || '—', - }, - { - title: '收款账号', - width: 140, - render: (_, r) => partnerBank(r)?.bankAccountNo || '—', - }, - { - title: '状态', - dataIndex: 'status', - width: 130, - render: (s, row) => ( - - {STATUS_LABELS[s] || s} - {s === 'REJECTED' && row.rejectReason ? ( - - {row.rejectReason} - - ) : null} - - ), - }, - { - title: '操作', - width: 220, - fixed: 'right', - render: (_, row) => ( - - - {(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && ( - - )} - {row.status === 'UNPAID' && ( - <> - - - - )} - {row.status === 'AWAITING_CONFIRM' && ( - - )} - - ), - }, - ]; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type Row = { + id: string; + billNo: string; + orderCommission: number; + redeemCommission: number; + totalAmount: number; + status: string; + periodStart: string; + periodEnd: string; + rejectReason?: string | null; + paymentRef?: string | null; + paidAt?: string | null; + partner?: { + companyName?: string; + phone?: string; + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + }; + partnerAccount?: { + companyName?: string; + phone?: string; + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + }; +}; + +type PartnerOption = { id: string; companyName: string; phone?: string }; + +const STATUS_LABELS: Record = { + PENDING_REVIEW: '待审核', + AWAITING_CONFIRM: '待合伙人确认', + UNPAID: '未打款', + PAID: '已打款', + REJECTED: '已驳回', +}; + +const STATUS_COLORS: Record = { + PENDING_REVIEW: 'gold', + AWAITING_CONFIRM: 'blue', + UNPAID: 'red', + PAID: 'green', + REJECTED: 'red', +}; + +export default function PartnerBillsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [partners, setPartners] = useState([]); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/partner-bills', + () => { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.partnerId) qs.set('partnerId', filters.partnerId); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + return qs; + }, + [filters], + ); + const [genOpen, setGenOpen] = useState(false); + const [genForm] = Form.useForm(); + const [exporting, setExporting] = useState(false); + const [rejectOpen, setRejectOpen] = useState(false); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectForm] = Form.useForm(); + const [rejecting, setRejecting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setPartners(res.items)) + .catch(() => {}); + }, []); + + async function generateBill(values: { partnerId?: string; month: Dayjs }) { + const year = values.month.year(); + const month = values.month.month() + 1; + if (!values.partnerId) { + const result = await request<{ success: number; failed: number; total: number }>( + '/admin/partner-bills/generate-all', + { method: 'POST', body: JSON.stringify({ year, month }) }, + ); + message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`); + } else { + await request('/admin/partner-bills/generate', { + method: 'POST', + body: JSON.stringify({ partnerId: values.partnerId, year, month }), + }); + message.success('账单已生成(待审核)'); + } + setGenOpen(false); + reload(); + } + + function sendBills(ids: string[]) { + Modal.confirm({ + title: '发送给合伙人?', + content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`, + okText: '确认发送', + cancelText: '取消', + onOk: async () => { + if (ids.length === 1) { + await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' }); + } else { + await request('/admin/partner-bills/batch-send', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已发送'); + setSelectedKeys([]); + reload(); + }, + }); + } + + function markPaid(ids: string[], amountHint?: number) { + let paymentRef = ''; + Modal.confirm({ + title: '确认打款?', + content: ( +
+
+ 将标记 {ids.length} 笔账单为已打款 + {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。 +
+ { + paymentRef = e.target.value; + }} + /> +
+ ), + okText: '确认打款', + cancelText: '取消', + onOk: async () => { + const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined }); + if (ids.length === 1) { + await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body }); + } else { + await request('/admin/partner-bills/batch-mark-paid', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } + message.success('已标记打款'); + setSelectedKeys([]); + reload(); + }, + }); + } + + function openReject(row: Row) { + Modal.confirm({ + title: '驳回该账单?', + content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。', + okText: '继续填写理由', + cancelText: '取消', + onOk: () => { + setRejectTarget(row); + rejectForm.resetFields(); + setRejectOpen(true); + }, + }); + } + + async function submitReject(values: { reason: string }) { + if (!rejectTarget) return; + setRejecting(true); + try { + await request(`/admin/partner-bills/${rejectTarget.id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason: values.reason.trim() }), + }); + message.success('已驳回'); + setRejectOpen(false); + setRejectTarget(null); + reload(); + } finally { + setRejecting(false); + } + } + + async function openDetail(id: string) { + const d = await request(`/admin/partner-bills/${id}`); + setDetail(d); + setDrawerOpen(true); + } + + async function exportExcel() { + setExporting(true); + try { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.partnerId) qs.set('partnerId', filters.partnerId); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`); + const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; + downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`); + message.success(`已导出 ${result.count} 条`); + } finally { + setExporting(false); + } + } + + const summary = data?.summary; + const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; + const partnerBank = (r: Row) => r.partnerAccount ?? r.partner; + const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); + const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED'); + const canPay = selectedRows.filter((r) => r.status === 'UNPAID'); + const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0); + + const baseColumns: ColumnsType = [ + { + title: '账单号', + dataIndex: 'billNo', + width: 180, + render: (v, row) => ( + void openDetail(row.id)}>{v} + ), + }, + { + title: '合伙人', + width: 160, + render: (_, r) => partnerName(r), + }, + { + title: '账期', + width: 200, + render: (_, r) => + `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`, + }, + { + title: '酒单佣金', + dataIndex: 'orderCommission', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '核销佣金', + dataIndex: 'redeemCommission', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '合计应付', + dataIndex: 'totalAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '收款户名', + width: 100, + render: (_, r) => partnerBank(r)?.bankAccountName || '—', + }, + { + title: '收款账号', + width: 140, + render: (_, r) => partnerBank(r)?.bankAccountNo || '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 130, + render: (s, row) => ( + + {STATUS_LABELS[s] || s} + {s === 'REJECTED' && row.rejectReason ? ( + + {row.rejectReason} + + ) : null} + + ), + }, + { + title: '操作', + width: 220, + fixed: 'right', + render: (_, row) => ( + + + {(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && ( + + )} + {row.status === 'UNPAID' && ( + <> + + + + )} + {row.status === 'AWAITING_CONFIRM' && ( + + )} + + ), + }, + ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} - - - 合伙人账单 + {settingsModal} + + + 合伙人账单 - {settingsButton} - - 每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款 - - - - {summary && ( - - - - - - - - - )} - -
{ - setFilters({ - partnerId: v.partnerId || '', - status: v.status || '', - year: v.month ? String(v.month.year()) : '', - month: v.month ? String(v.month.month() + 1) : '', - }); - setPage(1); - }} - > - - ({ value, label }))} - /> - - - - - - - - - - - - - - - - - - - - - -
{ - setPage(p); - setPageSize(ps); - }, - }} - /> - - setGenOpen(false)} footer={null}> -
- - - - - ({ + value: p.id, + label: p.companyName || p.phone || p.id, + }))} + /> + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setGenOpen(false)} footer={null}> + + + + + + - - - - - - - - - - - - - - - - - -
{ - setPage(p); - setPageSize(ps); - }, - }} - scroll={{ x: 'max-content' }} - /> - setDrawerOpen(false)} width={520}> - {detail && ( - - {Object.entries(detail).map(([k, v]) => ( - - {typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')} - - ))} - - )} - - - ); -} + {settingsButton} + ({ value: o.value, label: o.label }))} + value={category} + onChange={(v) => { + setCategory(String(v)); + setPage(1); + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (v) next.set('category', String(v)); + else next.delete('category'); + return next; + }); + }} + style={{ marginBottom: 16 }} + /> +
{ + setFilters(values); + setPage(1); + }} + > + + + + + + + + + + + + + + + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + scroll={{ x: 'max-content' }} + /> + setDrawerOpen(false)} width={520}> + {detail && ( + + {Object.entries(detail).map(([k, v]) => ( + + {typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')} + + ))} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/PartnersPage.tsx b/apps/admin-web/src/pages/PartnersPage.tsx index 2590e71..3a5d476 100644 --- a/apps/admin-web/src/pages/PartnersPage.tsx +++ b/apps/admin-web/src/pages/PartnersPage.tsx @@ -1,410 +1,417 @@ -import { useCallback, useEffect, useState } from 'react'; -import { - Button, - Drawer, - Form, - Input, - InputNumber, - Modal, - Select, - Space, - Table, - Tabs, - Tag, - Typography, - message, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { - CITY_PARTNER_SCOPE_LABELS, - CityPartnerScopeType, - PARTNER_PERMISSION_KEYS, - PARTNER_PERMISSION_LABELS, - type PartnerPermissionKey, -} from '@dukang/shared-types'; -import { request, type Paginated } from '../lib/api'; -import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; -import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + CITY_PARTNER_SCOPE_LABELS, + CityPartnerScopeType, + PARTNER_PERMISSION_KEYS, + PARTNER_PERMISSION_LABELS, + type PartnerPermissionKey, +} from '@dukang/shared-types'; +import { request, type Paginated } from '../lib/api'; +import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import { useAdminListColumns } from '../lib/useAdminListColumns'; - - -type Row = { - id: string; - companyName: string; - phone: string; - name: string; - contactPhone?: string | null; - cityId?: string | null; - cityName?: string | null; - scopeType?: string; - districtCodes?: string[] | null; - orderCommissionRate?: number; - redeemCommissionRate?: number; - storeCount: number; - accountCount: number; - createdAt: string; -}; - -type PartnerDetail = Row & { - address?: string; - districtCodes?: string[] | null; - bindingStatus?: string; - bankAccountName?: string | null; - bankAccountNo?: string | null; - bankBranch?: string | null; - managedWarehouseId?: string | null; - children?: Array<{ - id: string; - phone: string; - name: string; - staffRole?: string; - permissions?: string[]; - status: string; - }>; -}; - -type CityOption = { id: string; name: string; code: string }; -type WarehouseOption = { id: string; name: string }; - -const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); -const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] })); - -function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { - if (!values?.length) return []; - if (Array.isArray(values[0])) { - return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); - } - return values as string[]; -} - -export default function PartnersPage() { - const [form] = Form.useForm(); - const [editForm] = Form.useForm(); - const [createForm] = Form.useForm(); - const [subForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/partners', - () => { - const qs = new URLSearchParams(); - if (filters.companyName) qs.set('companyName', filters.companyName); - if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone); - return qs; - }, - [filters], - ); - const [detail, setDetail] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); - const [subOpen, setSubOpen] = useState(false); - const [cities, setCities] = useState([]); - const [warehouses, setWarehouses] = useState([]); - const [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); - const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); - const [createCityId, setCreateCityId] = useState(); - - const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined; - const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined; - - function formatApiError(err: unknown): string | null { - if (err && typeof err === 'object' && 'errorFields' in err) return null; - if (!(err instanceof Error)) return '操作失败'; - return err.message.replace(/\b(\d{6})\b/g, (code) => { - const label = districtCodeLabel(code); - return label !== code ? `${label}(${code})` : code; - }); - } - - const loadCities = useCallback(async () => { - const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); - setCities(res.items); - }, []); - - const loadWarehouses = useCallback(async (cityId: string) => { - const rows = await request(`/admin/cities/${cityId}/warehouses`); - setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name }))); - }, []); - - useEffect(() => { - void loadCities(); - }, [loadCities]); - - async function openPartner(id: string) { - const d = await request(`/admin/partners/${id}`); - setDetail(d); - setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); - if (d.cityId) await loadWarehouses(d.cityId); - editForm.setFieldsValue({ - name: d.name, - phone: d.phone, - companyName: d.companyName, - contactPhone: d.contactPhone ?? d.phone, - address: d.address ?? '', - scopeType: d.scopeType, - districtCodes: d.districtCodes ?? [], - orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, - redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, - bindingStatus: d.bindingStatus, - managedWarehouseId: d.managedWarehouseId, - bankAccountName: d.bankAccountName ?? '', - bankAccountNo: d.bankAccountNo ?? '', - bankBranch: d.bankBranch ?? '', - }); - setDrawerOpen(true); - } - - async function savePartner() { - if (!detail) return; - try { - const v = await editForm.validateFields(); - const body = { - ...v, - orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, - redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, - districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, - }; - await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) }); - message.success('已保存'); - setDrawerOpen(false); - void reload(); - } catch (e) { - const msg = formatApiError(e); - if (msg) message.error(msg); - } - } - - const baseColumns: ColumnsType = [ - { title: '城市', dataIndex: 'cityName', width: 100 }, - { - title: '区县', - dataIndex: 'districtCodes', - width: 160, - render: (codes: string[] | null | undefined, row) => - row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), - }, - { title: '公司名', dataIndex: 'companyName' }, - { title: '主账号', dataIndex: 'phone', width: 130 }, - { - title: '管辖', - dataIndex: 'scopeType', - width: 100, - render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), - }, - { title: '门店', dataIndex: 'storeCount', width: 70 }, - { title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) }, - { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, - { - title: '操作', - width: 120, - render: (_, row) => ( - - ), - }, - ]; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type Row = { + id: string; + companyName: string; + phone: string; + name: string; + contactPhone?: string | null; + cityId?: string | null; + cityName?: string | null; + scopeType?: string; + districtCodes?: string[] | null; + orderCommissionRate?: number; + redeemCommissionRate?: number; + storeCount: number; + accountCount: number; + createdAt: string; +}; + +type PartnerDetail = Row & { + address?: string; + districtCodes?: string[] | null; + bindingStatus?: string; + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + managedWarehouseId?: string | null; + children?: Array<{ + id: string; + phone: string; + name: string; + staffRole?: string; + permissions?: string[]; + status: string; + }>; +}; + +type CityOption = { id: string; name: string; code: string }; +type WarehouseOption = { id: string; name: string }; + +const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label })); +const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] })); + +function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] { + if (!values?.length) return []; + if (Array.isArray(values[0])) { + return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean); + } + return values as string[]; +} + +export default function PartnersPage() { + const [form] = Form.useForm(); + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [subForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/partners', + () => { + const qs = new URLSearchParams(); + if (filters.companyName) qs.set('companyName', filters.companyName); + if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [subOpen, setSubOpen] = useState(false); + const [cities, setCities] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + const [createCityId, setCreateCityId] = useState(); + + const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined; + const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined; + + function formatApiError(err: unknown): string | null { + if (err && typeof err === 'object' && 'errorFields' in err) return null; + if (!(err instanceof Error)) return '操作失败'; + return err.message.replace(/\b(\d{6})\b/g, (code) => { + const label = districtCodeLabel(code); + return label !== code ? `${label}(${code})` : code; + }); + } + + const loadCities = useCallback(async () => { + const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); + setCities(res.items); + }, []); + + const loadWarehouses = useCallback(async (cityId: string) => { + const rows = await request(`/admin/cities/${cityId}/warehouses`); + setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name }))); + }, []); + + useEffect(() => { + void loadCities(); + }, [loadCities]); + + async function openPartner(id: string) { + const d = await request(`/admin/partners/${id}`); + setDetail(d); + setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); + if (d.cityId) await loadWarehouses(d.cityId); + editForm.setFieldsValue({ + name: d.name, + phone: d.phone, + companyName: d.companyName, + contactPhone: d.contactPhone ?? d.phone, + address: d.address ?? '', + scopeType: d.scopeType, + districtCodes: d.districtCodes ?? [], + orderCommissionRate: (d.orderCommissionRate ?? 0) * 100, + redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100, + bindingStatus: d.bindingStatus, + managedWarehouseId: d.managedWarehouseId, + bankAccountName: d.bankAccountName ?? '', + bankAccountNo: d.bankAccountNo ?? '', + bankBranch: d.bankBranch ?? '', + }); + setDrawerOpen(true); + } + + async function savePartner() { + if (!detail) return; + try { + const v = await editForm.validateFields(); + const body = { + ...v, + orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, + redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, + districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, + }; + await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) }); + message.success('已保存'); + setDrawerOpen(false); + void reload(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); + } + } + + const baseColumns: ColumnsType = [ + { title: '城市', dataIndex: 'cityName', width: 100 }, + { + title: '区县', + dataIndex: 'districtCodes', + width: 160, + render: (codes: string[] | null | undefined, row) => + row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), + }, + { + title: '公司名', + dataIndex: 'companyName', + render: (v, row) => ( + void openPartner(row.id)}>{v} + ), + }, + { title: '主账号', dataIndex: 'phone', width: 130 }, + { + title: '管辖', + dataIndex: 'scopeType', + width: 100, + render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), + }, + { title: '门店', dataIndex: 'storeCount', width: 70 }, + { title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) }, + { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + ), + }, + ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} - + {settingsModal} + 开城合伙人 - {settingsButton} - - -
{ setFilters(v); setPage(1); }}> - - - - -
{ setPage(p); setPageSize(ps); }, - }} - /> - - setDrawerOpen(false)} - extra={} - > - {detail && ( - - - - - - - - ({ value: w.id, label: w.name }))} /> - - - - - - ), - }, - { - key: 'staff', - label: '子账号', - children: ( - <> - -
{s} }, - { - title: '权限', - dataIndex: 'permissions', - render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—', - }, - ]} - /> - - ), - }, - ]} - /> - )} - - - setCreateOpen(false)} - onOk={async () => { - try { - const v = await createForm.validateFields(); - await request('/admin/partners', { - method: 'POST', - body: JSON.stringify({ - ...v, - orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, - redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, - districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, - }), - }); - message.success('已创建'); - setCreateOpen(false); - createForm.resetFields(); - void reload(); - } catch (e) { - const msg = formatApiError(e); - if (msg) message.error(msg); - } - }} - > -
- - - - - - - ({ value: w.id, label: w.name }))} /> - - )} - -
- - setSubOpen(false)} - onOk={async () => { - if (!detail) return; - const v = await subForm.validateFields(); - await request('/admin/partner-accounts', { - method: 'POST', - body: JSON.stringify({ ...v, parentAccountId: detail.id }), - }); - message.success('已创建'); - setSubOpen(false); - void openPartner(detail.id); - }} - > -
- - - - + + + +
{ setPage(p); setPageSize(ps); }, + }} + /> + + setDrawerOpen(false)} + extra={} + > + {detail && ( + + + + + + + + ({ value: w.id, label: w.name }))} /> + + + + + + ), + }, + { + key: 'staff', + label: '子账号', + children: ( + <> + +
{s} }, + { + title: '权限', + dataIndex: 'permissions', + render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—', + }, + ]} + /> + + ), + }, + ]} + /> + )} + + + setCreateOpen(false)} + onOk={async () => { + try { + const v = await createForm.validateFields(); + await request('/admin/partners', { + method: 'POST', + body: JSON.stringify({ + ...v, + orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, + redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, + districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, + }), + }); + message.success('已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); + } + }} + > +
+ + + + + + + ({ value: w.id, label: w.name }))} /> + + )} + +
+ + setSubOpen(false)} + onOk={async () => { + if (!detail) return; + const v = await subForm.validateFields(); + await request('/admin/partner-accounts', { + method: 'POST', + body: JSON.stringify({ ...v, parentAccountId: detail.id }), + }); + message.success('已创建'); + setSubOpen(false); + void openPartner(detail.id); + }} + > +
+ + + + - - - - - - - - -
{ - setPage(p); - setPageSize(ps); - }, - }} - /> - - setDrawerOpen(false)} - extra={ - detail?.status === 'PENDING' && ( - - - - - ) - } - > - {detail && ( - <> - {detail.photoUrl && ( -
- -
- )} - - {detail.pendingNo} - - {detail.redeemToken} - - - - {REDEEM_PENDING_STATUS_LABELS[detail.status]} - - - ¥{detail.amount} - {detail.redeemType} - {detail.failCount} - - {detail.store?.name || '—'}({detail.store?.id}) - - - {detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'} - - - {detail.redeemRecord?.redeemNo || '—'} - - {detail.rejectReason || '—'} - {detail.remark || '—'} - {fmtTime(detail.createdAt)} - - {detail.processedAt ? fmtTime(detail.processedAt) : '—'} - - - - )} -
- - void reject()} - onCancel={() => setRejectOpen(false)} - > - setRejectReason(e.target.value)} - /> - - - ); -} + {settingsButton} + { + setFilters(v); + setPage(1); + }} + > + + + + + + + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + extra={ + detail?.status === 'PENDING' && ( + + + + + ) + } + > + {detail && ( + <> + {detail.photoUrl && ( +
+ +
+ )} + + {detail.pendingNo} + + {detail.redeemToken} + + + + {REDEEM_PENDING_STATUS_LABELS[detail.status]} + + + ¥{detail.amount} + {detail.redeemType} + {detail.failCount} + + {detail.store?.name || '—'}({detail.store?.id}) + + + {detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'} + + + {detail.redeemRecord?.redeemNo || '—'} + + {detail.rejectReason || '—'} + {detail.remark || '—'} + {fmtTime(detail.createdAt)} + + {detail.processedAt ? fmtTime(detail.processedAt) : '—'} + + + + )} +
+ + void reject()} + onCancel={() => setRejectOpen(false)} + > + setRejectReason(e.target.value)} + /> + + + ); +} diff --git a/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx index ab43500..b557f3d 100644 --- a/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx +++ b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx @@ -14,6 +14,7 @@ import DetailImageUrlList from '../components/DetailImageUrlList'; import type { ProductDetailTemplateDto } from '../lib/product-detail-templates'; import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = ProductDetailTemplateDto; @@ -140,7 +141,23 @@ export default function ProductDetailTemplatesPage() { const baseColumns: ColumnsType = [ { title: '编码', dataIndex: 'code', width: 120 }, - { title: '名称', dataIndex: 'name', width: 120 }, + { + title: '名称', + dataIndex: 'name', + width: 120, + render: (v, row) => ( + { + const d = await request>(`/admin/product-detail-templates/${row.id}`); + setDetail(d); + editForm.setFieldsValue(mapToForm(d)); + setDrawerOpen(true); + }} + > + {v} + + ), + }, { title: '说明', dataIndex: 'description', width: 200 }, { title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') }, { title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 }, diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index e345cd8..e81fbf8 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -15,6 +15,7 @@ import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePick import ProductSpecsEditor from '../components/ProductSpecsEditor'; import type { FormInstance } from 'antd/es/form'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type ProductDetailContentDto = { @@ -391,7 +392,14 @@ export default function ProductsPage() { const baseColumns: ColumnsType = useMemo(() => [ { title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v }, - { title: '品名', dataIndex: 'name', width: 200 }, + { title: '品名', dataIndex: 'name', width: 200, render: (v, row) => ( + { + const d = await request>(`/admin/products/${row.id}`); + setDetail(d); + editForm.setFieldsValue(mapDetailToForm(d)); + setDrawerOpen(true); + }}>{v} + ) }, { title: '累计销售', dataIndex: 'soldBottles', diff --git a/apps/admin-web/src/pages/PromoCodesPage.tsx b/apps/admin-web/src/pages/PromoCodesPage.tsx index 0a5818b..f43281c 100644 --- a/apps/admin-web/src/pages/PromoCodesPage.tsx +++ b/apps/admin-web/src/pages/PromoCodesPage.tsx @@ -15,6 +15,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = PromoCodeItem; @@ -77,7 +78,14 @@ export default function PromoCodesPage() { }, []); const baseColumns: ColumnsType = [ - { title: '名称', dataIndex: 'name', width: 160 }, + { + title: '名称', + dataIndex: 'name', + width: 160, + render: (v, row) => ( + navigate(`/promo-codes/${row.id}`)}>{v} + ), + }, { title: '码值', dataIndex: 'code', width: 110 }, { title: '场景', diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx index 4a70e76..51fc5d5 100644 --- a/apps/admin-web/src/pages/RedeemRecordsPage.tsx +++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx @@ -8,6 +8,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -84,7 +85,7 @@ export default function RedeemRecordsPage() { width: 200, render: (v, row) => ( - {v} + void openDetail(row.id)}>{v} {row.isTest ? ( 测试 diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index c8038af..eae55a8 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -7,6 +7,7 @@ import { request, type Paginated } from '../lib/api'; import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type StoreBrief = { id: string; name: string; status: string; cityName?: string }; @@ -83,7 +84,14 @@ export default function StoreAccountsPage() { width: 120, render: (v, row) => ( - {v} + { + setDetail(await request(`/admin/store-accounts/${row.id}`)); + setDrawerOpen(true); + }} + > + {v} + {row.isTest ? 测试 : null} ), diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index aec8278..e498147 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -1,688 +1,689 @@ -import { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { - Button, - Card, - DatePicker, - Descriptions, - Drawer, - Form, - Input, - Modal, - Select, - Space, - Statistic, - Table, - Tag, - Typography, - message, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import type { Dayjs } from 'dayjs'; -import { - STORE_SETTLEMENT_KIND_LABELS, - STORE_SETTLEMENT_STATUS_LABELS, - storeSettlementStatusLabel, - type StoreSettlementKind, - type StoreSettlementStatus, -} from '@dukang/shared-types'; -import { request, type Paginated } from '../lib/api'; -import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; -import { downloadExcelCsv } from '../lib/exportExcel'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + Button, + Card, + DatePicker, + Descriptions, + Drawer, + Form, + Input, + Modal, + Select, + Space, + Statistic, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { Dayjs } from 'dayjs'; +import { + STORE_SETTLEMENT_KIND_LABELS, + STORE_SETTLEMENT_STATUS_LABELS, + storeSettlementStatusLabel, + type StoreSettlementKind, + type StoreSettlementStatus, +} from '@dukang/shared-types'; +import { request, type Paginated } from '../lib/api'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { downloadExcelCsv } from '../lib/exportExcel'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; - - -type Row = { - kind: StoreSettlementKind; - id: string; - billNo: string; - amount: number; - status: StoreSettlementStatus; - date: string; - overdue?: boolean; - redeemCount?: number | null; - redeemAmount?: number | null; - settlementRate?: number | null; - payoutCount?: number | null; - store?: { id: string; name: string; cityName: string; phone?: string | null }; - bankAccount?: { - bankAccountName?: string | null; - bankAccountNo?: string | null; - bankBranch?: string | null; - } | null; -}; - -type StoreOption = { id: string; name: string; phone: string }; - -const STATUS_COLORS: Record = { - UNPAID: 'red', - PAID: 'green', - PENDING_REVIEW: 'orange', - REJECTED: 'red', -}; - -function settlementStatusColor(kind: StoreSettlementKind, status: string) { - if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green'; - return STATUS_COLORS[status] || 'default'; -} - -const KIND_COLORS: Record = { - T1_BILL: 'blue', - WITHDRAW: 'purple', -}; - -export default function StoreBillsPage() { - const [searchParams] = useSearchParams(); - const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; - const initialStoreId = searchParams.get('storeId') || ''; - const initialStatus = - searchParams.get('status')?.trim() || - (initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : ''); - const [form] = Form.useForm(); - const [filters, setFilters] = useState>({ - kind: initialKind, - status: initialStatus, - storeId: initialStoreId, - }); - const [stores, setStores] = useState([]); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/store-settlements', - () => { - const qs = new URLSearchParams(); - if (filters.kind) qs.set('kind', filters.kind); - if (filters.status) qs.set('status', filters.status); - if (filters.storeId) qs.set('storeId', filters.storeId); - if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); - if (filters.dateTo) qs.set('dateTo', filters.dateTo); - return qs; - }, - [filters], - ); - const [detail, setDetail] = useState | null>(null); - const [detailKind, setDetailKind] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - const [exporting, setExporting] = useState(false); - const [selectedKeys, setSelectedKeys] = useState([]); - const [batchLoading, setBatchLoading] = useState(false); - const [overdueSummary, setOverdueSummary] = useState<{ - pendingCount: number; - overdueCount: number; - } | null>(null); - - useEffect(() => { - form.setFieldsValue({ - kind: filters.kind || undefined, - status: filters.status || undefined, - storeId: filters.storeId || undefined, - }); - }, [filters.kind, filters.status, filters.storeId, form]); - - useEffect(() => { - void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) - .then((res) => setStores(res.items)) - .catch(() => {}); - void request<{ pendingCount: number; overdueCount: number }>( - '/admin/store-withdrawals/overdue-summary', - ) - .then(setOverdueSummary) - .catch(() => {}); - }, []); - - function confirmPay(ids: string[], amountHint?: number) { - let paymentRef = ''; - Modal.confirm({ - title: '确认打款?', - content: ( -
-
- 将确认 {ids.length} 笔 T+1 门店对账单 - {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。 -
- { - paymentRef = e.target.value; - }} - /> -
- ), - okText: '确认打款', - cancelText: '取消', - onOk: async () => { - const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined }); - if (ids.length === 1) { - await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body }); - } else { - setBatchLoading(true); - try { - await request('/admin/store-bills/batch-confirm', { - method: 'POST', - body: JSON.stringify({ ids }), - }); - } finally { - setBatchLoading(false); - } - } - message.success('已确认打款'); - setSelectedKeys([]); - reload(); - }, - }); - } - - function approveWithdraw(id: string) { - let paymentRef = ''; - Modal.confirm({ - title: '审核通过并标记已结算?', - content: ( - { - paymentRef = e.target.value; - }} - /> - ), - okText: '通过并结算', - cancelText: '取消', - onOk: async () => { - await request(`/admin/store-withdrawals/${id}/approve`, { - method: 'POST', - body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }), - }); - message.success('已通过并标记已结算'); - setDrawerOpen(false); - reload(); - void request<{ pendingCount: number; overdueCount: number }>( - '/admin/store-withdrawals/overdue-summary', - ) - .then(setOverdueSummary) - .catch(() => {}); - }, - }); - } - - function rejectWithdraw(id: string) { - let reason = ''; - Modal.confirm({ - title: '驳回提现申请?', - content: ( - { - reason = e.target.value; - }} - /> - ), - okText: '确认驳回', - okButtonProps: { danger: true }, - cancelText: '取消', - onOk: async () => { - if (!reason.trim()) { - message.error('请填写驳回理由'); - throw new Error('reason required'); - } - await request(`/admin/store-withdrawals/${id}/reject`, { - method: 'POST', - body: JSON.stringify({ reason: reason.trim() }), - }); - message.success('已驳回'); - setDrawerOpen(false); - reload(); - }, - }); - } - - async function openDetail(row: Row) { - const path = - row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`; - const d = await request>(path); - setDetail(d); - setDetailKind(row.kind); - setDrawerOpen(true); - } - - async function exportExcel() { - setExporting(true); - try { - const qs = new URLSearchParams(); - if (filters.status) qs.set('status', filters.status); - if (filters.storeId) qs.set('storeId', filters.storeId); - if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); - if (filters.dateTo) qs.set('dateTo', filters.dateTo); - const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); - downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); - message.success(`已导出 ${result.count} 条 T+1 账单`); - } finally { - setExporting(false); - } - } - - const summary = data?.summary; - const selectedRows = (data?.items ?? []).filter( - (r) => - selectedKeys.includes(`${r.kind}-${r.id}`) && - r.kind === 'T1_BILL' && - r.status === 'UNPAID', - ); - const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0); - - const baseColumns: ColumnsType = [ - { - title: '类型', - dataIndex: 'kind', - width: 100, - render: (k: StoreSettlementKind) => ( - - {STORE_SETTLEMENT_KIND_LABELS[k] ?? k} - - ), - }, - { - title: '单号', - dataIndex: 'billNo', - width: 180, - render: (v, row) => ( - - {v} - {row.overdue ? 超时 : null} - - ), - }, - { - title: '日期', - dataIndex: 'date', - width: 160, - render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), - }, - { title: '门店', dataIndex: ['store', 'name'], width: 140 }, - { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, - { title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, - { - title: '收款户名', - width: 100, - render: (_, row) => - row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—', - }, - { - title: '收款账号', - width: 140, - render: (_, row) => - row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—', - }, - { - title: '笔数', - width: 80, - render: (_, row) => - row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'), - }, - { - title: '应付金额', - dataIndex: 'amount', - width: 110, - render: (v) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '状态', - dataIndex: 'status', - width: 100, - render: (s: string, row) => ( - - {storeSettlementStatusLabel(row.kind, s)} - - ), - }, - { - title: '操作', - width: 200, - fixed: 'right', - render: (_, row) => ( - - - {row.kind === 'T1_BILL' && row.status === 'UNPAID' && ( - - )} - {row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && ( - <> - - - - )} - - ), - }, - ]; - - const withdrawDetailItems = - (detail?.items as Array> | undefined) ?? []; - const storeAccount = detail?.storeAccount as - | { - name?: string; - phone?: string; - bankAccountName?: string; - bankAccountNo?: string; - bankBranch?: string; - } - | undefined; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type Row = { + kind: StoreSettlementKind; + id: string; + billNo: string; + amount: number; + status: StoreSettlementStatus; + date: string; + overdue?: boolean; + redeemCount?: number | null; + redeemAmount?: number | null; + settlementRate?: number | null; + payoutCount?: number | null; + store?: { id: string; name: string; cityName: string; phone?: string | null }; + bankAccount?: { + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + } | null; +}; + +type StoreOption = { id: string; name: string; phone: string }; + +const STATUS_COLORS: Record = { + UNPAID: 'red', + PAID: 'green', + PENDING_REVIEW: 'orange', + REJECTED: 'red', +}; + +function settlementStatusColor(kind: StoreSettlementKind, status: string) { + if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green'; + return STATUS_COLORS[status] || 'default'; +} + +const KIND_COLORS: Record = { + T1_BILL: 'blue', + WITHDRAW: 'purple', +}; + +export default function StoreBillsPage() { + const [searchParams] = useSearchParams(); + const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; + const initialStoreId = searchParams.get('storeId') || ''; + const initialStatus = + searchParams.get('status')?.trim() || + (initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : ''); + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ + kind: initialKind, + status: initialStatus, + storeId: initialStoreId, + }); + const [stores, setStores] = useState([]); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/store-settlements', + () => { + const qs = new URLSearchParams(); + if (filters.kind) qs.set('kind', filters.kind); + if (filters.status) qs.set('status', filters.status); + if (filters.storeId) qs.set('storeId', filters.storeId); + if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); + if (filters.dateTo) qs.set('dateTo', filters.dateTo); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState | null>(null); + const [detailKind, setDetailKind] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [exporting, setExporting] = useState(false); + const [selectedKeys, setSelectedKeys] = useState([]); + const [batchLoading, setBatchLoading] = useState(false); + const [overdueSummary, setOverdueSummary] = useState<{ + pendingCount: number; + overdueCount: number; + } | null>(null); + + useEffect(() => { + form.setFieldsValue({ + kind: filters.kind || undefined, + status: filters.status || undefined, + storeId: filters.storeId || undefined, + }); + }, [filters.kind, filters.status, filters.storeId, form]); + + useEffect(() => { + void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setStores(res.items)) + .catch(() => {}); + void request<{ pendingCount: number; overdueCount: number }>( + '/admin/store-withdrawals/overdue-summary', + ) + .then(setOverdueSummary) + .catch(() => {}); + }, []); + + function confirmPay(ids: string[], amountHint?: number) { + let paymentRef = ''; + Modal.confirm({ + title: '确认打款?', + content: ( +
+
+ 将确认 {ids.length} 笔 T+1 门店对账单 + {amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。 +
+ { + paymentRef = e.target.value; + }} + /> +
+ ), + okText: '确认打款', + cancelText: '取消', + onOk: async () => { + const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined }); + if (ids.length === 1) { + await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body }); + } else { + setBatchLoading(true); + try { + await request('/admin/store-bills/batch-confirm', { + method: 'POST', + body: JSON.stringify({ ids }), + }); + } finally { + setBatchLoading(false); + } + } + message.success('已确认打款'); + setSelectedKeys([]); + reload(); + }, + }); + } + + function approveWithdraw(id: string) { + let paymentRef = ''; + Modal.confirm({ + title: '审核通过并标记已结算?', + content: ( + { + paymentRef = e.target.value; + }} + /> + ), + okText: '通过并结算', + cancelText: '取消', + onOk: async () => { + await request(`/admin/store-withdrawals/${id}/approve`, { + method: 'POST', + body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }), + }); + message.success('已通过并标记已结算'); + setDrawerOpen(false); + reload(); + void request<{ pendingCount: number; overdueCount: number }>( + '/admin/store-withdrawals/overdue-summary', + ) + .then(setOverdueSummary) + .catch(() => {}); + }, + }); + } + + function rejectWithdraw(id: string) { + let reason = ''; + Modal.confirm({ + title: '驳回提现申请?', + content: ( + { + reason = e.target.value; + }} + /> + ), + okText: '确认驳回', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + if (!reason.trim()) { + message.error('请填写驳回理由'); + throw new Error('reason required'); + } + await request(`/admin/store-withdrawals/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ reason: reason.trim() }), + }); + message.success('已驳回'); + setDrawerOpen(false); + reload(); + }, + }); + } + + async function openDetail(row: Row) { + const path = + row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`; + const d = await request>(path); + setDetail(d); + setDetailKind(row.kind); + setDrawerOpen(true); + } + + async function exportExcel() { + setExporting(true); + try { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.storeId) qs.set('storeId', filters.storeId); + if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); + if (filters.dateTo) qs.set('dateTo', filters.dateTo); + const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); + downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); + message.success(`已导出 ${result.count} 条 T+1 账单`); + } finally { + setExporting(false); + } + } + + const summary = data?.summary; + const selectedRows = (data?.items ?? []).filter( + (r) => + selectedKeys.includes(`${r.kind}-${r.id}`) && + r.kind === 'T1_BILL' && + r.status === 'UNPAID', + ); + const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0); + + const baseColumns: ColumnsType = [ + { + title: '类型', + dataIndex: 'kind', + width: 100, + render: (k: StoreSettlementKind) => ( + + {STORE_SETTLEMENT_KIND_LABELS[k] ?? k} + + ), + }, + { + title: '单号', + dataIndex: 'billNo', + width: 180, + render: (v, row) => ( + + void openDetail(row)}>{v} + {row.overdue ? 超时 : null} + + ), + }, + { + title: '日期', + dataIndex: 'date', + width: 160, + render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), + }, + { title: '门店', dataIndex: ['store', 'name'], width: 140 }, + { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, + { title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, + { + title: '收款户名', + width: 100, + render: (_, row) => + row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—', + }, + { + title: '收款账号', + width: 140, + render: (_, row) => + row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—', + }, + { + title: '笔数', + width: 80, + render: (_, row) => + row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'), + }, + { + title: '应付金额', + dataIndex: 'amount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + render: (s: string, row) => ( + + {storeSettlementStatusLabel(row.kind, s)} + + ), + }, + { + title: '操作', + width: 200, + fixed: 'right', + render: (_, row) => ( + + + {row.kind === 'T1_BILL' && row.status === 'UNPAID' && ( + + )} + {row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && ( + <> + + + + )} + + ), + }, + ]; + + const withdrawDetailItems = + (detail?.items as Array> | undefined) ?? []; + const storeAccount = detail?.storeAccount as + | { + name?: string; + phone?: string; + bankAccountName?: string; + bankAccountNo?: string; + bankBranch?: string; + } + | undefined; + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} - - - 门店账单 + {settingsModal} + + + 门店账单 - {settingsButton} - - 含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。 - 「已打款」= T+1 出账终态,「已结算」= 手动提现终态。 - - {overdueSummary && overdueSummary.overdueCount > 0 ? ( - - 手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount}) - - ) : null} - - - {summary && ( - - - - - - - - )} - - { - setFilters({ - kind: v.kind || '', - storeId: v.storeId || '', - status: v.status || '', - dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', - dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', - }); - setPage(1); - setSelectedKeys([]); - }} - > - - ({ value: s.id, label: s.name || s.phone || s.id }))} - /> - - -
`${r.kind}-${r.id}`} - className="admin-table-nowrap" - loading={loading} - columns={columns} - dataSource={data?.items ?? []} - rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')} - rowSelection={{ - selectedRowKeys: selectedKeys, - onChange: setSelectedKeys, - getCheckboxProps: (r) => ({ - disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'), - }), - }} - scroll={{ x: 'max-content' }} - pagination={{ - current: page, - pageSize, - total: data?.total ?? 0, - showSizeChanger: true, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, - }} - /> - - setDrawerOpen(false)} - width={detailKind === 'WITHDRAW' ? 640 : 520} - > - {detail && detailKind === 'T1_BILL' && ( - <> - - {String(detail.billNo)} - - {String(detail.billDate || '').slice(0, 10)} - - - ¥{Number(detail.payoutAmount ?? 0).toFixed(2)} - - - {storeSettlementStatusLabel('T1_BILL', String(detail.status))} - - - {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} - - - {detail.paymentRef ? String(detail.paymentRef) : '—'} - - - {(detail.storeAccount as { - bankAccountName?: string; - bankAccountNo?: string; - bankBranch?: string; - } | null) ? ( - <> - - 收款账户 - - - - {(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'} - - - {(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'} - - - {(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'} - - - - ) : null} - - 核销明细 - -
>) || []} - columns={[ - { - title: '核销单号', - render: (_, r) => - String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), - }, - { - title: '金额', - dataIndex: 'payoutAmount', - render: (v) => `¥${Number(v).toFixed(2)}`, - }, - { - title: '状态', - dataIndex: 'status', - render: (s) => (s === 'PAID' ? '已打款' : '未打款'), - }, - ]} - /> - - )} - {detail && detailKind === 'WITHDRAW' && ( - <> - - {String(detail.withdrawNo)} - - ¥{Number(detail.amount ?? 0).toFixed(2)} - - - {storeSettlementStatusLabel('WITHDRAW', String(detail.status))} - - - {detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'} - - - {detail.rejectReason ? String(detail.rejectReason) : '—'} - - - {detail.paymentRef ? String(detail.paymentRef) : '—'} - - - {storeAccount ? ( - <> - - 收款账户 - - - - {storeAccount.bankAccountName || '—'} - - - {storeAccount.bankAccountNo || '—'} - - - {storeAccount.bankBranch || '—'} - - - - ) : null} - - 提现明细 - -
{ - const payout = r.storePayout as - | { redeemRecord?: { redeemNo?: string }; payoutAmount?: number } - | undefined; - return String(payout?.redeemRecord?.redeemNo || '—'); - }, - }, - { - title: '应付', - render: (_, r) => { - const payout = r.storePayout as { payoutAmount?: number } | undefined; - return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`; - }, - }, - ]} - /> - {String(detail.status) === 'PENDING_REVIEW' ? ( - - - - - ) : null} - - )} - - - ); -} + {settingsButton} + + 含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。 + 「已打款」= T+1 出账终态,「已结算」= 手动提现终态。 + + {overdueSummary && overdueSummary.overdueCount > 0 ? ( + + 手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount}) + + ) : null} + + + {summary && ( + + + + + + + + )} + + { + setFilters({ + kind: v.kind || '', + storeId: v.storeId || '', + status: v.status || '', + dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', + dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', + }); + setPage(1); + setSelectedKeys([]); + }} + > + + ({ value: s.id, label: s.name || s.phone || s.id }))} + /> + + +
`${r.kind}-${r.id}`} + className="admin-table-nowrap" + loading={loading} + columns={columns} + dataSource={data?.items ?? []} + rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')} + rowSelection={{ + selectedRowKeys: selectedKeys, + onChange: setSelectedKeys, + getCheckboxProps: (r) => ({ + disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'), + }), + }} + scroll={{ x: 'max-content' }} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + width={detailKind === 'WITHDRAW' ? 640 : 520} + > + {detail && detailKind === 'T1_BILL' && ( + <> + + {String(detail.billNo)} + + {String(detail.billDate || '').slice(0, 10)} + + + ¥{Number(detail.payoutAmount ?? 0).toFixed(2)} + + + {storeSettlementStatusLabel('T1_BILL', String(detail.status))} + + + {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} + + + {detail.paymentRef ? String(detail.paymentRef) : '—'} + + + {(detail.storeAccount as { + bankAccountName?: string; + bankAccountNo?: string; + bankBranch?: string; + } | null) ? ( + <> + + 收款账户 + + + + {(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'} + + + {(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'} + + + {(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'} + + + + ) : null} + + 核销明细 + +
>) || []} + columns={[ + { + title: '核销单号', + render: (_, r) => + String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'), + }, + { + title: '金额', + dataIndex: 'payoutAmount', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + render: (s) => (s === 'PAID' ? '已打款' : '未打款'), + }, + ]} + /> + + )} + {detail && detailKind === 'WITHDRAW' && ( + <> + + {String(detail.withdrawNo)} + + ¥{Number(detail.amount ?? 0).toFixed(2)} + + + {storeSettlementStatusLabel('WITHDRAW', String(detail.status))} + + + {detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'} + + + {detail.rejectReason ? String(detail.rejectReason) : '—'} + + + {detail.paymentRef ? String(detail.paymentRef) : '—'} + + + {storeAccount ? ( + <> + + 收款账户 + + + + {storeAccount.bankAccountName || '—'} + + + {storeAccount.bankAccountNo || '—'} + + + {storeAccount.bankBranch || '—'} + + + + ) : null} + + 提现明细 + +
{ + const payout = r.storePayout as + | { redeemRecord?: { redeemNo?: string }; payoutAmount?: number } + | undefined; + return String(payout?.redeemRecord?.redeemNo || '—'); + }, + }, + { + title: '应付', + render: (_, r) => { + const payout = r.storePayout as { payoutAmount?: number } | undefined; + return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`; + }, + }, + ]} + /> + {String(detail.status) === 'PENDING_REVIEW' ? ( + + + + + ) : null} + + )} + + + ); +} diff --git a/apps/admin-web/src/pages/StoreCategoriesPage.tsx b/apps/admin-web/src/pages/StoreCategoriesPage.tsx index c5f6749..cc43073 100644 --- a/apps/admin-web/src/pages/StoreCategoriesPage.tsx +++ b/apps/admin-web/src/pages/StoreCategoriesPage.tsx @@ -5,6 +5,7 @@ import { import type { ColumnsType } from 'antd/es/table'; import { request, type HqProfile } from '../lib/api'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type CategoryNode = { @@ -130,7 +131,7 @@ export default function StoreCategoriesPage() { render: (name, row) => ( {row.level === 2 ? `${row.parentName || ''} / ` : ''} - {name} + openEdit(row)}>{name} ), }, diff --git a/apps/admin-web/src/pages/StoreLogsPage.tsx b/apps/admin-web/src/pages/StoreLogsPage.tsx index d4a4151..e6885f8 100644 --- a/apps/admin-web/src/pages/StoreLogsPage.tsx +++ b/apps/admin-web/src/pages/StoreLogsPage.tsx @@ -13,6 +13,7 @@ import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -103,7 +104,23 @@ export default function StoreLogsPage() { {STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'} ), }, - { title: '事件', dataIndex: 'eventName', width: 160 }, + { + title: '事件', + dataIndex: 'eventName', + width: 160, + render: (v, row) => ( + { + const parsed = parseCompositeId(row.id); + if (!parsed) return; + setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`)); + setDrawerOpen(true); + }} + > + {v} + + ), + }, { title: '来源', dataIndex: 'source', width: 100, render: (v: Row['source']) => SOURCE_LABELS[v] || v, diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx index 961b7b7..2ba93d8 100644 --- a/apps/admin-web/src/pages/StoreMediaPage.tsx +++ b/apps/admin-web/src/pages/StoreMediaPage.tsx @@ -8,6 +8,7 @@ import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/cons import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -43,7 +44,22 @@ export default function StoreMediaPage() { } const baseColumns: ColumnsType = [ - { title: '门店', dataIndex: ['store', 'name'], width: 140 }, + { + title: '门店', + dataIndex: ['store', 'name'], + width: 140, + render: (v, row) => ( + { + setEditing(row); + editForm.setFieldsValue(row); + setEditOpen(true); + }} + > + {v} + + ), + }, { title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => {MEDIA_TYPE_LABELS[t] || t} }, { title: '预览', dataIndex: 'url', width: 100, diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index 970ce0b..68cfb15 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -33,6 +33,7 @@ import { notifyPackageAuditChanged } from '../lib/admin-events'; import { fmtTime } from '../lib/constants'; import StorePackageAuditPanel from '../components/StorePackageAuditPanel'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; const HQ_PACKAGE_STATUS_LABELS: Record = { PENDING: '待审核', @@ -199,7 +200,15 @@ function InfoChangeAuditPanel({ } const columns: ColumnsType = [ - { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, + { + title: '门店', + dataIndex: 'storeName', + render: (_, row) => ( + void openDetail(row.id)}> + {row.storeName || row.storeId} + + ), + }, { title: '状态', dataIndex: 'status', @@ -471,7 +480,15 @@ export default function StorePackageAuditsPage() { } const baseColumns: ColumnsType = [ - { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, + { + title: '门店', + dataIndex: 'storeName', + render: (_, row) => ( + void openDetail(row.id)}> + {row.storeName || row.storeId} + + ), + }, { title: '状态', dataIndex: 'status', diff --git a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx index 0c61cd4..c1faba7 100644 --- a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx +++ b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx @@ -21,6 +21,7 @@ import { request, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -156,7 +157,7 @@ export default function StoreWithdrawalsPage() { width: 180, render: (v, row) => ( - void openDetail(row.id)}>{v} + void openDetail(row.id)}>{v} {row.overdue ? 超时 : null} ), diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 0c86f55..25952c2 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -40,6 +40,7 @@ import { } from '../lib/storeCreate'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { resolveRegionBinding } from '../lib/china-region'; import ChinaRegionCascader from '../components/ChinaRegionCascader'; import OssUpload from '../components/OssUpload'; @@ -739,7 +740,7 @@ export default function StoresPage() { const name = v || '—'; return ( - {name} + void openStoreDetail(row)}>{name === '—' ? '' : name} {row.isTest ? 测试 : null} ); diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index 0440c56..2ff42ee 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -47,6 +47,7 @@ import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; @@ -505,7 +506,13 @@ export default function SupportTicketsPage() { ), }, - { title: '标题', dataIndex: 'title' }, + { + title: '标题', + dataIndex: 'title', + render: (v, row) => ( + void openDetail(String(row.id))}>{v} + ), + }, { title: '创建人', dataIndex: 'creatorName', width: 100 }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { diff --git a/apps/admin-web/src/pages/TestWhitelistPage.tsx b/apps/admin-web/src/pages/TestWhitelistPage.tsx index 00b9d72..346b102 100644 --- a/apps/admin-web/src/pages/TestWhitelistPage.tsx +++ b/apps/admin-web/src/pages/TestWhitelistPage.tsx @@ -21,6 +21,7 @@ import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const; @@ -254,7 +255,22 @@ export default function TestWhitelistPage() { } const phoneColumns: ColumnsType = [ - { title: '手机号', dataIndex: 'phone', width: 140 }, + { + title: '手机号', + dataIndex: 'phone', + width: 140, + render: (v, row) => ( + { + setEditRow(row); + editForm.setFieldsValue({ note: row.note ?? '' }); + setEditOpen(true); + }} + > + {v} + + ), + }, { title: '备注', dataIndex: 'note', diff --git a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx index 9f99c31..a24f618 100644 --- a/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx +++ b/apps/admin-web/src/pages/ThirdPartyLogsPage.tsx @@ -5,6 +5,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -95,7 +96,21 @@ export default function ThirdPartyLogsPage() { title: '请求摘要', render: (_, r) => summarizeJson(r.requestBody), }, - { title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' }, + { + title: '外部单号', + dataIndex: 'externalNo', + width: 140, + render: (v, row) => ( + { + setDetail(await request(`/common/third-party-logs/${row.id}`)); + setDrawerOpen(true); + }} + > + {v} + + ), + }, { title: '操作', width: 80, diff --git a/apps/admin-web/src/pages/TicketsPage.tsx b/apps/admin-web/src/pages/TicketsPage.tsx index 1eb9d86..f16232b 100644 --- a/apps/admin-web/src/pages/TicketsPage.tsx +++ b/apps/admin-web/src/pages/TicketsPage.tsx @@ -26,6 +26,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -118,7 +119,21 @@ export default function TicketsPage() { } const baseColumns: ColumnsType = [ - { title: '工单号', dataIndex: 'ticketNo', width: 180 }, + { + title: '工单号', + dataIndex: 'ticketNo', + width: 180, + render: (v, row) => ( + { + setDetail(await request(`/admin/tickets/${row.id}`)); + setDrawerOpen(true); + }} + > + {v} + + ), + }, { title: '类型', dataIndex: 'ticketType', diff --git a/apps/admin-web/src/pages/UserLogsPage.tsx b/apps/admin-web/src/pages/UserLogsPage.tsx index 8aabb6b..d627cdc 100644 --- a/apps/admin-web/src/pages/UserLogsPage.tsx +++ b/apps/admin-web/src/pages/UserLogsPage.tsx @@ -8,6 +8,7 @@ import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -80,7 +81,21 @@ export default function UserLogsPage() { {CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'} ), }, - { title: '事件', dataIndex: 'eventName', width: 160 }, + { + title: '事件', + dataIndex: 'eventName', + width: 160, + render: (v, row) => ( + { + setDetail(await request(`/admin/logs/users/${row.id}`)); + setDrawerOpen(true); + }} + > + {v} + + ), + }, { title: '关联', width: 120, render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 9678042..9085cb5 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -20,6 +20,7 @@ import type { ColumnsType } from 'antd/es/table'; import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; type UserOrderRow = { @@ -332,7 +333,9 @@ export default function UsersPage() { title: '昵称', dataIndex: 'nickname', width: 140, - render: (v: string | null) => v || '—', + render: (v: string | null, row) => ( + void openDetail(row.id)}>{v} + ), }, { title: '备注', diff --git a/apps/admin-web/src/pages/WechatBindingsPage.tsx b/apps/admin-web/src/pages/WechatBindingsPage.tsx index 1fd27d5..e64b506 100644 --- a/apps/admin-web/src/pages/WechatBindingsPage.tsx +++ b/apps/admin-web/src/pages/WechatBindingsPage.tsx @@ -1,318 +1,321 @@ -import { useEffect, useState } from 'react'; -import { - Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { request } from '../lib/api'; -import { AdminCellLine } from '../components/AdminCellLine'; -import { fmtTime, maskPhone } from '../lib/constants'; +import { useEffect, useState } from 'react'; +import { + Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; +import { fmtTime, maskPhone } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; - - -type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; - -type Identity = { - actorType: ActorType; - actorId: string; - phone: string | null; - name: string | null; - wxOpenId: string; - wxUnionId: string | null; - phoneVerified?: boolean; - refLabel: string | null; - refId: string | null; - lastLoginAt: string | null; - status: string | number; -}; - -type GroupRow = { - groupKey: string; - unionId: string | null; - identityCount: number; - actorTypes: ActorType[]; - multiRole: boolean; - primaryPhone: string | null; - latestLoginAt: string | null; - identities: Identity[]; -}; - -const ACTOR_TYPE_LABELS: Record = { - USER: 'C 端用户', - STORE: '门店账号', - PARTNER: '合伙人账号', - HQ: 'HQ 账号', -}; - -const ACTOR_TYPE_COLORS: Record = { - USER: 'blue', - STORE: 'green', - PARTNER: 'orange', - HQ: 'purple', -}; - -function renderActorTags(types: ActorType[]) { - return types.map((t) => ( - - {ACTOR_TYPE_LABELS[t]} - - )); -} - -export default function WechatBindingsPage() { - const [form] = Form.useForm(); - const [filters, setFilters] = useState>({ - actorType: '', - phone: '', - unionId: '', - openId: '', - }); - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/wechat-bindings', - () => { - const qs = new URLSearchParams(); - if (filters.actorType) qs.set('actorType', filters.actorType); - if (filters.phone) qs.set('phone', filters.phone); - if (filters.unionId) qs.set('unionId', filters.unionId); - if (filters.openId) qs.set('openId', filters.openId); - return qs; - }, - [filters], - ); - const [detail, setDetail] = useState(null); - const [drawerOpen, setDrawerOpen] = useState(false); - - useEffect(() => { - form.setFieldsValue(filters); - }, [form, filters]); - - async function openDetail(row: GroupRow) { - const res = await request(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`); - setDetail(res); - setDrawerOpen(true); - } - - const baseColumns: ColumnsType = [ - { - title: 'unionId', - dataIndex: 'unionId', - width: 180, - render: (v) => v || 无 unionId, - }, - { - title: '身份数', - dataIndex: 'identityCount', - width: 90, - render: (v, r) => ( - - {v} - {r.multiRole ? 一人多角色 : null} - - ), - }, - { - title: '端类型', - dataIndex: 'actorTypes', - width: 220, - render: (types: ActorType[]) => renderActorTags(types), - }, - { - title: '手机号', - dataIndex: 'primaryPhone', - width: 140, - render: (v) => maskPhone(v), - }, - { - title: '身份摘要', - render: (_, r) => ( - ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} - secondary={r.identities - .map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : '')) - .filter(Boolean) - .join(' · ')} - /> - ), - }, - { - title: '最近登录', - dataIndex: 'latestLoginAt', - width: 160, - render: fmtTime, - }, - { - title: '操作', - width: 80, - render: (_, row) => ( - - ), - }, - ]; - - const identityColumns: ColumnsType = [ - { - title: '端类型', - dataIndex: 'actorType', - width: 120, - render: (t: ActorType) => {ACTOR_TYPE_LABELS[t]}, - }, - { - title: '账号', - render: (_, r) => ( - - ), - }, - { - title: '归属', - dataIndex: 'refLabel', - width: 160, - render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), - }, - { - title: 'wxOpenId', - dataIndex: 'wxOpenId', - width: 160, - }, - { - title: '手机验证', - width: 90, - render: (_, r) => - r.actorType === 'USER' ? ( - r.phoneVerified ? 已验证 : 未验证 - ) : ( - '—' - ), - }, - { - title: '最近登录', - dataIndex: 'lastLoginAt', - width: 160, - render: fmtTime, - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (v) => String(v), - }, - ]; - +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; + + +type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; + +type Identity = { + actorType: ActorType; + actorId: string; + phone: string | null; + name: string | null; + wxOpenId: string; + wxUnionId: string | null; + phoneVerified?: boolean; + refLabel: string | null; + refId: string | null; + lastLoginAt: string | null; + status: string | number; +}; + +type GroupRow = { + groupKey: string; + unionId: string | null; + identityCount: number; + actorTypes: ActorType[]; + multiRole: boolean; + primaryPhone: string | null; + latestLoginAt: string | null; + identities: Identity[]; +}; + +const ACTOR_TYPE_LABELS: Record = { + USER: 'C 端用户', + STORE: '门店账号', + PARTNER: '合伙人账号', + HQ: 'HQ 账号', +}; + +const ACTOR_TYPE_COLORS: Record = { + USER: 'blue', + STORE: 'green', + PARTNER: 'orange', + HQ: 'purple', +}; + +function renderActorTags(types: ActorType[]) { + return types.map((t) => ( + + {ACTOR_TYPE_LABELS[t]} + + )); +} + +export default function WechatBindingsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ + actorType: '', + phone: '', + unionId: '', + openId: '', + }); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/wechat-bindings', + () => { + const qs = new URLSearchParams(); + if (filters.actorType) qs.set('actorType', filters.actorType); + if (filters.phone) qs.set('phone', filters.phone); + if (filters.unionId) qs.set('unionId', filters.unionId); + if (filters.openId) qs.set('openId', filters.openId); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + form.setFieldsValue(filters); + }, [form, filters]); + + async function openDetail(row: GroupRow) { + const res = await request(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`); + setDetail(res); + setDrawerOpen(true); + } + + const baseColumns: ColumnsType = [ + { + title: 'unionId', + dataIndex: 'unionId', + width: 180, + render: (v) => v || 无 unionId, + }, + { + title: '身份数', + dataIndex: 'identityCount', + width: 90, + render: (v, r) => ( + + {v} + {r.multiRole ? 一人多角色 : null} + + ), + }, + { + title: '端类型', + dataIndex: 'actorTypes', + width: 220, + render: (types: ActorType[]) => renderActorTags(types), + }, + { + title: '手机号', + dataIndex: 'primaryPhone', + width: 140, + render: (v, row) => ( + void openDetail(row)}>{maskPhone(v)} + ), + }, + { + title: '身份摘要', + render: (_, r) => ( + ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} + secondary={r.identities + .map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : '')) + .filter(Boolean) + .join(' · ')} + /> + ), + }, + { + title: '最近登录', + dataIndex: 'latestLoginAt', + width: 160, + render: fmtTime, + }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + const identityColumns: ColumnsType = [ + { + title: '端类型', + dataIndex: 'actorType', + width: 120, + render: (t: ActorType) => {ACTOR_TYPE_LABELS[t]}, + }, + { + title: '账号', + render: (_, r) => ( + + ), + }, + { + title: '归属', + dataIndex: 'refLabel', + width: 160, + render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), + }, + { + title: 'wxOpenId', + dataIndex: 'wxOpenId', + width: 160, + }, + { + title: '手机验证', + width: 90, + render: (_, r) => + r.actorType === 'USER' ? ( + r.phoneVerified ? 已验证 : 未验证 + ) : ( + '—' + ), + }, + { + title: '最近登录', + dataIndex: 'lastLoginAt', + width: 160, + render: fmtTime, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (v) => String(v), + }, + ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize }); - return ( + return (
- {settingsModal} + {settingsModal} 微信绑定总览 - {settingsButton} - - 按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。 - - - { - setPage(1); - setFilters({ - actorType: values.actorType ?? '', - phone: values.phone?.trim() ?? '', - unionId: values.unionId?.trim() ?? '', - openId: values.openId?.trim() ?? '', - }); - }} - > - - - - - - - - - - - - - - - - - - - - rowKey="groupKey" - loading={loading} - columns={columns} - dataSource={data?.items ?? []} - pagination={{ - current: page, - pageSize, - total: data?.total ?? 0, - showSizeChanger: true, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, - }} - /> - - setDrawerOpen(false)} - > - {detail ? ( - <> - - {detail.groupKey} - {detail.unionId || '—'} - {detail.identityCount} - {renderActorTags(detail.actorTypes)} - - {detail.multiRole ? : } - - {fmtTime(detail.latestLoginAt)} - - - rowKey={(r) => `${r.actorType}-${r.actorId}`} - size="small" - columns={identityColumns} - dataSource={detail.identities} - pagination={false} - /> - - ) : null} - -
- ); -} + {settingsButton} + + 按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。 + + +
{ + setPage(1); + setFilters({ + actorType: values.actorType ?? '', + phone: values.phone?.trim() ?? '', + unionId: values.unionId?.trim() ?? '', + openId: values.openId?.trim() ?? '', + }); + }} + > + + + + + + + + + + + + + + + + + + + + rowKey="groupKey" + loading={loading} + columns={columns} + dataSource={data?.items ?? []} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + > + {detail ? ( + <> + + {detail.groupKey} + {detail.unionId || '—'} + {detail.identityCount} + {renderActorTags(detail.actorTypes)} + + {detail.multiRole ? : } + + {fmtTime(detail.latestLoginAt)} + + + rowKey={(r) => `${r.actorType}-${r.actorId}`} + size="small" + columns={identityColumns} + dataSource={detail.identities} + pagination={false} + /> + + ) : null} + + + ); +} diff --git a/apps/admin-web/src/pages/WecomBotLogsPage.tsx b/apps/admin-web/src/pages/WecomBotLogsPage.tsx index 7f96d6b..5b761a3 100644 --- a/apps/admin-web/src/pages/WecomBotLogsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotLogsPage.tsx @@ -6,6 +6,7 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import type { WecomBotLogDto } from '@dukang/shared-types'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; export default function WecomBotLogsPage() { @@ -28,7 +29,14 @@ export default function WecomBotLogsPage() { const baseColumns: ColumnsType = [ { 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) => ( + setDetail(r)}>{v || r.botKey || ''} + ), + }, { title: '企微用户', dataIndex: 'wecomUserId', width: 120 }, { title: '动作', dataIndex: 'action', width: 160 }, { title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' }, diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx index efbbe9e..2d4342f 100644 --- a/apps/admin-web/src/pages/WecomBotsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotsPage.tsx @@ -37,6 +37,7 @@ import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type FormValues = { @@ -293,7 +294,14 @@ export default function WecomBotsPage() { ), }, - { title: '名称', dataIndex: 'name', width: 140 }, + { + title: '名称', + dataIndex: 'name', + width: 140, + render: (v, row) => ( + void openEdit(row)}>{v} + ), + }, { title: '角色', dataIndex: 'role', diff --git a/apps/admin-web/src/pages/WecomMessagePushesPage.tsx b/apps/admin-web/src/pages/WecomMessagePushesPage.tsx index 580d564..f07b065 100644 --- a/apps/admin-web/src/pages/WecomMessagePushesPage.tsx +++ b/apps/admin-web/src/pages/WecomMessagePushesPage.tsx @@ -35,6 +35,7 @@ import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type FormValues = { @@ -202,7 +203,7 @@ function PushRoutesTab() { render: (name: string, row) => ( {name.slice(0, 1)} - {name} + openEdit(row)}>{name} ), }, diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index e9d0df1..77a2ec5 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -27,6 +27,7 @@ import { downloadExcelCsv } from '../lib/exportExcel'; import { request, type HqProfile } from '../lib/api'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; type Row = { @@ -238,7 +239,14 @@ export default function WineryBillsPage() { const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0); const baseColumns: ColumnsType = [ - { title: '账单号', dataIndex: 'billNo', width: 170 }, + { + title: '账单号', + dataIndex: 'billNo', + width: 170, + render: (v, row) => ( + void openDetail(row.id)}>{v} + ), + }, { title: '账单日', dataIndex: 'billDate', diff --git a/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx index 9d325d3..cccf480 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx @@ -10,6 +10,7 @@ import { fmtTime } from '../../lib/constants'; import { useAdminList } from '../../lib/useAdminList'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import { useAdminListColumns } from '../../lib/useAdminListColumns'; +import { AdminPrimaryLink } from '../../components/AdminPrimaryLink'; export default function PromoCodeUsersPage() { @@ -25,7 +26,16 @@ export default function PromoCodeUsersPage() { const baseColumns: ColumnsType = [ { title: '用户编号', dataIndex: 'userNo', width: 120 }, - { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, + { + title: '昵称', + dataIndex: 'nickname', + width: 100, + render: (v, row) => ( + navigate('/users', { state: { openUserId: row.id } })}> + {v} + + ), + }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, { title: '验手机', diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index a411468..85b2b07 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -28,7 +28,7 @@ | 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) | | 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) | | 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) | -| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) | +| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) | --- @@ -137,7 +137,7 @@ - **不**再因客户端 `validation_error` 自动创建技术支持工单。 - 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。 - HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。 -- HQ 主列表:最左序号;「列设置」控制显示列与顺序,偏好挂当前 HQ 账号。 +- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。 - 门店列表展示「累计核销好客权益」(该店核销券面额合计)。 - HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。 - 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。 diff --git a/docs/杜康好客-v3-现状对照.md b/docs/杜康好客-v3-现状对照.md index d1133bb..8402bc2 100644 --- a/docs/杜康好客-v3-现状对照.md +++ b/docs/杜康好客-v3-现状对照.md @@ -67,7 +67,7 @@ | 日期 | 说明 | |------|------| -| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 | +| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 | | 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | | 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 | | 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 | diff --git a/docs/杜康好客-v3.5.9-开发文档.md b/docs/杜康好客-v3.5.9-开发文档.md index 21bad1c..dfd20b9 100644 --- a/docs/杜康好客-v3.5.9-开发文档.md +++ b/docs/杜康好客-v3.5.9-开发文档.md @@ -9,7 +9,7 @@ 1. **门店列表**增加「累计核销好客权益」:该店历史核销券面额合计。 2. **全 HQ 表格**去掉 `...` 截断,长文本完整展示,横向滚动。 -3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序,偏好存当前 HQ 账号。 +3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序;可拖表头分割线改列宽。偏好(含列宽)存当前 HQ 账号。主展示列(名称 / 单号 / 昵称等)带下划线,点击进入编辑或详情。 4. **用户列表**:昵称只读(用户自己的);新增「备注」列,双击编辑 HQ 标记,离开即写入 `user_user.hq_remark`;手机号列表明文,不脱敏。 **不做**:C 端 / 合伙人 / 门店端;详情 Drawer 内嵌表不加列设置。 @@ -38,10 +38,11 @@ | 序号 | `(page-1)*pageSize+index+1`,锁定最左,不可隐藏 | | 操作列 | 锁定最右,不可隐藏 | | 弹窗 | 勾选显示、拖拽/上下移排序、重置默认 | +| 主列 | 名称/单号/昵称等下划线,点击同「编辑」或「详情」 | | 存储 | `hq_account.list_column_prefs` JSON,按 `listKey` | ```ts -{ stores: { order: ["name", "cityName"], hidden: ["intro"] } } +{ stores: { order: ["name", "cityName"], hidden: ["intro"], widths: { name: 180 } } } ``` 新列按默认位置插入且默认显示;未知 key 忽略。重置即删除该 `listKey`。 @@ -113,7 +114,17 @@ - [ ] HQ 主表长字段不再 `...`,可横向滑完 - [ ] 主列表最左序号跨页连续 - [ ] 列设置可隐藏/排序;保存后刷新仍在;重置恢复默认 +- [ ] 主展示列带下划线,点击进入对应编辑或详情 - [ ] 序号、操作列不能在弹窗关掉或拖走 - [ ] 详情描述列表仍可省略 - [ ] 用户列表昵称只读;双击「备注」可改,离开编辑后 `user_user.hq_remark` 已更新;C 端看不到该字段 - [ ] 用户列表手机号完整可见(详情仍脱敏) +- [ ] 权益券列表:用户编号、订单号可点进对应用户/订单详情;来源含商品名、规格、数量、配送方式、实付金额 + +--- + +## 10. 权益券列表快链与来源 + +- **用户**、**订单**列(及券详情)用主列下划线,分别打开用户详情抽屉、订单详情抽屉。 +- **来源**:关联订单时拼 `商品名 / 规格 / 数量(瓶或箱) / 配送方式 / ¥实付`;无订单仍用 `sourceProduct`(如总部手动发放)。 +- 列表接口 `order` 增补 `productName`、`productSpec`、`quantity`、`saleUnit`、`deliveryType`、`payAmount`。 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index b02c39e..11a7846 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -43,7 +43,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。 -**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)存 `hq_account.list_column_prefs`。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 +**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。 ## 5. 验收用例(必过) diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index 2795099..f1fba3a 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -92,7 +92,7 @@ | 交易 | 订单、权益券、核销记录、推广码+metrics | | 财务 | 门店/合伙人/酒厂/物流账单;打款确认 | | 工单 | 售后四类型 + 技术支持(ST) + 开发计划 | -| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」按账号保存 | +| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」与列宽按账号保存 | | 日志 | HQ/用户/门店/合伙人/企微 | ## 6. 商品与模板 diff --git a/packages/shared-types/src/hq-list-columns.ts b/packages/shared-types/src/hq-list-columns.ts index f298112..8ce45ae 100644 --- a/packages/shared-types/src/hq-list-columns.ts +++ b/packages/shared-types/src/hq-list-columns.ts @@ -60,6 +60,8 @@ export function isHqListColumnKey(value: string): value is HqListColumnKey { export type HqListColumnPref = { order: string[]; hidden: string[]; + /** 列宽(px);未出现的 key 用列默认 width */ + widths?: Record; }; export type HqListColumnPrefsMap = Partial>; @@ -68,4 +70,5 @@ export type SaveHqListColumnPrefsRequest = { reset?: boolean; order?: string[]; hidden?: string[]; + widths?: Record; }; diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 81e931f..082cd59 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -55,15 +55,29 @@ type UserRow = Pick< avatar?: { url: string } | null; }; +function parseColumnWidths(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + if (!key.trim()) continue; + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) continue; + out[key] = Math.min(960, Math.max(48, Math.round(n))); + } + return Object.keys(out).length ? out : undefined; +} + function parseListColumnPrefs(raw: unknown): HqListColumnPrefsMap { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; const out: HqListColumnPrefsMap = {}; for (const [key, value] of Object.entries(raw as Record)) { if (!isHqListColumnKey(key) || !value || typeof value !== 'object' || Array.isArray(value)) continue; - const row = value as { order?: unknown; hidden?: unknown }; + const row = value as { order?: unknown; hidden?: unknown; widths?: unknown }; + const widths = parseColumnWidths(row.widths); out[key] = { order: Array.isArray(row.order) ? row.order.filter((v): v is string => typeof v === 'string') : [], hidden: Array.isArray(row.hidden) ? row.hidden.filter((v): v is string => typeof v === 'string') : [], + ...(widths ? { widths } : {}), }; } return out; @@ -1238,7 +1252,7 @@ export class AuthService { actorType: string, actorId: bigint, listKey: string, - dto: { reset?: boolean; order?: string[]; hidden?: string[] }, + dto: { reset?: boolean; order?: string[]; hidden?: string[]; widths?: Record }, ) { if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置'); if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表'); @@ -1251,9 +1265,19 @@ export class AuthService { if (dto.reset) { delete current[listKey]; } else { + const prev = current[listKey]; + const widths = + dto.widths !== undefined ? parseColumnWidths(dto.widths) : prev?.widths; current[listKey] = { - order: (dto.order ?? []).filter((k) => typeof k === 'string' && k.trim()), - hidden: (dto.hidden ?? []).filter((k) => typeof k === 'string' && k.trim()), + order: + dto.order !== undefined + ? dto.order.filter((k) => typeof k === 'string' && k.trim()) + : (prev?.order ?? []), + hidden: + dto.hidden !== undefined + ? dto.hidden.filter((k) => typeof k === 'string' && k.trim()) + : (prev?.hidden ?? []), + ...(widths && Object.keys(widths).length ? { widths } : {}), }; } const updated = await this.prisma.hqAccount.update({ diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index 6f83529..1d2bf9a 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -1,4 +1,4 @@ -import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; import { SmsScene } from '@dukang/shared-types'; export class SendSmsDto { @@ -144,4 +144,8 @@ export class SaveHqListColumnPrefsDto { @IsArray() @IsString({ each: true }) hidden?: string[]; + + @IsOptional() + @IsObject() + widths?: Record; } diff --git a/server/dukang-api/src/modules/ops/admin-benefit.service.ts b/server/dukang-api/src/modules/ops/admin-benefit.service.ts index b710efc..ab701f7 100644 --- a/server/dukang-api/src/modules/ops/admin-benefit.service.ts +++ b/server/dukang-api/src/modules/ops/admin-benefit.service.ts @@ -33,7 +33,20 @@ export class AdminBenefitService { take: pageSize, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, - order: { select: { id: true, orderNo: true, status: true } }, + order: { + select: { + id: true, + orderNo: true, + status: true, + productName: true, + productSpec: true, + quantity: true, + saleUnit: true, + deliveryType: true, + payAmount: true, + benefitAmount: true, + }, + }, }, }), this.prisma.benefitCoupon.count({ where }), @@ -46,7 +59,20 @@ export class AdminBenefitService { where: { id }, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, - order: { select: { id: true, orderNo: true, status: true, payAmount: true } }, + order: { + select: { + id: true, + orderNo: true, + status: true, + payAmount: true, + productName: true, + productSpec: true, + quantity: true, + saleUnit: true, + deliveryType: true, + benefitAmount: true, + }, + }, }, }); if (!coupon) throw new NotFoundException('权益券不存在');