From 5935024ea8d36f91e414e7f276b26248ab7c9a03 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 25 Aug 2026 09:20:32 +0800 Subject: [PATCH] =?UTF-8?q?v3.5.8=E5=92=8Cv3.5.9=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/admin.css | 31 +- .../src/components/AdminCellLine.tsx | 13 +- .../src/components/AdminListColumnsModal.tsx | 102 ++ .../src/components/CityPartnersPanel.tsx | 917 ++++++----- .../src/components/PartnerSubAccountList.tsx | 183 ++- apps/admin-web/src/layouts/AdminLayout.tsx | 20 +- .../src/lib/ListColumnPrefsContext.tsx | 50 + apps/admin-web/src/lib/api.ts | 8 + apps/admin-web/src/lib/list-column-prefs.ts | 84 + .../admin-web/src/lib/useAdminListColumns.tsx | 103 ++ .../src/pages/BenefitCouponsPage.tsx | 24 +- .../src/pages/BenefitLedgersPage.tsx | 12 +- apps/admin-web/src/pages/CitiesPage.tsx | 1349 +++++++-------- apps/admin-web/src/pages/CityPartnersPage.tsx | 1439 +++++++++-------- .../src/pages/CityWarehousesPage.tsx | 19 +- apps/admin-web/src/pages/DashboardPage.tsx | 480 ++++-- apps/admin-web/src/pages/DeliveriesPage.tsx | 10 +- apps/admin-web/src/pages/DevPlanTasksPage.tsx | 12 +- .../src/pages/DevPlanVersionsPage.tsx | 12 +- apps/admin-web/src/pages/DomainEventsPage.tsx | 11 +- .../src/pages/FulfillmentProvidersPage.tsx | 13 +- apps/admin-web/src/pages/HqAccountsPage.tsx | 121 +- apps/admin-web/src/pages/HqLogsPage.tsx | 395 ++--- .../admin-web/src/pages/HqPermissionsPage.tsx | 140 +- apps/admin-web/src/pages/InvoicesPage.tsx | 12 +- .../src/pages/KnowledgeBasesPage.tsx | 12 +- apps/admin-web/src/pages/LlmConfigsPage.tsx | 10 +- .../src/pages/LogisticsBillsPage.tsx | 15 +- apps/admin-web/src/pages/OrdersPage.tsx | 17 +- .../src/pages/PartnerAccountsPage.tsx | 19 +- apps/admin-web/src/pages/PartnerBillsPage.tsx | 1147 ++++++------- apps/admin-web/src/pages/PartnerLogsPage.tsx | 463 +++--- apps/admin-web/src/pages/PartnersPage.tsx | 807 ++++----- .../admin-web/src/pages/PendingRedeemPage.tsx | 529 +++--- .../src/pages/ProductDetailTemplatesPage.tsx | 15 +- apps/admin-web/src/pages/ProductsPage.tsx | 20 +- apps/admin-web/src/pages/PromoCodesPage.tsx | 12 +- .../admin-web/src/pages/RedeemRecordsPage.tsx | 10 +- apps/admin-web/src/pages/ResourcesPage.tsx | 14 +- .../admin-web/src/pages/StoreAccountsPage.tsx | 10 +- apps/admin-web/src/pages/StoreBillsPage.tsx | 1365 ++++++++-------- .../src/pages/StoreCategoriesPage.tsx | 60 +- apps/admin-web/src/pages/StoreLogsPage.tsx | 16 +- apps/admin-web/src/pages/StoreMediaPage.tsx | 12 +- .../src/pages/StorePackageAuditsPage.tsx | 10 +- apps/admin-web/src/pages/StoreRatingsPage.tsx | 34 +- .../src/pages/StoreWithdrawalsPage.tsx | 10 +- apps/admin-web/src/pages/StoresPage.tsx | 69 +- .../src/pages/SupportTicketsPage.tsx | 12 +- .../admin-web/src/pages/TestWhitelistPage.tsx | 15 +- .../src/pages/ThirdPartyLogsPage.tsx | 15 +- apps/admin-web/src/pages/TicketsPage.tsx | 12 +- apps/admin-web/src/pages/UserLogsPage.tsx | 14 +- apps/admin-web/src/pages/UsersPage.tsx | 112 +- .../src/pages/WechatBindingsPage.tsx | 627 +++---- apps/admin-web/src/pages/WecomBotLogsPage.tsx | 20 +- apps/admin-web/src/pages/WecomBotsPage.tsx | 18 +- .../src/pages/WecomMessagePushesPage.tsx | 12 +- apps/admin-web/src/pages/WineryBillsPage.tsx | 14 +- .../src/pages/promo/PromoCodeUsersPage.tsx | 8 +- docs/杜康好客-v3-PRD.md | 17 +- docs/杜康好客-v3-现状对照.md | 4 + docs/杜康好客-v3.5.8-开发文档.md | 132 ++ docs/杜康好客-v3.5.9-开发文档.md | 113 ++ docs/杜康好客-v3编码手册.md | 4 + docs/杜康好客-知识库.md | 7 +- packages/shared-types/src/hq-list-columns.ts | 71 + packages/shared-types/src/hq-permissions.ts | 76 +- packages/shared-types/src/index.ts | 1 + packages/shared-types/src/ops.ts | 4 + .../migrate-hq-list-column-prefs-v359.sql | 3 + ...-hq-permissions-v358-categories-delete.sql | 15 + .../prisma/migrate-hq-permissions-v358.sql | 71 + server/dukang-api/prisma/schema.prisma | 30 +- .../src/common/guards/hq-permission.guard.ts | 86 +- .../hq-operation/hq-operation.constants.ts | 2 + .../src/integrations/wecom/wecom-handbook.ts | 2 + .../src/modules/iam/admin-me.controller.ts | 21 + .../src/modules/iam/auth.service.ts | 55 +- .../src/modules/iam/dto/auth.dto.ts | 18 +- .../dukang-api/src/modules/iam/iam.module.ts | 2 + .../modules/ops/admin-cities.controller.ts | 6 +- .../src/modules/ops/admin-cities.service.ts | 10 +- .../modules/ops/admin-dashboard.controller.ts | 20 +- .../modules/ops/admin-dashboard.service.ts | 430 +++-- .../modules/ops/admin-hq-accounts.service.ts | 164 +- .../ops/admin-hq-permissions.controller.ts | 3 +- .../ops/admin-hq-permissions.service.ts | 105 +- .../ops/admin-store-categories.controller.ts | 9 +- .../ops/admin-store-ratings.controller.ts | 13 +- .../ops/admin-store-ratings.service.ts | 17 +- .../modules/ops/admin-stores.controller.ts | 80 +- .../src/modules/ops/admin-stores.service.ts | 114 +- .../src/modules/ops/admin-users.controller.ts | 15 +- .../src/modules/ops/admin-users.service.ts | 12 + .../src/modules/ops/dto/admin-mutate.dto.ts | 33 +- .../src/modules/ops/dto/admin-query.dto.ts | 4 + .../store/store-info-change.controller.ts | 17 +- .../store/store-info-change.service.ts | 30 +- .../modules/store/store-package.controller.ts | 44 +- .../modules/store/store-package.service.ts | 29 +- 101 files changed, 7640 insertions(+), 5364 deletions(-) create mode 100644 apps/admin-web/src/components/AdminListColumnsModal.tsx create mode 100644 apps/admin-web/src/lib/ListColumnPrefsContext.tsx create mode 100644 apps/admin-web/src/lib/list-column-prefs.ts create mode 100644 apps/admin-web/src/lib/useAdminListColumns.tsx create mode 100644 docs/杜康好客-v3.5.8-开发文档.md create mode 100644 docs/杜康好客-v3.5.9-开发文档.md create mode 100644 packages/shared-types/src/hq-list-columns.ts create mode 100644 server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql create mode 100644 server/dukang-api/prisma/migrate-hq-permissions-v358-categories-delete.sql create mode 100644 server/dukang-api/prisma/migrate-hq-permissions-v358.sql create mode 100644 server/dukang-api/src/modules/iam/admin-me.controller.ts diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index c1faec6..171213b 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -16,37 +16,37 @@ body, } /* - * 后台内容行:不换行 + 超出省略 - * 覆盖主内容区、Drawer、Modal 内的表格与描述列表 + * 后台表格:不换行、完整展示(v3.5.9 去掉省略号) + * 描述列表仍省略,避免详情抽屉被长字段撑破 */ .admin-layout .ant-table-cell, .ant-drawer .ant-table-cell, -.ant-modal .ant-table-cell, +.ant-modal .ant-table-cell { + white-space: nowrap; + overflow: visible; + text-overflow: clip; + max-width: none; +} + .admin-layout .ant-descriptions-item-content, .ant-drawer .ant-descriptions-item-content, .ant-modal .ant-descriptions-item-content { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 0; -} - -.admin-layout .ant-descriptions-item-content, -.ant-drawer .ant-descriptions-item-content, -.ant-modal .ant-descriptions-item-content { max-width: 100%; } -/* 表头同样不换行 */ +/* 表头不换行 */ .admin-layout .ant-table-thead > tr > th, .ant-drawer .ant-table-thead > tr > th, .ant-modal .ant-table-thead > tr > th { white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + overflow: visible; + text-overflow: clip; } -/* 操作列、含交互控件的不截断 */ +/* 操作列保持可见 */ .admin-layout .ant-table-cell:has(.ant-btn), .ant-drawer .ant-table-cell:has(.ant-btn), .ant-modal .ant-table-cell:has(.ant-btn), @@ -71,10 +71,9 @@ body, max-width: none; } -/* 表格单元格双行信息合并为单行省略 */ +/* 表格单元格主/副信息同一行 */ .admin-cell-line { - display: block; - max-width: 100%; + display: inline; } .admin-cell-line-secondary { diff --git a/apps/admin-web/src/components/AdminCellLine.tsx b/apps/admin-web/src/components/AdminCellLine.tsx index ab210df..e502848 100644 --- a/apps/admin-web/src/components/AdminCellLine.tsx +++ b/apps/admin-web/src/components/AdminCellLine.tsx @@ -1,12 +1,10 @@ -import { Typography } from 'antd'; - type AdminCellLineProps = { primary?: string | null; secondary?: string | null; separator?: string; }; -/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */ +/** 表格单元格:主/副信息同一行完整展示 */ export function AdminCellLine({ primary, secondary, @@ -14,14 +12,9 @@ export function AdminCellLine({ }: AdminCellLineProps) { const main = primary?.trim() || '—'; const sub = secondary?.trim(); - const full = sub ? `${main}${separator}${sub}` : main; return ( - + {main} {sub ? ( @@ -29,6 +22,6 @@ export function AdminCellLine({ {sub} ) : null} - + ); } diff --git a/apps/admin-web/src/components/AdminListColumnsModal.tsx b/apps/admin-web/src/components/AdminListColumnsModal.tsx new file mode 100644 index 0000000..7a41e34 --- /dev/null +++ b/apps/admin-web/src/components/AdminListColumnsModal.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import { Button, Checkbox, Modal, Space, Typography } from 'antd'; +import { HolderOutlined } from '@ant-design/icons'; +import type { ListColumnSettingItem } from '../lib/list-column-prefs'; + +type Props = { + open: boolean; + items: ListColumnSettingItem[]; + saving?: boolean; + onCancel: () => void; + onReset: () => void; + onSave: (items: ListColumnSettingItem[]) => void; +}; + +export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) { + const [draft, setDraft] = useState(items); + const [dragKey, setDragKey] = useState(null); + + useEffect(() => { + if (open) setDraft(items); + }, [open, items]); + + function move(from: number, to: number) { + if (to < 0 || to >= draft.length) return; + const next = [...draft]; + const [row] = next.splice(from, 1); + next.splice(to, 0, row); + setDraft(next); + } + + return ( + + + + + + + + } + > + + 勾选显示列,拖拽或上下箭头调整顺序。序号与操作列固定,不在此列表中。 + +
+ {draft.map((item, index) => ( +
setDragKey(item.key)} + onDragOver={(e) => e.preventDefault()} + onDrop={() => { + if (!dragKey || dragKey === item.key) return; + const from = draft.findIndex((r) => r.key === dragKey); + if (from >= 0) move(from, index); + setDragKey(null); + }} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 0', + borderBottom: '1px solid #f0f0f0', + cursor: 'move', + }} + > + + { + const next = [...draft]; + next[index] = { ...item, visible: e.target.checked }; + setDraft(next); + }} + > + {item.title} + + + + + +
+ ))} +
+
+ ); +} diff --git a/apps/admin-web/src/components/CityPartnersPanel.tsx b/apps/admin-web/src/components/CityPartnersPanel.tsx index b9d5318..0ad3ba8 100644 --- a/apps/admin-web/src/components/CityPartnersPanel.tsx +++ b/apps/admin-web/src/components/CityPartnersPanel.tsx @@ -1,459 +1,458 @@ -import { useCallback, useEffect, useState } from 'react'; -import { - Button, - Checkbox, - Drawer, - Form, - Input, - InputNumber, - Modal, - Select, - Space, - Table, - Tabs, - 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 { fmtTime } from '../lib/constants'; -import CityDistrictMultiSelect from './CityDistrictMultiSelect'; -import PartnerSubAccountList from './PartnerSubAccountList'; - -type PartnerRow = { - id: string; - companyName: string; - phone: string; - name: string; - scopeType?: string; - districtCodes?: string[] | null; - orderCommissionRate?: number; - redeemCommissionRate?: number; - accountCount: number; - createdAt: string; -}; - -type PartnerDetail = PartnerRow & { - address?: string; - districtCodes?: string[] | null; - bindingStatus?: string; - bankAccountName?: string | null; - bankAccountNo?: string | null; - bankBranch?: string | null; - managedWarehouseId?: string | null; - managedWarehouseName?: string | null; - contactPhone?: string | null; - children?: Array<{ - id: string; - phone: string; - name: string; - staffRole?: string; - permissions?: string[]; - status: string; - }>; -}; - -type Props = { - cityId: string; - cityCode?: string; - maxPartnerCommissionRate?: number; - onChanged?: () => void; -}; - -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 CityPartnersPanel({ - cityId, - cityCode, - maxPartnerCommissionRate = 0.05, - onChanged, -}: Props) { - const [editForm] = Form.useForm(); - const [createForm] = Form.useForm(); - const [subForm] = Form.useForm(); - const [subEditForm] = Form.useForm(); - const [partners, setPartners] = useState([]); - const [loading, setLoading] = useState(false); - 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 [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); - const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); - - const loadPartners = useCallback(async () => { - setLoading(true); - try { - const res = await request>(`/admin/partners?cityId=${cityId}&pageSize=100`); - setPartners(res.items); - } finally { - setLoading(false); - } - }, [cityId]); - - useEffect(() => { - void loadPartners(); - }, [loadPartners]); - - async function openPartner(id: string) { - const d = await request(`/admin/partners/${id}`); - setDetail(d); - setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); - 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); - } - - async function savePartner() { - if (!detail) return; - try { - const v = await editForm.validateFields(); - const err = commissionSumError( - Number(v.orderCommissionRate ?? 0), - Number(v.redeemCommissionRate ?? 0), - maxPartnerCommissionRate, - ); - 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 loadPartners(); - onChanged?.(); - } catch (e) { - const msg = formatApiError(e); - if (msg) message.error(msg); - } - } - - async function deleteSubAccount(subId: string) { - await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' }); - message.success('子账号已删除'); - if (detail) void openPartner(detail.id); - void loadPartners(); - onChanged?.(); - } - - const columns: ColumnsType = [ - { - title: '区县', - dataIndex: 'districtCodes', - width: 160, - ellipsis: true, - render: (codes: string[] | null | undefined, row) => - row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), - }, - { title: '公司名', dataIndex: 'companyName', ellipsis: true }, - { title: '主账号', dataIndex: 'phone', width: 120 }, - { - title: '管辖', - dataIndex: 'scopeType', - width: 100, - render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), - }, - { - title: '佣金', - width: 120, - render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`, - }, - { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, - { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, - { - title: '操作', - width: 80, - render: (_, row) => ( - - ), - }, - ]; - - return ( - <> - - - - setDrawerOpen(false)} - extra={} - > - {detail && ( - - - - - - - - setEditScopeType(v)} /> - - {editScopeType === CityPartnerScopeType.DISTRICT && ( - - - - )} - - - - - - 订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 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={(row) => { - setSubEditId(row.id); - subEditForm.setFieldsValue({ - name: row.name, - phone: row.phone, - staffRole: row.staffRole ?? 'INTERNAL', - permissions: row.permissions ?? [], - status: row.status, - }); - setSubEditOpen(true); - }} - onDelete={(subId) => void deleteSubAccount(subId)} - /> - ), - }, - ]} - /> - )} - - - setCreateOpen(false)} onOk={async () => { - try { - const v = await createForm.validateFields(); - const err = commissionSumError( - Number(v.orderCommissionRate ?? 0), - Number(v.redeemCommissionRate ?? 3), - maxPartnerCommissionRate, - ); - if (err) { - message.error(err); - return; - } - await request('/admin/partners', { - method: 'POST', - body: JSON.stringify({ - ...v, - cityId, - 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 loadPartners(); - onChanged?.(); - } catch (e) { - const msg = formatApiError(e); - if (msg) message.error(msg); - } - }}> -
- - - - - - - - - - - - - - - - -
- - ); -} +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Checkbox, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tabs, + 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 { fmtTime } from '../lib/constants'; +import CityDistrictMultiSelect from './CityDistrictMultiSelect'; +import PartnerSubAccountList from './PartnerSubAccountList'; + +type PartnerRow = { + id: string; + companyName: string; + phone: string; + name: string; + scopeType?: string; + districtCodes?: string[] | null; + orderCommissionRate?: number; + redeemCommissionRate?: number; + accountCount: number; + createdAt: string; +}; + +type PartnerDetail = PartnerRow & { + address?: string; + districtCodes?: string[] | null; + bindingStatus?: string; + bankAccountName?: string | null; + bankAccountNo?: string | null; + bankBranch?: string | null; + managedWarehouseId?: string | null; + managedWarehouseName?: string | null; + contactPhone?: string | null; + children?: Array<{ + id: string; + phone: string; + name: string; + staffRole?: string; + permissions?: string[]; + status: string; + }>; +}; + +type Props = { + cityId: string; + cityCode?: string; + maxPartnerCommissionRate?: number; + onChanged?: () => void; +}; + +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 CityPartnersPanel({ + cityId, + cityCode, + maxPartnerCommissionRate = 0.05, + onChanged, +}: Props) { + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [subForm] = Form.useForm(); + const [subEditForm] = Form.useForm(); + const [partners, setPartners] = useState([]); + const [loading, setLoading] = useState(false); + 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 [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + + const loadPartners = useCallback(async () => { + setLoading(true); + try { + const res = await request>(`/admin/partners?cityId=${cityId}&pageSize=100`); + setPartners(res.items); + } finally { + setLoading(false); + } + }, [cityId]); + + useEffect(() => { + void loadPartners(); + }, [loadPartners]); + + async function openPartner(id: string) { + const d = await request(`/admin/partners/${id}`); + setDetail(d); + setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); + 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); + } + + async function savePartner() { + if (!detail) return; + try { + const v = await editForm.validateFields(); + const err = commissionSumError( + Number(v.orderCommissionRate ?? 0), + Number(v.redeemCommissionRate ?? 0), + maxPartnerCommissionRate, + ); + 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 loadPartners(); + onChanged?.(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); + } + } + + async function deleteSubAccount(subId: string) { + await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' }); + message.success('子账号已删除'); + if (detail) void openPartner(detail.id); + void loadPartners(); + onChanged?.(); + } + + const columns: ColumnsType = [ + { + 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: 120 }, + { + title: '管辖', + dataIndex: 'scopeType', + width: 100, + render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'), + }, + { + title: '佣金', + width: 120, + render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`, + }, + { title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) }, + { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + return ( + <> + +
+ + setDrawerOpen(false)} + extra={} + > + {detail && ( + + + + + + + + setEditScopeType(v)} /> + + {editScopeType === CityPartnerScopeType.DISTRICT && ( + + + + )} + + + + + + 订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 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={(row) => { + setSubEditId(row.id); + subEditForm.setFieldsValue({ + name: row.name, + phone: row.phone, + staffRole: row.staffRole ?? 'INTERNAL', + permissions: row.permissions ?? [], + status: row.status, + }); + setSubEditOpen(true); + }} + onDelete={(subId) => void deleteSubAccount(subId)} + /> + ), + }, + ]} + /> + )} + + + setCreateOpen(false)} onOk={async () => { + try { + const v = await createForm.validateFields(); + const err = commissionSumError( + Number(v.orderCommissionRate ?? 0), + Number(v.redeemCommissionRate ?? 3), + maxPartnerCommissionRate, + ); + if (err) { + message.error(err); + return; + } + await request('/admin/partners', { + method: 'POST', + body: JSON.stringify({ + ...v, + cityId, + 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 loadPartners(); + onChanged?.(); + } catch (e) { + const msg = formatApiError(e); + if (msg) message.error(msg); + } + }}> +
+ + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/components/PartnerSubAccountList.tsx b/apps/admin-web/src/components/PartnerSubAccountList.tsx index 90b2a90..c32d707 100644 --- a/apps/admin-web/src/components/PartnerSubAccountList.tsx +++ b/apps/admin-web/src/components/PartnerSubAccountList.tsx @@ -1,92 +1,91 @@ -import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { - PARTNER_PERMISSION_LABELS, - PARTNER_STAFF_ROLE_LABELS, - type PartnerPermissionKey, -} from '@dukang/shared-types'; - -export type PartnerSubAccountRow = { - id: string; - phone: string; - name: string; - staffRole?: string; - permissions?: string[]; - status: string; -}; - -function formatPermissions(permissions?: string[]) { - return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—'; -} - -type Props = { - subs: PartnerSubAccountRow[]; - onAdd: () => void; - onEdit: (sub: PartnerSubAccountRow) => void; - onDelete: (subId: string) => void; -}; - -export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) { - const columns: ColumnsType = [ - { title: '姓名', dataIndex: 'name', width: 100 }, - { title: '手机', dataIndex: 'phone', width: 120 }, - { - title: '角色', - dataIndex: 'staffRole', - width: 90, - render: (v) => - v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—', - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (s) => ( - {s === 'ACTIVE' ? '启用' : '停用'} - ), - }, - { - title: '权限', - dataIndex: 'permissions', - ellipsis: true, - render: (p: string[] | undefined) => formatPermissions(p), - }, - { - title: '操作', - width: 120, - render: (_, row) => ( - - - onDelete(row.id)}> - - - - ), - }, - ]; - - return ( - <> -
- - 子账号({subs.length})· 仅主账号可添加,不可多级 - - -
-
- - ); -} +import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + PARTNER_PERMISSION_LABELS, + PARTNER_STAFF_ROLE_LABELS, + type PartnerPermissionKey, +} from '@dukang/shared-types'; + +export type PartnerSubAccountRow = { + id: string; + phone: string; + name: string; + staffRole?: string; + permissions?: string[]; + status: string; +}; + +function formatPermissions(permissions?: string[]) { + return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—'; +} + +type Props = { + subs: PartnerSubAccountRow[]; + onAdd: () => void; + onEdit: (sub: PartnerSubAccountRow) => void; + onDelete: (subId: string) => void; +}; + +export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) { + const columns: ColumnsType = [ + { title: '姓名', dataIndex: 'name', width: 100 }, + { title: '手机', dataIndex: 'phone', width: 120 }, + { + title: '角色', + dataIndex: 'staffRole', + width: 90, + render: (v) => + v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—', + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s) => ( + {s === 'ACTIVE' ? '启用' : '停用'} + ), + }, + { + title: '权限', + dataIndex: 'permissions', + render: (p: string[] | undefined) => formatPermissions(p), + }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]; + + return ( + <> +
+ + 子账号({subs.length})· 仅主账号可添加,不可多级 + + +
+
+ + ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index af6f784..460b8cc 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -20,10 +20,11 @@ import { AccountBookOutlined, ProjectOutlined, } from '@ant-design/icons'; -import { hasAnySystemSettingsPermission } from '@dukang/shared-types'; +import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types'; import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; +import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext'; const { Header, Sider, Content } = Layout; @@ -156,6 +157,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { if (key === 'tickets-group') { return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support'); } + if (key === 'stores-group') { + return hasAnyStoreMenuPermission(permissionKeys); + } if (key === 'dev-plan-group') { return permissionKeys.includes('dev_plan'); } @@ -175,11 +179,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean { '/knowledge-bases': 'knowledge_bases', 'stores-group': 'stores', '/stores': 'stores', - '/store-package-audits': 'stores', - '/store-ratings': 'stores', - '/store-categories': 'stores', - '/store-accounts': 'stores', - '/store-media': 'stores', + '/store-package-audits': 'store_audits', + '/store-ratings': 'store_ratings', + '/store-categories': 'store_categories', + '/store-accounts': 'store_accounts', + '/store-media': 'store_media', 'partners-group': 'partners', '/cities': 'partners', '/city-partners': 'partners', @@ -413,7 +417,9 @@ export default function AdminLayout() { style={{ flex: 1, minHeight: 0, overflow: 'auto' }} > - + + + diff --git a/apps/admin-web/src/lib/ListColumnPrefsContext.tsx b/apps/admin-web/src/lib/ListColumnPrefsContext.tsx new file mode 100644 index 0000000..83dd744 --- /dev/null +++ b/apps/admin-web/src/lib/ListColumnPrefsContext.tsx @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; +import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types'; +import { request } from './api'; + +type Ctx = { + prefs: HqListColumnPrefsMap; + ready: boolean; + setPrefs: (next: HqListColumnPrefsMap) => void; + save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise; +}; + +const ListColumnPrefsContext = createContext({ + prefs: {}, + ready: false, + setPrefs: () => {}, + save: async () => {}, +}); + +export function ListColumnPrefsProvider({ + initial, + children, +}: { + initial?: HqListColumnPrefsMap; + children: ReactNode; +}) { + const [prefs, setPrefs] = useState(initial ?? {}); + const [ready] = useState(true); + + useEffect(() => { + if (initial) setPrefs(initial); + }, [initial]); + + const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => { + const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>( + `/admin/me/list-columns/${listKey}`, + { + method: 'PUT', + body: JSON.stringify(next ? { order: next.order, hidden: next.hidden } : { reset: true }), + }, + ); + setPrefs(res.listColumnPrefs ?? {}); + }, []); + + const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]); + return {children}; +} + +export function useListColumnPrefs() { + return useContext(ListColumnPrefsContext); +} diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index b1ee956..386e1b1 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -1,3 +1,5 @@ +import type { HqListColumnPrefsMap } from '@dukang/shared-types'; + export const apiBase = '/api/v1'; export const CLIENT_APP = 'HQ_WEB'; @@ -8,6 +10,9 @@ export type HqProfile = { adminRole: string; status: string; permissionKeys?: string[]; + cityIds?: string[]; + cityScoped?: boolean; + listColumnPrefs?: HqListColumnPrefsMap; }; export function getToken() { @@ -87,6 +92,9 @@ export type DashboardStats = { openTickets?: number; pendingStoreWithdrawals?: number; overdueStoreWithdrawals?: number; + pendingStoreOnboard?: number; + pendingStorePackageAudits?: number; + pendingStoreInfoChanges?: number; ordersByStatus: Array<{ status: string; count: number }>; }; diff --git a/apps/admin-web/src/lib/list-column-prefs.ts b/apps/admin-web/src/lib/list-column-prefs.ts new file mode 100644 index 0000000..6283cfb --- /dev/null +++ b/apps/admin-web/src/lib/list-column-prefs.ts @@ -0,0 +1,84 @@ +import type { ColumnType } from 'antd/es/table'; +import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types'; + +export const SERIAL_COLUMN_KEY = '__serial'; +export const ACTIONS_COLUMN_KEY = '__actions'; + +export function columnKey(col: ColumnType, index: number): string { + if (col.key != null && String(col.key).length) return String(col.key); + const dataIndex = col.dataIndex; + if (typeof dataIndex === 'string' && dataIndex) return dataIndex; + if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.'); + if (typeof col.title === 'string' && col.title) return col.title; + return `col-${index}`; +} + +export function isActionsColumn(col: ColumnType, key: string): boolean { + if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true; + return col.title === '操作'; +} + +export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] { + const known = new Set(defaultKeys); + const ordered = (pref?.order ?? []).filter((k) => known.has(k)); + for (const key of defaultKeys) { + if (ordered.includes(key)) continue; + const defaultIdx = defaultKeys.indexOf(key); + let insertAt = ordered.length; + for (let i = 0; i < ordered.length; i += 1) { + if (defaultKeys.indexOf(ordered[i]) > defaultIdx) { + insertAt = i; + break; + } + } + ordered.splice(insertAt, 0, key); + } + return ordered; +} + +export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] { + const hidden = new Set(pref?.hidden ?? []); + return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k)); +} + +export function applyColumnPrefs( + columns: ColumnType[], + pref: HqListColumnPref | undefined, +): ColumnType[] { + const keyed = columns.map((col, i) => { + const key = columnKey(col, i); + return { col: { ...col, key }, key, actions: isActionsColumn(col, key) }; + }); + const configurable = keyed.filter((c) => !c.actions); + const actions = keyed.filter((c) => c.actions); + const defaultKeys = configurable.map((c) => c.key); + 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)]; +} + +export type ListColumnSettingItem = { + key: string; + title: string; + visible: boolean; +}; + +export function settingItems( + columns: ColumnType[], + pref?: HqListColumnPref, +): ListColumnSettingItem[] { + const keyed = columns + .map((col, i) => ({ col, key: columnKey(col, i) })) + .filter(({ col, key }) => !isActionsColumn(col, key)); + const hidden = new Set(pref?.hidden ?? []); + const order = mergeColumnOrder(keyed.map((c) => c.key), pref); + const byKey = new Map(keyed.map((c) => [c.key, c.col])); + return order.map((key) => { + const col = byKey.get(key); + const title = typeof col?.title === 'string' ? col.title : key; + return { key, title, visible: !hidden.has(key) }; + }); +} + +export type { HqListColumnKey }; diff --git a/apps/admin-web/src/lib/useAdminListColumns.tsx b/apps/admin-web/src/lib/useAdminListColumns.tsx new file mode 100644 index 0000000..7f9ece4 --- /dev/null +++ b/apps/admin-web/src/lib/useAdminListColumns.tsx @@ -0,0 +1,103 @@ +import { useMemo, useState } from 'react'; +import { Button, message } from 'antd'; +import { SettingOutlined } from '@ant-design/icons'; +import type { ColumnsType, ColumnType } from 'antd/es/table'; +import type { HqListColumnKey } from '@dukang/shared-types'; +import { AdminListColumnsModal } from '../components/AdminListColumnsModal'; +import { useListColumnPrefs } from './ListColumnPrefsContext'; +import { + ACTIONS_COLUMN_KEY, + SERIAL_COLUMN_KEY, + applyColumnPrefs, + settingItems, + type ListColumnSettingItem, +} from './list-column-prefs'; + +type Options = { + page?: number; + pageSize?: number; +}; + +export function useAdminListColumns( + listKey: HqListColumnKey, + allColumns: ColumnsType, + options: Options = {}, +) { + const { page = 1, pageSize = 20 } = options; + const { prefs, save } = useListColumnPrefs(); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const pref = prefs[listKey]; + + const configured = useMemo( + () => applyColumnPrefs(allColumns as ColumnType[], pref), + [allColumns, pref], + ); + + const items = useMemo( + () => settingItems(allColumns as ColumnType[], pref), + [allColumns, pref], + ); + + const serialCol: ColumnType = useMemo( + () => ({ + key: SERIAL_COLUMN_KEY, + title: '序号', + width: 64, + fixed: 'left' as const, + render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1, + }), + [page, pageSize], + ); + + const columns = useMemo>( + () => [ + 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 settingsButton = ( + + ); + + const settingsModal = ( + setOpen(false)} + onReset={() => void persist(null)} + onSave={(draft) => void persist(draft)} + /> + ); + + return { columns, settingsButton, settingsModal }; +} diff --git a/apps/admin-web/src/pages/BenefitCouponsPage.tsx b/apps/admin-web/src/pages/BenefitCouponsPage.tsx index eaa2eef..6d34dbb 100644 --- a/apps/admin-web/src/pages/BenefitCouponsPage.tsx +++ b/apps/admin-web/src/pages/BenefitCouponsPage.tsx @@ -20,6 +20,8 @@ import { request } from '../lib/api'; import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants'; import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -99,7 +101,7 @@ export default function BenefitCouponsPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, { title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, { title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' }, @@ -113,7 +115,7 @@ export default function BenefitCouponsPage() { { title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` }, { title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {COUPON_STATUS_LABELS[s] || s} }, - { title: '来源', dataIndex: 'sourceProduct', ellipsis: true }, + { title: '来源', dataIndex: 'sourceProduct' }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作', @@ -155,15 +157,21 @@ export default function BenefitCouponsPage() { } } + const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
好客权益券 - + + {settingsButton} + +
{ setFilters(v); setPage(1); }}> @@ -190,7 +198,7 @@ export default function BenefitCouponsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, @@ -316,7 +324,7 @@ export default function BenefitCouponsPage() { locale={{ emptyText: '暂无关联核销单' }} dataSource={detail.redeemRecords ?? []} columns={[ - { title: '核销号', dataIndex: 'redeemNo', ellipsis: true }, + { title: '核销号', dataIndex: 'redeemNo' }, { title: '角色', dataIndex: 'role', @@ -327,7 +335,7 @@ export default function BenefitCouponsPage() { { title: '门店', dataIndex: ['store', 'name'], - ellipsis: true, + render: (v: string | undefined, row: CouponRedeemRecord) => v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—', }, diff --git a/apps/admin-web/src/pages/BenefitLedgersPage.tsx b/apps/admin-web/src/pages/BenefitLedgersPage.tsx index 689d963..588434f 100644 --- a/apps/admin-web/src/pages/BenefitLedgersPage.tsx +++ b/apps/admin-web/src/pages/BenefitLedgersPage.tsx @@ -3,6 +3,8 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string; @@ -24,19 +26,23 @@ export default function BenefitLedgersPage() { [filters], ); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '类型', dataIndex: 'type', width: 90, render: (t) => {LEDGER_TYPE_LABELS[t] || t} }, { title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, { title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false }, { title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` }, { title: '余额后', dataIndex: 'balanceAfter', width: 90 }, - { title: '备注', dataIndex: 'remark', ellipsis: true }, + { title: '备注', dataIndex: 'remark' }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 权益流水 + {settingsButton} { setFilters(v); setPage(1); }}> @@ -45,7 +51,7 @@ export default function BenefitLedgersPage() { -
{ setPage(p); setPageSize(ps); } }} /> ); diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index 5c6325e..62b027e 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -1,676 +1,681 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { - Button, - Descriptions, - Drawer, - Form, - Input, - InputNumber, - Modal, - Select, - Space, - Table, - Tabs, - Tag, - Typography, - message, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; -import { request, type HqProfile, type Paginated } from '../lib/api'; -import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; -import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; -import CityPartnersPanel from '../components/CityPartnersPanel'; +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + Button, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; +import { request, type HqProfile, type Paginated } from '../lib/api'; +import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; +import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; +import CityPartnersPanel from '../components/CityPartnersPanel'; import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + + +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 }, + { 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 }); -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 columns: ColumnsType = [ - { title: '编码', dataIndex: 'code', width: 90 }, - { title: '城市', dataIndex: 'name', width: 100 }, - { 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', ellipsis: true }, - { 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', ellipsis: true, render: (v) => v || '—' }, - { title: '状态', dataIndex: 'status', width: 80, render: (s) => {s} }, - { - title: '操作', - width: 120, - render: (_, row) => ( - - - - - ), - }, - ]; - - return ( + return (
- + {settingsModal} + 城市 - - -
{ setFilters(v); setPage(1); }}> - - - -
{ setPage(p); setPageSize(ps); } }} /> - - setDrawerOpen(false)}> - {detail && ( - - - {String(detail.code)} - {String(detail.partnerBindingCount ?? '—')} - - - - - -
- - ), - }, - ]} - /> - )} - - - setCreateOpen(false)} onOk={async () => { - const v = await createForm.validateFields(); - if (!v.code || !v.name || !v.province) { - message.error('请选择省 / 市'); - return; - } - await request('/admin/cities', { - method: 'POST', - body: JSON.stringify({ - code: v.code, - name: v.name, - province: v.province, - status: v.status, - }), - }); - message.success('已创建'); - setCreateOpen(false); - createForm.resetFields(); - setCreateRegionPreview(null); - void reload(); - }}> - - - - - {createRegionPreview ? ( - - {createRegionPreview.cityCode} - {createRegionPreview.province} - {createRegionPreview.city} - - ) : ( - - 选择省 / 市后,城市编码将自动填入(不可编辑) - - )} - - - - - - - - - - ({ value: p.id, label: p.companyName }))} /> - - )} - - - - - - - ({ value: p.id, label: p.companyName }))} /> - - )} - -
[ - { - id: p.id, - kind: '主账号', - name: p.companyName || p.name, - phone: p.phone, - parent: '—', - }, - ...p.staff.map((s) => ({ - id: s.id, - kind: '子账号', - name: s.name, - phone: s.phone, - parent: p.companyName || p.name, - })), - ]).concat( - deletePreview.orphanStaff.map((s) => ({ - id: s.id, - kind: '子账号', - name: s.name, - phone: s.phone, - parent: '(无主账号)', - })), - )} - columns={[ - { title: '类型', dataIndex: 'kind', width: 80 }, - { title: '名称', dataIndex: 'name', ellipsis: true }, - { title: '手机号', dataIndex: 'phone', width: 120 }, - { title: '归属', dataIndex: 'parent', ellipsis: true }, - ]} - /> - - -
- 门店 -
r.partnerAccount?.companyName || r.partnerAccount?.phone || '—', - }, - ]} - /> - - - {deletePreview.canDelete ? ( - - setConfirmName(e.target.value)} - disabled={deleteSubmitting} - /> - - ) : ( - 存在阻断项,无法删除。请先处理订单等关联数据。 - )} - - )} - - - ); -} + {settingsButton} + + + { setFilters(v); setPage(1); }}> + + + +
{ setPage(p); setPageSize(ps); } }} /> + + setDrawerOpen(false)}> + {detail && ( + + + {String(detail.code)} + {String(detail.partnerBindingCount ?? '—')} + + + + + +
+ + ), + }, + ]} + /> + )} + + + setCreateOpen(false)} onOk={async () => { + const v = await createForm.validateFields(); + if (!v.code || !v.name || !v.province) { + message.error('请选择省 / 市'); + return; + } + await request('/admin/cities', { + method: 'POST', + body: JSON.stringify({ + code: v.code, + name: v.name, + province: v.province, + status: v.status, + }), + }); + message.success('已创建'); + setCreateOpen(false); + createForm.resetFields(); + setCreateRegionPreview(null); + void reload(); + }}> + + + + + {createRegionPreview ? ( + + {createRegionPreview.cityCode} + {createRegionPreview.province} + {createRegionPreview.city} + + ) : ( + + 选择省 / 市后,城市编码将自动填入(不可编辑) + + )} + + + + + + + + + + ({ value: p.id, label: p.companyName }))} /> + + )} + + + + + + + ({ value: p.id, label: p.companyName }))} /> + + )} + +
[ + { + id: p.id, + kind: '主账号', + name: p.companyName || p.name, + phone: p.phone, + parent: '—', + }, + ...p.staff.map((s) => ({ + id: s.id, + kind: '子账号', + name: s.name, + phone: s.phone, + parent: p.companyName || p.name, + })), + ]).concat( + deletePreview.orphanStaff.map((s) => ({ + id: s.id, + kind: '子账号', + name: s.name, + phone: s.phone, + parent: '(无主账号)', + })), + )} + columns={[ + { title: '类型', dataIndex: 'kind', width: 80 }, + { title: '名称', dataIndex: 'name' }, + { title: '手机号', dataIndex: 'phone', width: 120 }, + { title: '归属', dataIndex: 'parent' }, + ]} + /> + + +
+ 门店 +
r.partnerAccount?.companyName || r.partnerAccount?.phone || '—', + }, + ]} + /> + + + {deletePreview.canDelete ? ( + + 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 8c2d6ae..def7467 100644 --- a/apps/admin-web/src/pages/CityPartnersPage.tsx +++ b/apps/admin-web/src/pages/CityPartnersPage.tsx @@ -1,722 +1,725 @@ -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) => ( + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize }); -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 columns: ColumnsType = [ - { title: '城市', dataIndex: 'cityName', width: 90 }, - { - title: '区县', - dataIndex: 'districtCodes', - width: 160, - ellipsis: true, - render: (codes: string[] | null | undefined, row) => - row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), - }, - { title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 }, - { - title: '主账号姓名', - dataIndex: 'name', - width: 120, - ellipsis: true, - 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, - ellipsis: true, - 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) => ( - - ), - }, - ]; - - return ( + return (
- - - 城市合伙人 + {settingsModal} + + + 城市合伙人 - - - - - 账号结构仅两级:主账号 → 子账号(子账号不可再添加下级)。管仓仓库请在{' '} - 仓库管理 中分配。 - - } - /> - - { - 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 874b0d8..b970206 100644 --- a/apps/admin-web/src/pages/CityWarehousesPage.tsx +++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx @@ -30,6 +30,8 @@ import { 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'; + type Row = { id: string; @@ -70,6 +72,7 @@ function FulfillmentFields({ onModeChange: (mode: WarehouseFulfillmentMode) => void; }) { const autoShip = mode === WarehouseFulfillmentMode.API_AUTO; + return ( <> = [ + const baseColumns: ColumnsType = [ { title: '城市', width: 100, @@ -229,8 +232,8 @@ export default function CityWarehousesPage() { ), }, - { title: '仓库', dataIndex: 'name', width: 140, ellipsis: true }, - { title: '地址', dataIndex: 'address', width: 180, ellipsis: true }, + { title: '仓库', dataIndex: 'name', width: 140 }, + { title: '地址', dataIndex: 'address', width: 180 }, { title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '电话', dataIndex: 'contactPhone', width: 120 }, { @@ -242,7 +245,6 @@ export default function CityWarehousesPage() { title: '合伙人', dataIndex: 'partnerCompanyName', width: 120, - ellipsis: true, render: (v, row) => v && row.partnerAccountId ? ( {v} @@ -298,10 +300,17 @@ export default function CityWarehousesPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 仓库 + {settingsButton}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {!permsReady ? ( + + + + ) : null} + {permsReady && canUsers ? ( + + + + + + ) : null} + {permsReady && canUsers ? ( + + + + + + ) : null} + {permsReady && canUsers ? ( + + + + + + ) : null} + {permsReady && canOrders ? ( + + + + + + ) : null} + {permsReady && canStores ? ( + + + + + + ) : null} + {permsReady && canStores ? ( + + 去处理}> + 0 ? '#fa8c16' : undefined }} + /> + + + ) : null} + {permsReady && canStoreAudits ? ( + + 去处理}> + 0 + ? '#fa8c16' + : undefined, + }} + /> + + + ) : null} + {permsReady && canPartners ? ( + + + + + + ) : null} + {permsReady && canBenefit ? ( + + + + + + ) : null} + {permsReady && canDeliveries ? ( + + + + + + ) : null} ({ value: p.id, label: `${p.name}(${p.code})` })), - ]} - /> - - - ({ value: p.id, label: `${p.name}(${p.code})` })), + ]} + /> + + ) : null} + {canPartners ? ( + +
+ ) : null} + ) : null} ); } diff --git a/apps/admin-web/src/pages/DeliveriesPage.tsx b/apps/admin-web/src/pages/DeliveriesPage.tsx index 75e11e2..ed28749 100644 --- a/apps/admin-web/src/pages/DeliveriesPage.tsx +++ b/apps/admin-web/src/pages/DeliveriesPage.tsx @@ -7,6 +7,8 @@ import OrderTrackDrawer from '../components/OrderTrackDrawer'; import { request } from '../lib/api'; import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -59,7 +61,7 @@ export default function DeliveriesPage() { setTrackOpen(true); } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 }, { title: 'provider', dataIndex: 'provider', width: 90 }, { title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' }, @@ -83,16 +85,20 @@ export default function DeliveriesPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 快递/配送单 + {settingsButton}
{ setFilters(v); setPage(1); }}> -
{ setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)} extra={ diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index 2d1d76c..d214eec 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -30,6 +30,8 @@ import { fmtTime } from '../lib/constants'; import { downloadBase64File } from '../lib/exportExcel'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({ value: v, @@ -240,7 +242,7 @@ export default function DevPlanTasksPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '任务号', dataIndex: 'taskNo', width: 160 }, { title: '类型', @@ -256,7 +258,7 @@ export default function DevPlanTasksPage() { {DEV_PLAN_TASK_STATUS_LABELS[s]} ), }, - { title: '内容', dataIndex: 'content', ellipsis: true }, + { title: '内容', dataIndex: 'content' }, { title: '附件', width: 100, @@ -294,12 +296,16 @@ export default function DevPlanTasksPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
开发计划 · 任务列表 + {settingsButton} ({ value, @@ -173,7 +175,7 @@ export default function FulfillmentProvidersPage() { void load(); } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '编码', dataIndex: 'code', width: 100 }, { title: '名称', dataIndex: 'name' }, { @@ -230,13 +232,20 @@ export default function FulfillmentProvidersPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, { + page: 1, + pageSize: rows.length || 20, + }); + return (
+ {settingsModal}
仓配管理 + {settingsButton} 注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准 @@ -253,7 +262,7 @@ export default function FulfillmentProvidersPage() { message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。" /> -
+
= { - SUPER_ADMIN: '超级管理员', - OPS: '运营', - FINANCE: '财务', - CUSTOMER_SERVICE: '客服', -}; +type CityOption = { id: string; name: string }; + +const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label })); +const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label])); export default function HqAccountsPage() { const [profile, setProfile] = useState(null); @@ -46,14 +48,27 @@ export default function HqAccountsPage() { const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [cities, setCities] = useState([]); const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone'; + const editRole = Form.useWatch('adminRole', editForm); + const createRole = Form.useWatch('adminRole', createForm); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; useEffect(() => { request('/admin/auth/me').then(setProfile).catch(() => {}); }, []); - const columns: ColumnsType = [ + useEffect(() => { + if (!isSuperAdmin) return; + request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setCities(res.items)) + .catch(() => {}); + }, [isSuperAdmin]); + + const cityOptions = cities.map((c) => ({ value: c.id, label: c.name })); + + const baseColumns: ColumnsType = [ { title: '姓名', dataIndex: 'name' }, { title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 130 }, @@ -67,7 +82,18 @@ export default function HqAccountsPage() { ), }, - { title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r }, + { title: '角色', dataIndex: 'adminRole', width: 130, render: (r) => ROLE_LABELS[r] || r }, + { + title: '城市', + width: 160, + render: (_, r) => { + if (!r.cityIds?.length) return 全国; + const names = r.cityIds + .map((id) => cities.find((c) => c.id === id)?.name || id) + .join('、'); + return names; + }, + }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} }, { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime }, { @@ -81,6 +107,7 @@ export default function HqAccountsPage() { loginName: row.loginName, adminRole: row.adminRole, status: row.status, + cityIds: row.cityIds ?? [], }); setDrawerOpen(true); }}>编辑 @@ -88,16 +115,20 @@ export default function HqAccountsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize }); + return (
+ {settingsModal} HQ 账户 + {settingsButton} {isSuperAdmin && ( -
{ setPage(p); setPageSize(ps); } }} /> - setDrawerOpen(false)} + setDrawerOpen(false)} extra={ - }>
@@ -144,7 +183,15 @@ export default function HqAccountsPage() { - + + + ({ value, label }))} /> @@ -156,15 +203,19 @@ export default function HqAccountsPage() { open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => { - const v = await createForm.validateFields(); - await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) }); - message.success('已创建'); - setCreateOpen(false); - createForm.resetFields(); - void reload(); + try { + const v = await createForm.validateFields(); + await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) }); + message.success('已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + } catch (e) { + if (e instanceof Error && e.message) message.error(e.message); + } }} > - + 手机号账户(短信登录) @@ -173,7 +224,15 @@ export default function HqAccountsPage() { - + + + - - - - - - - - - - - - -
{ - 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/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx index 15fd956..aed30aa 100644 --- a/apps/admin-web/src/pages/HqPermissionsPage.tsx +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -17,6 +17,7 @@ import { import { HQ_ADMIN_ROLES, HQ_PERMISSION_CATALOG, + computeHqEffectivePermissionKeys, type HqPermissionKey, } from '@dukang/shared-types'; import { request, type HqProfile } from '../lib/api'; @@ -27,6 +28,8 @@ type AccountPermRes = { account: AccountOption; permissionKeys: string[]; rolePermissionKeys: string[]; + grantKeys?: string[]; + denyKeys?: string[]; userPermissionKeys: string[]; effectivePermissionKeys: string[]; }; @@ -46,11 +49,17 @@ function PermissionChecklist({ value, onChange, disabled, + roleKeys, + grantKeys, }: { value: string[]; onChange: (keys: string[]) => void; disabled?: boolean; + roleKeys?: string[]; + grantKeys?: string[]; }) { + const roleSet = new Set(roleKeys ?? []); + const grantSet = new Set(grantKeys ?? []); return ( ( {item.label} + {roleSet.has(item.key) && !value.includes(item.key) ? ( + 已撤销 + ) : grantSet.has(item.key) ? ( + 追加 + ) : roleSet.has(item.key) ? ( + 角色 + ) : null} ))} @@ -88,7 +104,16 @@ function PermissionChecklist({ {items.map((item) => ( - {item.label} + + {item.label} + {roleSet.has(item.key) && !value.includes(item.key) ? ( + 已撤销 + ) : grantSet.has(item.key) ? ( + 追加 + ) : roleSet.has(item.key) ? ( + 角色 + ) : null} + ))} @@ -100,6 +125,14 @@ function PermissionChecklist({ ); } +function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) { + const roleSet = new Set(roleKeys); + const effectiveSet = new Set(effectiveKeys); + const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k)); + const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k)); + return { grantKeys, denyKeys }; +} + export default function HqPermissionsPage() { const [profile, setProfile] = useState(null); const [role, setRole] = useState('OPS'); @@ -111,6 +144,8 @@ export default function HqPermissionsPage() { const [accountId, setAccountId] = useState(); const [accountKeys, setAccountKeys] = useState([]); const [roleInheritedKeys, setRoleInheritedKeys] = useState([]); + const [grantKeys, setGrantKeys] = useState([]); + const [denyKeys, setDenyKeys] = useState([]); const [accountLoading, setAccountLoading] = useState(false); const [accountSaving, setAccountSaving] = useState(false); @@ -121,8 +156,8 @@ export default function HqPermissionsPage() { const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN'; const previewEffectiveKeys = useMemo( - () => [...new Set([...roleInheritedKeys, ...accountKeys])], - [roleInheritedKeys, accountKeys], + () => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys), + [roleInheritedKeys, grantKeys, denyKeys], ); const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; @@ -151,8 +186,10 @@ export default function HqPermissionsPage() { setAccountLoading(true); request(`/admin/hq-permissions/accounts/${accountId}`) .then((res) => { - setAccountKeys(res.userPermissionKeys); + setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []); + setDenyKeys(res.denyKeys ?? []); setRoleInheritedKeys(res.rolePermissionKeys); + setAccountKeys(res.effectivePermissionKeys); }) .finally(() => setAccountLoading(false)); }, [isSuperAdmin, accountId]); @@ -179,10 +216,12 @@ export default function HqPermissionsPage() { try { const res = await request(`/admin/hq-permissions/accounts/${accountId}`, { method: 'PUT', - body: JSON.stringify({ permissionKeys: accountKeys }), + body: JSON.stringify({ grantKeys, denyKeys }), }); - setAccountKeys(res.userPermissionKeys); + setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []); + setDenyKeys(res.denyKeys ?? []); setRoleInheritedKeys(res.rolePermissionKeys); + setAccountKeys(res.effectivePermissionKeys); message.success('用户权限已保存'); } catch (e) { message.error(e instanceof Error ? e.message : '保存失败'); @@ -204,8 +243,9 @@ export default function HqPermissionsPage() {
权限分配 - 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。 + 按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。 + 运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。 @@ -275,59 +315,51 @@ export default function HqPermissionsPage() { ) : ( <> -
- 角色继承: - {selectedIsSuperAdmin ? ( - 超级管理员基础权限(不含危险操作) - ) : ( - - {roleInheritedKeys.map((key) => { - const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); - return ( - - {item?.label || key} - - ); - })} - {!roleInheritedKeys.length ? ( - - ) : null} - - )} -
- 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。 + 勾选表示该账号最终拥有该权限。取消角色已有项即为撤销;勾选角色没有的项即为追加。 + 切换角色会清空账号级追加/撤销。 - + { + if (selectedIsSuperAdmin) return; + const next = splitAccountOverrides(roleInheritedKeys, nextEffective); + setGrantKeys(next.grantKeys); + setDenyKeys(next.denyKeys); + setAccountKeys(nextEffective); + }} + /> + {selectedIsSuperAdmin ? ( + + ) : null}
合并生效: - {selectedIsSuperAdmin ? ( - - 基础权限(全部) - {accountKeys.map((key) => { - const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); - return ( - - {item?.label || key} - - ); - })} - - ) : ( - - {previewEffectiveKeys.map((key) => { - const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); - return ( - - {item?.label || key} - - ); - })} - - )} + + {previewEffectiveKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); + return ( + + {item?.label || key} + + ); + })} +
-
diff --git a/apps/admin-web/src/pages/InvoicesPage.tsx b/apps/admin-web/src/pages/InvoicesPage.tsx index 22dbc4a..354ab69 100644 --- a/apps/admin-web/src/pages/InvoicesPage.tsx +++ b/apps/admin-web/src/pages/InvoicesPage.tsx @@ -30,6 +30,8 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import { uploadFileToOss } from '../lib/upload'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -181,7 +183,7 @@ export default function InvoicesPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '申请单号', dataIndex: 'invoiceNo', width: 180 }, { title: '订单号', dataIndex: 'orderNo', width: 160 }, { @@ -202,7 +204,7 @@ export default function InvoicesPage() { ? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory : '—', }, - { title: '名称', dataIndex: 'titleName', ellipsis: true }, + { title: '名称', dataIndex: 'titleName' }, { title: '状态', width: 110, @@ -227,8 +229,11 @@ export default function InvoicesPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
发票管理 + {settingsButton}
= [ + const baseColumns: ColumnsType = [ { title: '商品', width: 240, @@ -686,11 +687,18 @@ export default function OrdersPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 订单监控 + {settingsButton} {canProxyOrder ? (
+ dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 'max-content' }} /> ), }, { @@ -407,7 +414,7 @@ export default function PartnerAccountsPage() { label: `名下订单 (${detail.orders?.length ?? 0})`, children: (
+ dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} /> ), }, ]} /> diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index 5ca09ac..cb97582 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -1,576 +1,579 @@ -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' && ( + + )} + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize }); -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 columns: ColumnsType = [ - { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, - { - title: '合伙人', - width: 160, - ellipsis: true, - 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, - ellipsis: true, - render: (_, r) => partnerBank(r)?.bankAccountName || '—', - }, - { - title: '收款账号', - width: 140, - ellipsis: true, - 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' && ( - - )} - - ), - }, - ]; - - return ( + return (
- - - 合伙人账单 + {settingsModal} + + + 合伙人账单 - - 每月 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: 1100 }} - /> - 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 23a9425..2590e71 100644 --- a/apps/admin-web/src/pages/PartnersPage.tsx +++ b/apps/admin-web/src/pages/PartnersPage.tsx @@ -1,405 +1,410 @@ -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) => ( + + ), + }, + ]; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize }); -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 columns: ColumnsType = [ - { title: '城市', dataIndex: 'cityName', width: 100 }, - { - title: '区县', - dataIndex: 'districtCodes', - width: 160, - ellipsis: true, - render: (codes: string[] | null | undefined, row) => - row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes), - }, - { title: '公司名', dataIndex: 'companyName', ellipsis: true }, - { 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) => ( - - ), - }, - ]; - - return ( + return (
- + {settingsModal} + 开城合伙人 - - -
{ 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 e466488..ab43500 100644 --- a/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx +++ b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx @@ -13,6 +13,8 @@ import { useAdminList } from '../lib/useAdminList'; 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'; + type Row = ProductDetailTemplateDto; @@ -136,10 +138,10 @@ export default function ProductDetailTemplatesPage() { const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '编码', dataIndex: 'code', width: 120 }, { title: '名称', dataIndex: 'name', width: 120 }, - { title: '说明', dataIndex: 'description', width: 200, ellipsis: true }, + { 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 }, { title: '排序', dataIndex: 'sortOrder', width: 60 }, @@ -158,10 +160,17 @@ export default function ProductDetailTemplatesPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('product-detail-templates', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 商品详情模板 + {settingsButton} { setFilters(v); setPage(1); }}> @@ -175,7 +184,7 @@ export default function ProductDetailTemplatesPage() { -
{ setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)} extra={detail && ( diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index 9062a8c..e345cd8 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -14,6 +14,8 @@ import DetailImageUrlList from '../components/DetailImageUrlList'; import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker'; import ProductSpecsEditor from '../components/ProductSpecsEditor'; import type { FormInstance } from 'antd/es/form'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type ProductDetailContentDto = { storyTitle?: string; @@ -387,9 +389,9 @@ export default function ProductsPage() { } } - const columns: ColumnsType = useMemo(() => [ + const baseColumns: ColumnsType = useMemo(() => [ { title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v }, - { title: '品名', dataIndex: 'name', width: 200, ellipsis: true }, + { title: '品名', dataIndex: 'name', width: 200 }, { title: '累计销售', dataIndex: 'soldBottles', @@ -458,7 +460,6 @@ export default function ProductsPage() { title: '规格', dataIndex: 'specText', width: 140, - ellipsis: true, render: (v: string, row) => ( {v || '默认'} @@ -468,7 +469,7 @@ export default function ProductsPage() { ), }, { title: 'SKU', dataIndex: 'skuCode', width: 110 }, - { title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true }, + { title: '69码', dataIndex: 'barcode69', width: 160 }, { title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` }, { title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` }, { @@ -493,10 +494,17 @@ export default function ProductsPage() { }, ], []); + const { columns, settingsButton, settingsModal } = useAdminListColumns('products', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 商品管理 + {settingsButton}
{ setFilters(v); setPage(1); }}> @@ -517,7 +525,7 @@ export default function ProductsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1080 }} + scroll={{ x: 'max-content' }} expandable={{ rowExpandable: () => true, expandedRowRender: (row) => ( @@ -528,7 +536,7 @@ export default function ProductsPage() { pagination={false} columns={skuColumns} dataSource={row.skus ?? []} - scroll={{ x: 980 }} + scroll={{ x: 'max-content' }} style={{ background: 'transparent' }} />
diff --git a/apps/admin-web/src/pages/PromoCodesPage.tsx b/apps/admin-web/src/pages/PromoCodesPage.tsx index 094a008..0a5818b 100644 --- a/apps/admin-web/src/pages/PromoCodesPage.tsx +++ b/apps/admin-web/src/pages/PromoCodesPage.tsx @@ -14,6 +14,8 @@ import { import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = PromoCodeItem; @@ -74,8 +76,8 @@ export default function PromoCodesPage() { void loadScenes(); }, []); - const columns: ColumnsType = [ - { title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, + const baseColumns: ColumnsType = [ + { title: '名称', dataIndex: 'name', width: 160 }, { title: '码值', dataIndex: 'code', width: 110 }, { title: '场景', @@ -160,10 +162,14 @@ export default function PromoCodesPage() { } } + const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
推广码管理 + {settingsButton}
@@ -194,7 +200,7 @@ export default function PromoCodesPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/RedeemRecordsPage.tsx b/apps/admin-web/src/pages/RedeemRecordsPage.tsx index 54b69e4..4a70e76 100644 --- a/apps/admin-web/src/pages/RedeemRecordsPage.tsx +++ b/apps/admin-web/src/pages/RedeemRecordsPage.tsx @@ -7,6 +7,8 @@ import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDesc import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -75,7 +77,7 @@ export default function RedeemRecordsPage() { } }, [data, initialRedeemNo, loading]); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '核销号', dataIndex: 'redeemNo', @@ -133,9 +135,13 @@ export default function RedeemRecordsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 核销记录 + {settingsButton} = [ + const baseColumns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 80 }, { title: '归属', @@ -84,8 +86,8 @@ export default function ResourcesPage() { ), }, - { title: 'URL', dataIndex: 'url', ellipsis: true }, - { title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 }, + { title: 'URL', dataIndex: 'url' }, + { title: 'OSS Key', dataIndex: 'ossKey', width: 160 }, { title: '排序', dataIndex: 'sortOrder', width: 60 }, { title: '状态', @@ -114,12 +116,16 @@ export default function ResourcesPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize }); + return (
+ {settingsModal} OSS 资源库 + {settingsButton} @@ -170,7 +176,7 @@ export default function ResourcesPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index 74606d4..c8038af 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -6,6 +6,8 @@ import type { ColumnsType } from 'antd/es/table'; 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'; + type StoreBrief = { id: string; name: string; status: string; cityName?: string }; @@ -74,7 +76,7 @@ export default function StoreAccountsPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '姓名', dataIndex: 'name', @@ -138,11 +140,15 @@ export default function StoreAccountsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 门店账户 + {settingsButton} 主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店 @@ -185,7 +191,7 @@ export default function StoreAccountsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1100 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index 5ae17a0..aec8278 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -1,685 +1,688 @@ -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; + + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize }); -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 columns: ColumnsType = [ - { - title: '类型', - dataIndex: 'kind', - width: 100, - render: (k: StoreSettlementKind) => ( - - {STORE_SETTLEMENT_KIND_LABELS[k] ?? k} - - ), - }, - { - title: '单号', - dataIndex: 'billNo', - width: 180, - ellipsis: true, - 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, ellipsis: true }, - { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, - { title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, - { - title: '收款户名', - width: 100, - ellipsis: true, - render: (_, row) => - row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—', - }, - { - title: '收款账号', - width: 140, - ellipsis: true, - 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; - - return ( + return (
- - - 门店账单 + {settingsModal} + + + 门店账单 - - 含 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: 1300 }} - 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 7838554..c5f6749 100644 --- a/apps/admin-web/src/pages/StoreCategoriesPage.tsx +++ b/apps/admin-web/src/pages/StoreCategoriesPage.tsx @@ -3,7 +3,9 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { request } from '../lib/api'; +import { request, type HqProfile } from '../lib/api'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type CategoryNode = { id: string; @@ -38,7 +40,9 @@ export default function StoreCategoriesPage() { const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); + const [profile, setProfile] = useState(null); const [form] = Form.useForm(); + const canDelete = (profile?.permissionKeys ?? []).includes('store_categories_delete'); const rows = useMemo(() => flattenTree(tree), [tree]); const rootOptions = useMemo( @@ -60,6 +64,7 @@ export default function StoreCategoriesPage() { useEffect(() => { void reload(); + request('/admin/auth/me').then(setProfile).catch(() => {}); }, []); function openCreate(parentId?: string) { @@ -112,7 +117,7 @@ export default function StoreCategoriesPage() { void reload(); } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '层级', dataIndex: 'level', @@ -141,45 +146,56 @@ export default function StoreCategoriesPage() { }, { title: '操作', - width: 220, + width: canDelete ? 220 : 160, render: (_, row) => ( {row.level === 1 ? ( ) : null} - { - await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); - message.success('已处理'); - void reload(); - }} - > - - + {canDelete ? ( + { + await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' }); + message.success('已处理'); + void reload(); + }} + > + + + ) : null} ), }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('store-categories', baseColumns, { + page: 1, + pageSize: rows.length || 20, + }); + return (
+ {settingsModal} 门店分类 + {settingsButton} 两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择 - + {canDelete ? ( + + ) : null} diff --git a/apps/admin-web/src/pages/StoreLogsPage.tsx b/apps/admin-web/src/pages/StoreLogsPage.tsx index 99fb995..d4a4151 100644 --- a/apps/admin-web/src/pages/StoreLogsPage.tsx +++ b/apps/admin-web/src/pages/StoreLogsPage.tsx @@ -12,6 +12,8 @@ import { request } from '../lib/api'; import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -80,14 +82,14 @@ export default function StoreLogsPage() { form.setFieldsValue(filters); }, [form, filters]); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '门店', width: 180, ellipsis: true, + title: '门店', width: 180, render: (_, r) => , }, { - title: '账号', width: 150, ellipsis: true, + title: '账号', width: 150, render: (_, r) => ( (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), }, { - title: '摘要', ellipsis: true, + title: '摘要', render: (_, r) => summarizeExtra(r.extraJson), }, { @@ -127,9 +129,13 @@ export default function StoreLogsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 商户日志 + {settingsButton} 门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。 @@ -164,7 +170,7 @@ export default function StoreLogsPage() { -
{ setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)}> {detail && ( diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx index 5549185..961b7b7 100644 --- a/apps/admin-web/src/pages/StoreMediaPage.tsx +++ b/apps/admin-web/src/pages/StoreMediaPage.tsx @@ -7,6 +7,8 @@ import { request, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; mediaType: string; url: string; sortOrder: number; createdAt: string; @@ -40,7 +42,7 @@ export default function StoreMediaPage() { setStores(res.items); } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '门店', dataIndex: ['store', 'name'], width: 140 }, { title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => {MEDIA_TYPE_LABELS[t] || t} }, { @@ -49,7 +51,7 @@ export default function StoreMediaPage() { ? : 视频, }, - { title: 'URL', dataIndex: 'url', ellipsis: true }, + { title: 'URL', dataIndex: 'url' }, { title: '排序', dataIndex: 'sortOrder', width: 70 }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { @@ -73,10 +75,14 @@ export default function StoreMediaPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 门店资源 + {settingsButton}
{ setFilters(v); setPage(1); }}> @@ -86,7 +92,7 @@ export default function StoreMediaPage() { -
{ setPage(p); setPageSize(ps); } }} /> setCreateOpen(false)} onOk={async () => { const v = await createForm.validateFields(); diff --git a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx index b1273fb..970ce0b 100644 --- a/apps/admin-web/src/pages/StorePackageAuditsPage.tsx +++ b/apps/admin-web/src/pages/StorePackageAuditsPage.tsx @@ -32,6 +32,7 @@ import { request, type Paginated } from '../lib/api'; import { notifyPackageAuditChanged } from '../lib/admin-events'; import { fmtTime } from '../lib/constants'; import StorePackageAuditPanel from '../components/StorePackageAuditPanel'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; const HQ_PACKAGE_STATUS_LABELS: Record = { PENDING: '待审核', @@ -469,7 +470,7 @@ export default function StorePackageAuditsPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId }, { title: '状态', @@ -519,9 +520,16 @@ export default function StorePackageAuditsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, { + page, + pageSize: 20, + }); + return (
+ {settingsModal} 审核通知 + {settingsButton} ([]); const [filters, setFilters] = useState>({ ...(initialStoreId ? { storeId: initialStoreId } : {}), }); + useEffect(() => { + request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setCities(res.items)) + .catch(() => {}); + }, []); + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( '/admin/store-ratings', () => { const qs = new URLSearchParams(); if (filters.storeId) qs.set('storeId', filters.storeId); + if (filters.cityId) qs.set('cityId', filters.cityId); if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo); return qs; }, [filters], ); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '核销号', dataIndex: 'redeemNo', width: 180 }, { title: '门店', @@ -53,11 +64,15 @@ export default function StoreRatingsPage() { { title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 门店评价 + {settingsButton}
{ setFilters({ storeId: v.storeId || '', + cityId: v.cityId || '', redeemNo: v.redeemNo || '', }); setPage(1); }} > + + diff --git a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx index 3c163e3..0c61cd4 100644 --- a/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx +++ b/apps/admin-web/src/pages/StoreWithdrawalsPage.tsx @@ -20,6 +20,8 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/ 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'; + type Row = { id: string; @@ -147,7 +149,7 @@ export default function StoreWithdrawalsPage() { }); } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '提现单号', dataIndex: 'withdrawNo', @@ -229,11 +231,15 @@ export default function StoreWithdrawalsPage() { } | undefined; + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 门店提现审 + {settingsButton} {overdueSummary ? ( 待审 {overdueSummary.pendingCount} 笔 @@ -299,7 +305,7 @@ export default function StoreWithdrawalsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1100 }} + scroll={{ x: 'max-content' }} rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')} pagination={{ current: page, diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 5c7243c..0c86f55 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -39,6 +39,7 @@ import { type StoreCreateForm, } from '../lib/storeCreate'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; import { resolveRegionBinding } from '../lib/china-region'; import ChinaRegionCascader from '../components/ChinaRegionCascader'; import OssUpload from '../components/OssUpload'; @@ -198,6 +199,9 @@ type StoreRow = { pendingPackageAuditId?: string | null; /** 该门店当前待审核信息变更的 requestId(无则为空) */ pendingInfoChangeId?: string | null; + /** 累计核销好客权益(券面额合计) */ + totalRedeemedBenefitAmount?: number; + contactPhone?: string | null; isTest?: boolean; cityRef?: { name: string; code: string }; partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null }; @@ -720,47 +724,60 @@ export default function StoresPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { + key: 'coverUrl', title: '封面', dataIndex: 'coverUrl', width: 72, render: (url) => url ? : '—', }, { + key: 'name', title: '门店名', dataIndex: 'name', width: 180, - ellipsis: { showTitle: false }, render: (v: string, row) => { const name = v || '—'; return ( - - - {name} - + + {name} {row.isTest ? 测试 : null} ); }, }, { + key: 'category', title: '分类', width: 100, - ellipsis: true, render: (_, row) => row.category?.name || '—', }, - { title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true }, - { title: '登录号', dataIndex: 'phone', width: 120 }, + { key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 }, + { key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 }, { + key: 'contactPhone', title: '联系电话', dataIndex: 'contactPhone', width: 120, render: (v: string | null | undefined, row) => v || row.phone, }, { + key: 'status', title: '营业状态', dataIndex: 'status', width: 90, render: (s) => {STORE_STATUS_LABELS[s] || s}, }, { + key: 'totalRedeemedBenefitAmount', + title: '累计核销好客权益', + dataIndex: 'totalRedeemedBenefitAmount', + width: 150, + align: 'right', + render: (v: number | undefined) => { + const n = Number(v ?? 0); + return n > 0 ? `¥${n.toFixed(2)}` : '—'; + }, + }, + { + key: 'auditStatus', title: '审核', dataIndex: 'auditStatus', width: 100, render: (s, row) => { const status = s || 'APPROVED'; @@ -769,7 +786,7 @@ export default function StoresPage() { {STORE_AUDIT_STATUS_LABELS[status] || status} {status === 'REJECTED' && row.rejectReason ? ( - + {row.rejectReason} ) : null} @@ -778,32 +795,29 @@ export default function StoresPage() { }, }, { + key: 'partner', title: '开城合伙人', dataIndex: 'partner', width: 140, - ellipsis: { showTitle: false }, render: (partner: StoreRow['partner']) => { if (!partner) return '—'; - const label = partnerOptionLabel({ id: partner.id ?? '', ...partner }); - return ( - - {label} - - ); + return partnerOptionLabel({ id: partner.id ?? '', ...partner }); }, }, { + key: 'visibilityWhitelistEnabled', title: '可见', dataIndex: 'visibilityWhitelistEnabled', width: 90, render: (v) => v ? 限测 : 公开, }, - { title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' }, - { title: '排序', dataIndex: 'sortOrder', width: 70 }, - { title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' }, - { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { key: 'intro', title: '介绍', dataIndex: 'intro', width: 160, render: (v) => v || '—' }, + { key: 'sortOrder', title: '排序', dataIndex: 'sortOrder', width: 70 }, + { key: 'accountName', title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' }, + { key: 'createdAt', title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { + key: 'actions', title: '操作', width: 280, fixed: 'right', @@ -850,14 +864,23 @@ export default function StoresPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('stores', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 门店 共 {data?.total ?? 0} 家门店(含合伙人录入) - + + {settingsButton} + + { setFilters(v); setPage(1); }}> @@ -918,7 +941,7 @@ export default function StoresPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1720 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index 2b25a62..0440c56 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -46,6 +46,8 @@ import { request, type HqProfile } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom'; const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程'; @@ -470,7 +472,7 @@ export default function SupportTicketsPage() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '类型', @@ -503,7 +505,7 @@ export default function SupportTicketsPage() { ), }, - { title: '标题', dataIndex: 'title', ellipsis: true }, + { title: '标题', dataIndex: 'title' }, { title: '创建人', dataIndex: 'creatorName', width: 100 }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { @@ -546,8 +548,11 @@ export default function SupportTicketsPage() { return null; })(); + const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
技术支持 + {settingsButton} {isSuperAdmin ? ( <> @@ -619,7 +625,7 @@ export default function SupportTicketsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 900 }} + scroll={{ x: 'max-content' }} rowSelection={ isSuperAdmin ? { diff --git a/apps/admin-web/src/pages/TestWhitelistPage.tsx b/apps/admin-web/src/pages/TestWhitelistPage.tsx index 17e31fa..00b9d72 100644 --- a/apps/admin-web/src/pages/TestWhitelistPage.tsx +++ b/apps/admin-web/src/pages/TestWhitelistPage.tsx @@ -20,6 +20,7 @@ import { import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; import { fmtTime } from '../lib/constants'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const; @@ -257,7 +258,6 @@ export default function TestWhitelistPage() { { title: '备注', dataIndex: 'note', - ellipsis: true, render: (v) => v || '—', }, { @@ -334,7 +334,7 @@ export default function TestWhitelistPage() { if (accountType === 'partner') { return [ { title: '姓名', dataIndex: 'name', width: 100 }, - { title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' }, + { title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' }, { title: '手机', dataIndex: 'phone', width: 120 }, { title: '标记', @@ -347,7 +347,7 @@ export default function TestWhitelistPage() { } if (accountType === 'store') { return [ - { title: '门店名', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '门店名', dataIndex: 'name', width: 160 }, { title: '城市', dataIndex: 'cityName', width: 90 }, { title: '电话', dataIndex: 'phone', width: 120 }, { title: '状态', dataIndex: 'status', width: 90 }, @@ -386,11 +386,18 @@ export default function TestWhitelistPage() { ]; } + const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, { + page: phonePage, + pageSize: phonePageSize, + }); + return (
+ {settingsModal} 白名单管理 + {settingsButton} @@ -472,7 +479,7 @@ export default function TestWhitelistPage() {
| null>(null); const [drawerOpen, setDrawerOpen] = useState(false); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: 'Provider', dataIndex: 'provider', width: 120 }, { title: '场景', dataIndex: 'scene', width: 120 }, @@ -87,12 +89,10 @@ export default function ThirdPartyLogsPage() { { title: '错误信息', dataIndex: 'errorMessage', - ellipsis: true, render: (v: string | null | undefined) => v || '—', }, { title: '请求摘要', - ellipsis: true, render: (_, r) => summarizeJson(r.requestBody), }, { title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' }, @@ -114,9 +114,16 @@ export default function ThirdPartyLogsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 第三方日志 + {settingsButton} = [ + const baseColumns: ColumnsType = [ { title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '类型', @@ -132,7 +134,7 @@ export default function TicketsPage() { ), }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, - { title: '备注', dataIndex: 'remark', ellipsis: true }, + { title: '备注', dataIndex: 'remark' }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作', @@ -154,8 +156,11 @@ export default function TicketsPage() { const evidenceUrls = detail?.extraJson?.evidenceUrls ?? []; + const { columns, settingsButton, settingsModal } = useAdminListColumns('tickets', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
工单中心 + {settingsButton} @@ -205,7 +211,7 @@ export default function TicketsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 900 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/UserLogsPage.tsx b/apps/admin-web/src/pages/UserLogsPage.tsx index c66044e..8aabb6b 100644 --- a/apps/admin-web/src/pages/UserLogsPage.tsx +++ b/apps/admin-web/src/pages/UserLogsPage.tsx @@ -7,6 +7,8 @@ import { request } from '../lib/api'; import { AdminCellLine } from '../components/AdminCellLine'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -61,10 +63,10 @@ export default function UserLogsPage() { form.setFieldsValue(filters); }, [form, filters]); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '用户', width: 180, ellipsis: true, + title: '用户', width: 180, render: (_, r) => ( (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'), }, { - title: '摘要', ellipsis: true, + title: '摘要', render: (_, r) => summarizeExtra(r.extraJson), }, { @@ -98,9 +100,13 @@ export default function UserLogsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 用户日志 + {settingsButton} ({ value: o.value, label: o.label }))} @@ -131,7 +137,7 @@ export default function UserLogsPage() { -
{ setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)}> {detail && ( diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index c828398..fd8c8c0 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Alert, @@ -19,6 +19,7 @@ import { 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 { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; type UserOrderRow = { @@ -30,6 +31,77 @@ type UserOrderRow = { createdAt: string; }; +function NicknameCell({ + id, + nickname, + onSaved, +}: { + id: string; + nickname: string | null; + onSaved: (id: string, nickname: string | null) => void; +}) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(nickname ?? ''); + const savingRef = useRef(false); + + useEffect(() => { + if (!editing) setValue(nickname ?? ''); + }, [nickname, editing]); + + async function commit() { + if (savingRef.current) return; + const next = value.trim() || null; + const prev = nickname?.trim() || null; + setEditing(false); + if (next === prev) return; + savingRef.current = true; + try { + const res = await request<{ id: string; nickname: string | null }>(`/admin/users/${id}`, { + method: 'PUT', + body: JSON.stringify({ nickname: next ?? '' }), + }); + onSaved(id, res.nickname); + message.success('昵称已保存'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + setValue(nickname ?? ''); + } finally { + savingRef.current = false; + } + } + + if (editing) { + return ( + setValue(e.target.value)} + onBlur={() => void commit()} + onPressEnter={() => void commit()} + onKeyDown={(e) => { + if (e.key === 'Escape') void commit(); + }} + onClick={(e) => e.stopPropagation()} + /> + ); + } + + return ( + { + e.stopPropagation(); + setEditing(true); + }} + style={{ cursor: 'text', display: 'inline-block', minWidth: 48 }} + > + {nickname || '—'} + + ); +} + /** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */ function fmtBenefit(v: number | null | undefined) { const n = Number(v ?? 0); @@ -244,7 +316,7 @@ export default function UsersPage() { { title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, ]; - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '用户编号', dataIndex: 'userNo', @@ -256,12 +328,30 @@ export default function UsersPage() { ), }, - { title: '昵称', dataIndex: 'nickname', width: 100 }, + { + title: '昵称', + dataIndex: 'nickname', + width: 140, + render: (v: string | null, row) => ( + { + setData((prev) => + prev + ? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, nickname } : u)) } + : prev, + ); + setDetail((d) => (d && d.id === id ? { ...d, nickname } : d)); + }} + /> + ), + }, { title: '手机', dataIndex: 'phone', width: 120, - render: (v) => maskPhone(v), + render: (v) => v || '—', }, { title: '验手机', @@ -289,7 +379,6 @@ export default function UsersPage() { title: '来源 ID', dataIndex: 'sourceRefId', width: 100, - ellipsis: true, render: (v, row) => { if (!v) return '—'; if (row.sourceType === 'PROMO_CODE') { @@ -306,13 +395,11 @@ export default function UsersPage() { title: '来源标签', dataIndex: 'sourceLabel', width: 120, - ellipsis: true, render: (v) => v || '—', }, { title: 'deviceKey', dataIndex: 'deviceKey', - ellipsis: true, render: (v) => v || '—', }, { @@ -377,10 +464,18 @@ export default function UsersPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal} 用户监控 + + {settingsButton} {canDeleteUsers ? ( + ), + }, + ]; + + 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 }); -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 columns: ColumnsType = [ - { - title: 'unionId', - dataIndex: 'unionId', - width: 180, - ellipsis: true, - 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: '身份摘要', - ellipsis: true, - 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: '账号', - ellipsis: true, - render: (_, r) => ( - - ), - }, - { - title: '归属', - dataIndex: 'refLabel', - width: 160, - ellipsis: true, - render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), - }, - { - title: 'wxOpenId', - dataIndex: 'wxOpenId', - width: 160, - ellipsis: true, - }, - { - 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), - }, - ]; - - return ( + return (
+ {settingsModal} 微信绑定总览 - - 按 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 b98886a..7f96d6b 100644 --- a/apps/admin-web/src/pages/WecomBotLogsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotLogsPage.tsx @@ -5,6 +5,8 @@ import { useAdminList } from '../lib/useAdminList'; import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import type { WecomBotLogDto } from '@dukang/shared-types'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + export default function WecomBotLogsPage() { const [form] = Form.useForm(); @@ -24,12 +26,12 @@ export default function WecomBotLogsPage() { [filters], ); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, - { title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' }, - { title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true }, - { title: '动作', dataIndex: 'action', width: 160, ellipsis: true }, - { title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' }, + { title: '机器人', dataIndex: 'botName', width: 120, render: (v, r) => v || r.botKey || '—' }, + { title: '企微用户', dataIndex: 'wecomUserId', width: 120 }, + { title: '动作', dataIndex: 'action', width: 160 }, + { title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' }, { title: '结果', dataIndex: 'success', @@ -37,7 +39,7 @@ export default function WecomBotLogsPage() { render: (v: boolean) => {v ? '成功' : '失败'}, }, { title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' }, - { title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true }, + { title: '输入摘要', dataIndex: 'inputSummary' }, { title: '操作', width: 80, @@ -49,9 +51,13 @@ export default function WecomBotLogsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize }); + return (
+ {settingsModal} 企微机器人日志 + {settingsButton} 记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。 @@ -96,7 +102,7 @@ export default function WecomBotLogsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1100 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/WecomBotsPage.tsx b/apps/admin-web/src/pages/WecomBotsPage.tsx index 1ea6632..efbbe9e 100644 --- a/apps/admin-web/src/pages/WecomBotsPage.tsx +++ b/apps/admin-web/src/pages/WecomBotsPage.tsx @@ -36,6 +36,8 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type FormValues = { name: string; @@ -280,7 +282,7 @@ export default function WecomBotsPage() { const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b])); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '头像', dataIndex: 'avatarUrl', @@ -291,18 +293,17 @@ export default function WecomBotsPage() { ), }, - { title: '名称', dataIndex: 'name', width: 140, ellipsis: true }, + { title: '名称', dataIndex: 'name', width: 140 }, { title: '角色', dataIndex: 'role', width: 120, render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r, }, - { title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true }, + { title: 'BotID', dataIndex: 'botId', width: 160 }, { title: '权限', dataIndex: 'permissions', - ellipsis: true, render: (perms: WecomBotPermission[]) => perms.map((p) => ( @@ -367,13 +368,20 @@ export default function WecomBotsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-bots', baseColumns, { + page, + pageSize, + }); + return (
+ {settingsModal}
企微机器人 + {settingsButton} 并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。 {runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''} @@ -428,7 +436,7 @@ export default function WecomBotsPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, diff --git a/apps/admin-web/src/pages/WecomMessagePushesPage.tsx b/apps/admin-web/src/pages/WecomMessagePushesPage.tsx index 649bbdd..580d564 100644 --- a/apps/admin-web/src/pages/WecomMessagePushesPage.tsx +++ b/apps/admin-web/src/pages/WecomMessagePushesPage.tsx @@ -34,6 +34,8 @@ import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type FormValues = { name: string; @@ -193,7 +195,7 @@ function PushRoutesTab() { } } - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '名称', dataIndex: 'name', @@ -207,7 +209,6 @@ function PushRoutesTab() { { title: 'Webhook', dataIndex: 'webhookUrlMasked', - ellipsis: true, }, { title: '推送条件', @@ -262,8 +263,14 @@ function PushRoutesTab() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, { + page, + pageSize, + }); + return ( <> + {settingsModal} 配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。 @@ -304,6 +311,7 @@ function PushRoutesTab() { + {settingsButton} diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index 22c1d20..e9d0df1 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -26,6 +26,8 @@ import { fmtTime } from '../lib/constants'; import { downloadExcelCsv } from '../lib/exportExcel'; import { request, type HqProfile } from '../lib/api'; import { useAdminList } from '../lib/useAdminList'; +import { useAdminListColumns } from '../lib/useAdminListColumns'; + type Row = { id: string; @@ -235,8 +237,8 @@ export default function WineryBillsPage() { const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id)); const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0); - const columns: ColumnsType = [ - { title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true }, + const baseColumns: ColumnsType = [ + { title: '账单号', dataIndex: 'billNo', width: 170 }, { title: '账单日', dataIndex: 'billDate', @@ -290,8 +292,11 @@ export default function WineryBillsPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize }); + return (
+ {settingsModal}
酒厂对账单 + {settingsButton} T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细 @@ -398,7 +404,7 @@ export default function WineryBillsPage() { onChange: setSelectedKeys, getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }), }} - scroll={{ x: 1100 }} + scroll={{ x: 'max-content' }} pagination={{ current: page, pageSize, @@ -456,7 +462,7 @@ export default function WineryBillsPage() { pagination={false} dataSource={detail.items ?? []} columns={[ - { title: '订单号', dataIndex: 'orderNo', ellipsis: true }, + { title: '订单号', dataIndex: 'orderNo' }, { title: '配送', dataIndex: 'deliveryType', diff --git a/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx index 4860bcc..9d325d3 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx @@ -9,6 +9,8 @@ import { import { fmtTime } from '../../lib/constants'; import { useAdminList } from '../../lib/useAdminList'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; +import { useAdminListColumns } from '../../lib/useAdminListColumns'; + export default function PromoCodeUsersPage() { const { id = '' } = useParams(); @@ -21,7 +23,7 @@ export default function PromoCodeUsersPage() { [id], ); - const columns: ColumnsType = [ + const baseColumns: ColumnsType = [ { title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, @@ -69,6 +71,8 @@ export default function PromoCodeUsersPage() { }, ]; + const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize }); + return (
**2026-08-24** · admin-web / API +> **主题**:HQ 单账号追加/撤销权限、运营改客服修复、城市门店服务角色与城市范围 + +--- + +## 1. 版本目标 + +1. **单账号权限** 在角色基础上可追加,也可撤销某项功能。 +2. **运营 → 客服** 可保存成功,且生效权限跟随新角色(清空账号级追加/撤销)。 +3. **新分组「城市门店服务」**:默认门店列表、审核通知、评价、分类;按账号勾选城市,非勾选城市的门店不可见。 +4. 任意 HQ 账号都可绑城市;**未勾选 = 全国**。该新角色 **必须至少 1 个城市**。 +5. 超管始终全权限、全国可见,不受撤销/城市限制。 + +**不做**:C 端 / 合伙人 / 门店端;按区分权;小程序版本号。 + +--- + +## 2. 权限模型 + +公式:`生效权限 = (角色权限 ∪ 账号追加) − 账号撤销` + +| 层 | 存储 | 说明 | +|----|------|------| +| 角色 | `hq_role_permission` | 分组默认能力,超管基础权限固定 | +| 账号追加 | `hq_account_permission.effect=GRANT` | 在角色之上加点权限 | +| 账号撤销 | `hq_account_permission.effect=DENY` | 去掉角色已有的某项 | + +同一账号同一权限键只能 GRANT 或 DENY。切换角色时清空该账号 GRANT/DENY。 + +### 2.1 角色 + +| 值 | 标签 | 默认 | +|----|------|------| +| SUPER_ADMIN | 超级管理员 | 目录全部(含调试;危险操作需按用户勾选) | +| OPS | 运营 | 与现网一致,且含拆分后的门店子权限 | +| FINANCE | 财务 | 同上(含门店子权限) | +| CUSTOMER_SERVICE | 客服 | dashboard / users / orders / tickets / tech_support / invoices / logs | +| CITY_STORE_SERVICE | 城市门店服务 | dashboard、stores、store_audits、store_ratings、store_categories(**无** store_categories_delete) | + +### 2.2 门店权限拆分 + +| 键 | 菜单 | +|----|------| +| `stores` | 门店列表(含入驻审核) | +| `store_audits` | 审核通知(套餐变更 / 信息变更) | +| `store_ratings` | 门店评价 | +| `store_categories` | 门店分类(全国字典,不按城过滤;可新增/编辑) | +| `store_categories_delete` | 删除门店分类 / 同步默认分类(危险操作;运营/财务默认有,城市门店服务无) | +| `store_accounts` | 门店账户 | +| `store_media` | 门店资源 | + +存量角色/账号若已有 `stores`,迁移一次性补齐后 5 键(行为与拆分前一致)。新角色不补账户/资源。删除分类键另按「非城市门店服务 + 已有分类权限」补齐。 + +--- + +## 3. 城市范围 + +- 表 `hq_account_city`;空列表且非「城市门店服务」= 全国。 +- 「城市门店服务」保存时必须 ≥1 城;若数据异常无城市,则看不到任何门店。 +- 有范围时:列表/详情/审核/评价/账户/资源均 `Store.cityId IN (...)`;越权 403「无权访问该城市的门店」。 +- 概览 `GET /admin/dashboard/stats|analytics`:按生效权限裁剪指标;有城市范围时只统计负责城市(无「未选城」)。 +- `GET /admin/auth/me` 返回 `cityIds`、`cityScoped`。 + +--- + +## 4. 运营改客服修复 + +1. 短信账号 `loginName` 为空时,编辑提交空字符串不再报「用户名不能为空」(视为不改)。 +2. 切换角色清空账号级权限,避免运营追加的门店等权限带到客服。 +3. 改为「城市门店服务」时若未绑城市则拒绝。 + +--- + +## 5. Prisma / SQL + +- `HqAdminRole` 增加 `CITY_STORE_SERVICE` +- `HqPermissionEffect`:`GRANT` / `DENY`;`hq_account_permission.effect` +- `hq_account_city` + +脚本:[`server/dukang-api/prisma/migrate-hq-permissions-v358.sql`](../server/dukang-api/prisma/migrate-hq-permissions-v358.sql) +已跑过主脚本的库补删除分类键:[`migrate-hq-permissions-v358-categories-delete.sql`](../server/dukang-api/prisma/migrate-hq-permissions-v358-categories-delete.sql) + +--- + +## 6. API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET/PUT | `/admin/hq-permissions/accounts/:id` | 返回/保存 `grantKeys`、`denyKeys` | +| PUT | `/admin/hq-accounts/:id` | 可传 `cityIds`;空 loginName 忽略 | +| GET | `/admin/auth/me` | `permissionKeys` + `cityIds` + `cityScoped` | + +门店相关 HQ 接口加 `HqPermissionGuard` 对应键。 + +--- + +## 7. 发版步骤 + +| 步骤 | 动作 | +|------|------| +| 1 | 执行 `migrate-hq-permissions-v358.sql` 或 `prisma db push` | +| 2 | `pnpm build`(shared-types + API + admin-web) | +| 3 | 发 `dukang-api` + `admin-web` | + +--- + +## 8. 关键路径 + +| 域 | 路径 | +|----|------| +| 权限目录/公式 | `packages/shared-types/src/hq-permissions.ts` | +| 生效解析 | `hq-permission.guard.ts` | +| 账号/城市 | `admin-hq-accounts.service.ts` | +| 权限页 | `HqPermissionsPage.tsx` | +| 账户页 | `HqAccountsPage.tsx` | +| 菜单 | `AdminLayout.tsx` | +| 门店范围 | `admin-stores.service.ts`、套餐/信息变更审核、评价 | + +--- + +## 9. 验收清单 + +- [ ] 按用户分配:可追加非角色权限,可取消角色已有权限(撤销后菜单与 API 均不可用) +- [ ] 短信登录的运营账号改为客服:保存成功;刷新后无运营菜单 +- [ ] 新建「城市门店服务」不选城市无法保存;选城后仅见这些城市的门店/审核/评价 +- [ ] 直链其他城市门店详情/审核 403 +- [ ] 该角色默认无「门店账户」「门店资源」;运营原菜单不变 +- [ ] 超管仍全国、全权限 +- [ ] 分类页可见但不按城过滤;城市门店服务可新增/编辑,无删除与「同步默认分类」 +- [ ] 概览只显示有权限的卡片;城市范围账号仅统计负责城市 diff --git a/docs/杜康好客-v3.5.9-开发文档.md b/docs/杜康好客-v3.5.9-开发文档.md new file mode 100644 index 0000000..4fe0fce --- /dev/null +++ b/docs/杜康好客-v3.5.9-开发文档.md @@ -0,0 +1,113 @@ +# 杜康好客 · v3.5.9 开发文档 + +> **2026-08-25** · admin-web / API +> **主题**:门店累计核销好客权益、HQ 表格去省略号、序号列与列设置;用户列表改昵称 / 手机号不脱敏 + +--- + +## 1. 版本目标 + +1. **门店列表**增加「累计核销好客权益」:该店历史核销券面额合计。 +2. **全 HQ 表格**去掉 `...` 截断,长文本完整展示,横向滚动。 +3. 每张 **主列表**:最左序号(跨页连续);「列设置」弹窗控制显示列与顺序,偏好存当前 HQ 账号。 +4. **用户列表**:双击昵称进入编辑,离开编辑(失焦 / 回车 / Esc)即写入数据库;手机号列表明文,不脱敏。 + +**不做**:C 端 / 合伙人 / 门店端;详情 Drawer 内嵌表不加列设置。 + +--- + +## 2. 累计核销好客权益 + +口径:当前页每家店 `SUM(user_redeem_record.amount)`,含测试核销;无记录为 `—`。不是结算额 `settleAmount`。 + +`GET /admin/stores` 行字段 `totalRedeemedBenefitAmount`(数字)。详情/导出不加。 + +--- + +## 3. 表格展示 + +- 全局单元格:`nowrap` + 不省略;描述列表仍省略。 +- 列级 `ellipsis` 去掉;操作列仍可 `fixed: 'right'`。 + +--- + +## 4. 序号与列设置 + +| 项 | 规则 | +|----|------| +| 序号 | `(page-1)*pageSize+index+1`,锁定最左,不可隐藏 | +| 操作列 | 锁定最右,不可隐藏 | +| 弹窗 | 勾选显示、拖拽/上下移排序、重置默认 | +| 存储 | `hq_account.list_column_prefs` JSON,按 `listKey` | + +```ts +{ stores: { order: ["name", "cityName"], hidden: ["intro"] } } +``` + +新列按默认位置插入且默认显示;未知 key 忽略。重置即删除该 `listKey`。 + +### API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/auth/me` | `listColumnPrefs` | +| PUT | `/admin/me/list-columns/:listKey` | `{ order, hidden }` 或 `{ reset: true }`;只改当前账号 | +| PUT | `/admin/users/:id` | `{ nickname }`,空串存 `null` | + +`listKey` 白名单见 `packages/shared-types/src/hq-list-columns.ts`。无新权限键。 + +--- + +## 5. 用户列表:改昵称 / 手机号不脱敏 + +| 项 | 行为 | +|----|------| +| 昵称 | 双击单元格进入输入;失焦、回车或 Esc 退出编辑即 `PUT /admin/users/:id` 保存(空串写成 `null`);最长 64 | +| 手机号 | 列表列明文 `phone`,不再 `maskPhone`。详情抽屉仍脱敏 | + +审计:`USER_UPDATE`(修改用户昵称)。无新权限键,已登录 HQ 即可(与用户列表同入口)。 + +--- + +## 6. Prisma / SQL + +`hq_account.list_column_prefs` JSON NULL。 + +脚本:[`server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql`](../server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql) + +--- + +## 7. 发版步骤 + +| 步骤 | 动作 | +|------|------| +| 1 | 执行 `migrate-hq-list-column-prefs-v359.sql` 或 `prisma db push` | +| 2 | `pnpm build`(shared-types + API + admin-web) | +| 3 | 发 `dukang-api` + `admin-web` | + +--- + +## 8. 关键路径 + +| 域 | 路径 | +|----|------| +| 门店聚合 | `admin-stores.service.ts` | +| 列偏好 | `auth.service.ts` · `admin-me.controller.ts` | +| 类型 | `packages/shared-types/src/hq-list-columns.ts` | +| 表格 hook | `apps/admin-web/src/lib/useAdminListColumns.tsx` | +| 弹窗 | `AdminListColumnsModal.tsx` | +| 样式 | `admin.css` | +| 改昵称 | `admin-users.service.ts` · `UsersPage.tsx` | + +--- + +## 9. 验收清单 + +- [ ] 门店列表「累计核销好客权益」= 该店核销记录金额合计;无核销为 `—` +- [ ] HQ 主表长字段不再 `...`,可横向滑完 +- [ ] 主列表最左序号跨页连续 +- [ ] 列设置可隐藏/排序;保存后刷新仍在;重置恢复默认 +- [ ] 序号、操作列不能在弹窗关掉或拖走 +- [ ] 详情描述列表仍可省略 +- [ ] 用户列表双击昵称可改,离开编辑后库中已更新 +- [ ] 用户列表手机号完整可见(详情仍脱敏) diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index d744691..3243ac6 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -41,6 +41,10 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd | 合伙人 | 拓店三步→HQ审核→辖区订单/账单 | | HQ | 开城/商品/审核/订单/权益/核销/结算/工单 | +**HQ 权限(v3.5.8)**:生效 =(角色 ∪ 追加)− 撤销。城市范围绑在账号上(空=全国;「城市门店服务」必须勾城)。门店 API 按 `cityIds` 强制过滤。城市门店服务可新增分类、不可删除;概览按权限与城市范围裁剪。 + +**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)存 `hq_account.list_column_prefs`。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表双击昵称、离开即保存;列表手机号不脱敏。 + ## 5. 验收用例(必过) **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… diff --git a/docs/杜康好客-知识库.md b/docs/杜康好客-知识库.md index 119b62d..ae02b22 100644 --- a/docs/杜康好客-知识库.md +++ b/docs/杜康好客-知识库.md @@ -87,11 +87,12 @@ |--------|------| | 开城 | 城市、合伙人(全城/区域+佣金)、仓库 | | 商品 | SKU、上下架、详情模板 | -| 门店 | 审核、套餐 Tab 直存/审核、合伙人列 | +| 门店 | 审核、套餐 Tab 直存/审核、合伙人列、累计核销好客权益 | +| 用户 | 列表手机号明文;双击昵称离开即保存 | | 交易 | 订单、权益券、核销记录、推广码+metrics | | 财务 | 门店/合伙人/酒厂/物流账单;打款确认 | | 工单 | 售后四类型 + 技术支持(ST) + 开发计划 | -| 系统 | 账号权限、客户端配置、企微机器人/消息推送 | +| 系统 | 账号权限、客户端配置、企微机器人/消息推送;列表「列设置」按账号保存 | | 日志 | HQ/用户/门店/合伙人/企微 | ## 6. 商品与模板 @@ -140,7 +141,7 @@ HQ 推广码:场景/合伙人绑定/上下线;touch 归因;metrics 四指 ## 16. 系统设置 -HQ 账号/角色(`hq-permissions`) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。 +HQ 账号/角色(`hq-permissions`,生效=(角色∪追加)−撤销;可绑城市;城市门店服务可增分类不可删,概览按权限/城市) · 客户端 `minClientVersion` · 运营告警走企微消息推送(DB Webhook)。 ## 17. 企业微信 diff --git a/packages/shared-types/src/hq-list-columns.ts b/packages/shared-types/src/hq-list-columns.ts new file mode 100644 index 0000000..f298112 --- /dev/null +++ b/packages/shared-types/src/hq-list-columns.ts @@ -0,0 +1,71 @@ +/** HQ 主列表列偏好(按账号存储) */ + +export const HQ_LIST_COLUMN_KEYS = [ + 'users', + 'wechat-bindings', + 'products', + 'product-detail-templates', + 'orders', + 'promo-codes', + 'promo-code-users', + 'wecom-bots', + 'wecom-pushes', + 'llm-configs', + 'knowledge-bases', + 'stores', + 'store-package-audits', + 'store-ratings', + 'store-categories', + 'store-accounts', + 'store-media', + 'cities', + 'city-partners', + 'city-warehouses', + 'fulfillment-providers', + 'partners', + 'partner-accounts', + 'finance-store-bills', + 'finance-partner-bills', + 'finance-winery-bills', + 'finance-logistics-bills', + 'finance-store-withdrawals', + 'benefit-coupons', + 'benefit-ledgers', + 'redeem-records', + 'redeem-pending', + 'deliveries', + 'tickets', + 'support-tickets', + 'dev-plan-versions', + 'dev-plan-tasks', + 'invoices', + 'resources', + 'logs-users', + 'logs-stores', + 'logs-partners', + 'logs-hq', + 'logs-third-party', + 'logs-domain-events', + 'logs-wecom-bots', + 'hq-accounts', + 'test-whitelist', +] as const; + +export type HqListColumnKey = (typeof HQ_LIST_COLUMN_KEYS)[number]; + +export function isHqListColumnKey(value: string): value is HqListColumnKey { + return (HQ_LIST_COLUMN_KEYS as readonly string[]).includes(value); +} + +export type HqListColumnPref = { + order: string[]; + hidden: string[]; +}; + +export type HqListColumnPrefsMap = Partial>; + +export type SaveHqListColumnPrefsRequest = { + reset?: boolean; + order?: string[]; + hidden?: string[]; +}; diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index 830b9e1..c66d5a7 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -6,7 +6,12 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'products', label: '商品管理', group: '业务' }, { key: 'orders', label: '订单管理', group: '业务' }, { key: 'promo_codes', label: '推广码', group: '业务' }, - { key: 'stores', label: '门店管理', group: '业务' }, + { key: 'stores', label: '门店列表', group: '业务' }, + { key: 'store_audits', label: '门店审核', group: '业务' }, + { key: 'store_ratings', label: '门店评价', group: '业务' }, + { key: 'store_categories', label: '门店分类', group: '业务' }, + { key: 'store_accounts', label: '门店账户', group: '业务' }, + { key: 'store_media', label: '门店资源', group: '业务' }, { key: 'partners', label: '开城管理', group: '业务' }, { key: 'finance', label: '财务', group: '业务' }, { key: 'benefit', label: '好客权益', group: '业务' }, @@ -26,6 +31,7 @@ export const HQ_PERMISSION_CATALOG = [ { key: 'users_delete', label: '删除用户', group: '危险操作' }, { key: 'orders_delete', label: '删除订单', group: '危险操作' }, { key: 'cities_delete', label: '删除城市', group: '危险操作' }, + { key: 'store_categories_delete', label: '删除门店分类', group: '危险操作' }, { key: 'hq_permissions', label: '权限分配', group: '管理' }, { key: 'hq_accounts', label: 'HQ 账户', group: '管理' }, { key: 'system_settings_feature', label: '功能开关', group: '系统设置' }, @@ -42,6 +48,20 @@ export const HQ_PERMISSION_CATALOG = [ export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; +/** 由原 `stores` 拆出的子权限;存量数据迁移时补齐 */ +export const HQ_STORE_SUB_PERMISSION_KEYS = [ + 'store_audits', + 'store_ratings', + 'store_categories', + 'store_accounts', + 'store_media', +] as const satisfies readonly HqPermissionKey[]; + +export const HQ_STORE_MENU_PERMISSION_KEYS = [ + 'stores', + ...HQ_STORE_SUB_PERMISSION_KEYS, +] as const satisfies readonly HqPermissionKey[]; + /** 危险操作:非超管角色默认不含,需在权限分配中显式勾选;超管默认拥有 */ export const HQ_DEBUG_PERMISSION_KEYS = [ 'benefit_debug', @@ -52,6 +72,7 @@ export const HQ_DANGEROUS_PERMISSION_KEYS = [ 'users_delete', 'orders_delete', 'cities_delete', + 'store_categories_delete', ] as const satisfies readonly HqPermissionKey[]; export function isHqDangerousPermission(key: string): boolean { @@ -100,19 +121,53 @@ export function expandHqPermissionKeys(keys: string[]): HqPermissionKey[] { ); } +/** 生效 = (角色 ∪ 追加) − 撤销 */ +export function computeHqEffectivePermissionKeys( + roleKeys: string[], + grantKeys: string[], + denyKeys: string[] = [], +): HqPermissionKey[] { + const denied = new Set(denyKeys); + return expandHqPermissionKeys([...roleKeys, ...grantKeys]).filter((k) => !denied.has(k)); +} + export function hasAnySystemSettingsPermission(keys: string[]): boolean { const expanded = expandHqPermissionKeys(keys); return SYSTEM_SETTINGS_PERMISSION_KEYS.some((k) => expanded.includes(k)); } -export const HQ_ADMIN_ROLES = [ +export function hasAnyStoreMenuPermission(keys: string[]): boolean { + return HQ_STORE_MENU_PERMISSION_KEYS.some((k) => keys.includes(k)); +} + +export const HQ_ADMIN_ROLE_VALUES = [ + 'SUPER_ADMIN', + 'OPS', + 'FINANCE', + 'CUSTOMER_SERVICE', + 'CITY_STORE_SERVICE', +] as const; + +export type HqAdminRoleValue = (typeof HQ_ADMIN_ROLE_VALUES)[number]; + +export const HQ_ADMIN_ROLES: { value: HqAdminRoleValue; label: string }[] = [ { value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'OPS', label: '运营' }, { value: 'FINANCE', label: '财务' }, { value: 'CUSTOMER_SERVICE', label: '客服' }, -] as const; + { value: 'CITY_STORE_SERVICE', label: '城市门店服务' }, +]; -export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { +const OPS_STORE_KEYS: HqPermissionKey[] = [ + 'stores', + 'store_audits', + 'store_ratings', + 'store_categories', + 'store_accounts', + 'store_media', +]; + +export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS], OPS: [ 'dashboard', @@ -121,7 +176,8 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'products', 'orders', 'promo_codes', - 'stores', + ...OPS_STORE_KEYS, + 'store_categories_delete', 'partners', 'benefit', 'deliveries', @@ -141,7 +197,8 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { FINANCE: [ 'dashboard', 'orders', - 'stores', + ...OPS_STORE_KEYS, + 'store_categories_delete', 'partners', 'finance', 'benefit', @@ -160,4 +217,11 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { 'invoices', 'logs', ], + CITY_STORE_SERVICE: [ + 'dashboard', + 'stores', + 'store_audits', + 'store_ratings', + 'store_categories', + ], }; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 3a5dfbe..874aa3f 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -19,6 +19,7 @@ export * from './store-info-change'; export * from './partner-log'; export * from './promo'; export * from './hq-permissions'; +export * from './hq-list-columns'; export * from './partner'; export * from './shop'; export * from './city-partner'; diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts index 5720d96..28710f8 100644 --- a/packages/shared-types/src/ops.ts +++ b/packages/shared-types/src/ops.ts @@ -3,6 +3,10 @@ export interface AdminListQuery { pageSize?: number; } +export interface UpdateAdminUserRequest { + nickname: string; +} + export interface AdminPageResult { items: T[]; total: number; diff --git a/server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql b/server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql new file mode 100644 index 0000000..75b2089 --- /dev/null +++ b/server/dukang-api/prisma/migrate-hq-list-column-prefs-v359.sql @@ -0,0 +1,3 @@ +-- v3.5.9: HQ 账号列表显示列 / 顺序偏好 +ALTER TABLE `hq_account` + ADD COLUMN `list_column_prefs` JSON NULL; diff --git a/server/dukang-api/prisma/migrate-hq-permissions-v358-categories-delete.sql b/server/dukang-api/prisma/migrate-hq-permissions-v358-categories-delete.sql new file mode 100644 index 0000000..3c09e32 --- /dev/null +++ b/server/dukang-api/prisma/migrate-hq-permissions-v358-categories-delete.sql @@ -0,0 +1,15 @@ +-- v3.5.8 补丁:删除门店分类权限(可对已执行过 migrate-hq-permissions-v358.sql 的库单独跑) +-- 城市门店服务可新增分类,不可删除 + +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_categories_delete', NOW(3) +FROM `hq_role_permission` +WHERE `permission_key` = 'store_categories' + AND `admin_role` <> 'CITY_STORE_SERVICE'; + +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT p.`hq_account_id`, 'store_categories_delete', p.`effect`, NOW(3) +FROM `hq_account_permission` p +INNER JOIN `hq_account` a ON a.`id` = p.`hq_account_id` +WHERE p.`permission_key` = 'store_categories' + AND a.`admin_role` <> 'CITY_STORE_SERVICE'; diff --git a/server/dukang-api/prisma/migrate-hq-permissions-v358.sql b/server/dukang-api/prisma/migrate-hq-permissions-v358.sql new file mode 100644 index 0000000..5e5443a --- /dev/null +++ b/server/dukang-api/prisma/migrate-hq-permissions-v358.sql @@ -0,0 +1,71 @@ +-- v3.5.8: HQ 权限追加/撤销、城市门店服务、账号城市范围 + +ALTER TABLE `hq_account` + MODIFY COLUMN `admin_role` ENUM( + 'SUPER_ADMIN', + 'OPS', + 'FINANCE', + 'CUSTOMER_SERVICE', + 'CITY_STORE_SERVICE' + ) NOT NULL DEFAULT 'OPS'; + +ALTER TABLE `hq_role_permission` + MODIFY COLUMN `admin_role` ENUM( + 'SUPER_ADMIN', + 'OPS', + 'FINANCE', + 'CUSTOMER_SERVICE', + 'CITY_STORE_SERVICE' + ) NOT NULL; + +ALTER TABLE `hq_account_permission` + ADD COLUMN `effect` ENUM('GRANT', 'DENY') NOT NULL DEFAULT 'GRANT' AFTER `permission_key`; + +CREATE TABLE IF NOT EXISTS `hq_account_city` ( + `hq_account_id` BIGINT UNSIGNED NOT NULL, + `city_id` BIGINT UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`hq_account_id`, `city_id`), + INDEX `hq_account_city_city_id_idx` (`city_id`), + CONSTRAINT `hq_account_city_hq_account_id_fkey` + FOREIGN KEY (`hq_account_id`) REFERENCES `hq_account` (`id`) ON DELETE CASCADE, + CONSTRAINT `hq_account_city_city_id_fkey` + FOREIGN KEY (`city_id`) REFERENCES `common_city` (`id`) ON DELETE CASCADE +) DEFAULT CHARSET = utf8mb4; + +-- 存量 stores 拆键:补齐审核/评价/分类/账户/资源,保持与拆分前菜单一致 +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_audits', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_ratings', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_categories', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_accounts', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_media', NOW(3) FROM `hq_role_permission` WHERE `permission_key` = 'stores'; + +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT `hq_account_id`, 'store_audits', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT `hq_account_id`, 'store_ratings', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT `hq_account_id`, 'store_categories', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT `hq_account_id`, 'store_accounts', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores'; +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT `hq_account_id`, 'store_media', `effect`, NOW(3) FROM `hq_account_permission` WHERE `permission_key` = 'stores'; + +-- 删除分类:有分类权限的角色/账号补齐,城市门店服务除外(可新增、不可删除) +INSERT IGNORE INTO `hq_role_permission` (`admin_role`, `permission_key`, `created_at`) +SELECT `admin_role`, 'store_categories_delete', NOW(3) +FROM `hq_role_permission` +WHERE `permission_key` = 'store_categories' + AND `admin_role` <> 'CITY_STORE_SERVICE'; + +INSERT IGNORE INTO `hq_account_permission` (`hq_account_id`, `permission_key`, `effect`, `created_at`) +SELECT p.`hq_account_id`, 'store_categories_delete', p.`effect`, NOW(3) +FROM `hq_account_permission` p +INNER JOIN `hq_account` a ON a.`id` = p.`hq_account_id` +WHERE p.`permission_key` = 'store_categories' + AND a.`admin_role` <> 'CITY_STORE_SERVICE'; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 1bbee69..5a90400 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -263,6 +263,12 @@ enum HqAdminRole { OPS FINANCE CUSTOMER_SERVICE + CITY_STORE_SERVICE +} + +enum HqPermissionEffect { + GRANT + DENY } enum PartnerBillStatus { @@ -1031,6 +1037,7 @@ model CommonCity { partnerAccounts PartnerAccount[] @relation("PartnerAccountCity") stores Store[] orders Order[] + hqAccountCities HqAccountCity[] @@map("common_city") } @@ -1244,10 +1251,13 @@ model HqAccount { wxUnionId String? @map("wx_union_id") @db.VarChar(64) status AccountStatus @default(ACTIVE) lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) + /// HQ 各列表的显示列与顺序(按 listKey) + listColumnPrefs Json? @map("list_column_prefs") createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) permissions HqAccountPermission[] + cities HqAccountCity[] testWhitelistPhones CommonTestWhitelistPhone[] @relation("TestWhitelistCreatedBy") @@map("hq_account") @@ -1263,9 +1273,10 @@ model HqRolePermission { } model HqAccountPermission { - hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt - permissionKey String @map("permission_key") @db.VarChar(64) - createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt + permissionKey String @map("permission_key") @db.VarChar(64) + effect HqPermissionEffect @default(GRANT) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade) @@ -1273,6 +1284,19 @@ model HqAccountPermission { @@map("hq_account_permission") } +model HqAccountCity { + hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt + cityId BigInt @map("city_id") @db.UnsignedBigInt + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade) + city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade) + + @@id([hqAccountId, cityId]) + @@index([cityId]) + @@map("hq_account_city") +} + // ─── USER ───────────────────────────────────────────── model User { diff --git a/server/dukang-api/src/common/guards/hq-permission.guard.ts b/server/dukang-api/src/common/guards/hq-permission.guard.ts index c4751ca..e99df82 100644 --- a/server/dukang-api/src/common/guards/hq-permission.guard.ts +++ b/server/dukang-api/src/common/guards/hq-permission.guard.ts @@ -3,16 +3,20 @@ import { ExecutionContext, ForbiddenException, Injectable, + NotFoundException, SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { HQ_PERMISSION_CATALOG, HQ_ROLE_DEFAULT_PERMISSIONS, + computeHqEffectivePermissionKeys, expandHqPermissionKeys, hasAnySystemSettingsPermission, + type HqAdminRoleValue, type HqPermissionKey, } from '@dukang/shared-types'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.module'; import type { AuthUser } from './jwt-auth.guard'; @@ -23,6 +27,37 @@ export const RequireHqPermissions = (...keys: string[]) => export const RequireAnySystemSettings = () => SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']); +/** null = 全国;[] = 无可见城市 */ +export type HqCityScope = bigint[] | null; + +export function hqStoreCityWhere(scope: HqCityScope): Prisma.StoreWhereInput | undefined { + if (scope === null) return undefined; + if (!scope.length) return { id: { equals: BigInt(0) } }; + return { cityId: { in: scope } }; +} + +export function mergeHqStoreCityWhere( + where: Prisma.StoreWhereInput, + scope: HqCityScope, + requestedCityId?: string, +): Prisma.StoreWhereInput { + if (requestedCityId) { + const cityId = BigInt(requestedCityId); + assertHqCityInScope(scope, cityId); + return { AND: [where, { cityId }] }; + } + const scoped = hqStoreCityWhere(scope); + if (!scoped) return where; + return { AND: [where, scoped] }; +} + +export function assertHqCityInScope(scope: HqCityScope, cityId: bigint) { + if (scope === null) return; + if (!scope.some((id) => id === cityId)) { + throw new ForbiddenException('无权访问该城市的门店'); + } +} + @Injectable() export class HqPermissionsResolver { constructor(private readonly prisma: PrismaService) {} @@ -41,39 +76,45 @@ export class HqPermissionsResolver { async resolveAccess(actorId: bigint): Promise<{ keys: HqPermissionKey[]; isSuperAdmin: boolean; + adminRole: HqAdminRoleValue; }> { const account = await this.loadActiveAccount(actorId); + const adminRole = account.adminRole as HqAdminRoleValue; const userRows = await this.prisma.hqAccountPermission.findMany({ where: { hqAccountId: actorId }, - select: { permissionKey: true }, + select: { permissionKey: true, effect: true }, }); - const userKeys = userRows.map((r) => r.permissionKey); + const grantKeys = userRows + .filter((r) => r.effect !== 'DENY') + .map((r) => r.permissionKey); + const denyKeys = userRows.filter((r) => r.effect === 'DENY').map((r) => r.permissionKey); - if (account.adminRole === 'SUPER_ADMIN') { - // 超管拥有权限目录内全部项(含后续新增),另含危险操作与用户级附加项 + if (adminRole === 'SUPER_ADMIN') { return { isSuperAdmin: true, + adminRole, keys: expandHqPermissionKeys([ ...HQ_PERMISSION_CATALOG.map((p) => p.key), - ...userKeys, + ...grantKeys, ]), }; } const roleRows = await this.prisma.hqRolePermission.findMany({ - where: { adminRole: account.adminRole }, + where: { adminRole }, select: { permissionKey: true }, }); const roleKeys = roleRows.length > 0 ? roleRows.map((r) => r.permissionKey) - : [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])]; + : [...(HQ_ROLE_DEFAULT_PERMISSIONS[adminRole] ?? [])]; return { isSuperAdmin: false, - keys: expandHqPermissionKeys([...roleKeys, ...userKeys]), + adminRole, + keys: computeHqEffectivePermissionKeys(roleKeys, grantKeys, denyKeys), }; } @@ -81,6 +122,35 @@ export class HqPermissionsResolver { const { keys } = await this.resolveAccess(actorId); return keys; } + + async resolveCityScope(actorId: bigint): Promise { + const account = await this.loadActiveAccount(actorId); + if (account.adminRole === 'SUPER_ADMIN') return null; + const rows = await this.prisma.hqAccountCity.findMany({ + where: { hqAccountId: actorId }, + select: { cityId: true }, + }); + if (!rows.length) { + return account.adminRole === 'CITY_STORE_SERVICE' ? [] : null; + } + return rows.map((r) => r.cityId); + } + + async assertStoreCityInScope(actorId: bigint, cityId: bigint) { + const scope = await this.resolveCityScope(actorId); + assertHqCityInScope(scope, cityId); + } + + async assertStoreIdInScope(actorId: bigint, storeId: bigint) { + const store = await this.prisma.store.findUnique({ + where: { id: storeId }, + select: { cityId: true }, + }); + if (!store) { + throw new NotFoundException('门店不存在'); + } + await this.assertStoreCityInScope(actorId, store.cityId); + } } @Injectable() diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 5004258..3648cf1 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -17,6 +17,7 @@ export const HqOperationAction = { HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE', HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE', HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE', + USER_UPDATE: 'USER_UPDATE', USER_DELETE: 'USER_DELETE', USER_BATCH_DELETE: 'USER_BATCH_DELETE', ORDER_SHIP: 'ORDER_SHIP', @@ -141,6 +142,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员', [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员', [HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限', + [HqOperationAction.USER_UPDATE]: '修改用户昵称', [HqOperationAction.USER_DELETE]: '删除用户', [HqOperationAction.USER_BATCH_DELETE]: '批量删除用户', [HqOperationAction.ORDER_SHIP]: '订单发货', diff --git a/server/dukang-api/src/integrations/wecom/wecom-handbook.ts b/server/dukang-api/src/integrations/wecom/wecom-handbook.ts index c37a720..592a28b 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-handbook.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-handbook.ts @@ -87,7 +87,9 @@ export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [ '运营:商品/开城/门店/订单/配送/权益。', '财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。', '客服:用户/订单、售后工单、发票协助。', + '城市门店服务:门店列表/审核/评价/分类(可新增不可删除),且限制负责城市;概览只显示有权限的数据和负责城市。', '超管:权限分配、技术支持评审、系统设置。', + '单账号可在角色之上追加或撤销权限;切换角色会清空账号级权限。', ].join('\n'), }, { diff --git a/server/dukang-api/src/modules/iam/admin-me.controller.ts b/server/dukang-api/src/modules/iam/admin-me.controller.ts new file mode 100644 index 0000000..0c27926 --- /dev/null +++ b/server/dukang-api/src/modules/iam/admin-me.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Param, Put, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { SaveHqListColumnPrefsDto } from './dto/auth.dto'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { AuthUser } from '../../common/guards/jwt-auth.guard'; + +@Controller('admin/me') +@UseGuards(JwtAuthGuard) +export class AdminMeController { + constructor(private readonly authService: AuthService) {} + + @Put('list-columns/:listKey') + saveListColumns( + @CurrentUser() user: AuthUser, + @Param('listKey') listKey: string, + @Body() dto: SaveHqListColumnPrefsDto, + ) { + return this.authService.updateMyListColumnPrefs(user.actorType, user.actorId, listKey, dto); + } +} diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index a691063..81e931f 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -10,7 +10,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { ClientApp, SmsScene } from '@dukang/shared-types'; +import { ClientApp, SmsScene, isHqListColumnKey, type HqListColumnPrefsMap } from '@dukang/shared-types'; import { generateUserNo } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { RedisService } from '../../common/redis/redis.service'; @@ -55,6 +55,20 @@ type UserRow = Pick< avatar?: { url: string } | null; }; +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 }; + 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') : [], + }; + } + return out; +} + @Injectable() export class AuthService { constructor( @@ -1207,11 +1221,48 @@ export class AuthService { const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } }); if (!account) return null; const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId); - return serializeBigInt({ ...account, permissionKeys }); + const cityScope = await this.hqPermissions.resolveCityScope(actorId); + const { passwordHash: _passwordHash, ...safeAccount } = account; + return serializeBigInt({ + ...safeAccount, + listColumnPrefs: parseListColumnPrefs(account.listColumnPrefs), + permissionKeys, + cityIds: (cityScope ?? []).map((id) => id.toString()), + cityScoped: cityScope !== null, + }); } return null; } + async updateMyListColumnPrefs( + actorType: string, + actorId: bigint, + listKey: string, + dto: { reset?: boolean; order?: string[]; hidden?: string[] }, + ) { + if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可保存列表列设置'); + if (!isHqListColumnKey(listKey)) throw new BadRequestException('未知列表'); + const account = await this.prisma.hqAccount.findUnique({ + where: { id: actorId }, + select: { id: true, listColumnPrefs: true }, + }); + if (!account) throw new NotFoundException('账号不存在'); + const current = parseListColumnPrefs(account.listColumnPrefs); + if (dto.reset) { + delete current[listKey]; + } else { + current[listKey] = { + order: (dto.order ?? []).filter((k) => typeof k === 'string' && k.trim()), + hidden: (dto.hidden ?? []).filter((k) => typeof k === 'string' && k.trim()), + }; + } + const updated = await this.prisma.hqAccount.update({ + where: { id: actorId }, + data: { listColumnPrefs: current }, + }); + return { listColumnPrefs: parseListColumnPrefs(updated.listColumnPrefs) }; + } + wechatDisabled() { throw new NotImplementedException('FEATURE_DISABLED'); } 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 d6ccd83..6f83529 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 { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { SmsScene } from '@dukang/shared-types'; export class SendSmsDto { @@ -129,3 +129,19 @@ export class CheckPartnerPhoneDto { @IsNotEmpty() phone: string; } + +export class SaveHqListColumnPrefsDto { + @IsOptional() + @IsBoolean() + reset?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + order?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + hidden?: string[]; +} diff --git a/server/dukang-api/src/modules/iam/iam.module.ts b/server/dukang-api/src/modules/iam/iam.module.ts index 74ec9b9..d4a57a9 100644 --- a/server/dukang-api/src/modules/iam/iam.module.ts +++ b/server/dukang-api/src/modules/iam/iam.module.ts @@ -16,6 +16,7 @@ import { PartnerStaffService } from './partner-staff.service'; import { StoreStaffController } from './store-staff.controller'; import { StoreStaffService } from './store-staff.service'; import { AdminAuthController } from './admin-auth.controller'; +import { AdminMeController } from './admin-me.controller'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PhoneVerifiedGuard } from '../../common/guards/phone-verified.guard'; import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard'; @@ -50,6 +51,7 @@ import { CommonModule } from '../common/common.module'; UserProfileController, UserAddressController, AdminAuthController, + AdminMeController, ], providers: [ AuthService, diff --git a/server/dukang-api/src/modules/ops/admin-cities.controller.ts b/server/dukang-api/src/modules/ops/admin-cities.controller.ts index f94f3bd..96628a0 100644 --- a/server/dukang-api/src/modules/ops/admin-cities.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-cities.controller.ts @@ -7,6 +7,8 @@ import { } from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { AdminCitiesService } from './admin-cities.service'; import { AdminCitiesQueryDto } from './dto/admin-query.dto'; import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; @@ -23,8 +25,8 @@ export class AdminCitiesController { constructor(private readonly service: AdminCitiesService) {} @Get() - list(@Query() query: AdminCitiesQueryDto) { - return this.service.list(query); + list(@CurrentUser() user: AuthUser, @Query() query: AdminCitiesQueryDto) { + return this.service.list(query, user.actorId); } @Get(':id/delete-preview') diff --git a/server/dukang-api/src/modules/ops/admin-cities.service.ts b/server/dukang-api/src/modules/ops/admin-cities.service.ts index 8eb0672..29e7a2d 100644 --- a/server/dukang-api/src/modules/ops/admin-cities.service.ts +++ b/server/dukang-api/src/modules/ops/admin-cities.service.ts @@ -4,6 +4,7 @@ import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { PartnerCityService } from '../city-scope/partner-city.service'; +import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard'; import type { AdminCitiesQueryDto } from './dto/admin-query.dto'; import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; @@ -12,9 +13,10 @@ export class AdminCitiesService { constructor( private readonly prisma: PrismaService, private readonly partnerCityService: PartnerCityService, + private readonly hqPermissions: HqPermissionsResolver, ) {} - async list(query: AdminCitiesQueryDto) { + async list(query: AdminCitiesQueryDto, actorId?: bigint) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.CommonCityWhereInput = {}; @@ -24,6 +26,12 @@ export class AdminCitiesService { if (query.partnerId) { where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } }; } + if (actorId) { + const scope = await this.hqPermissions.resolveCityScope(actorId); + if (scope !== null) { + where.id = { in: scope.length ? scope : [BigInt(0)] }; + } + } const [items, total] = await Promise.all([ this.prisma.commonCity.findMany({ diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts b/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts index ee9bcd2..ac16199 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.controller.ts @@ -1,22 +1,32 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { AdminDashboardService } from './admin-dashboard.service'; import { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto'; @Controller('admin/dashboard') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('dashboard') export class AdminDashboardController { constructor(private readonly dashboardService: AdminDashboardService) {} @Get('stats') - stats() { - return this.dashboardService.getStats(); + stats(@CurrentUser() user: AuthUser) { + return this.dashboardService.getStats(user.actorId); } @Get('analytics') - analytics(@Query() query: AdminDashboardAnalyticsQueryDto) { - return this.dashboardService.getAnalytics(query); + analytics( + @CurrentUser() user: AuthUser, + @Query() query: AdminDashboardAnalyticsQueryDto, + ) { + return this.dashboardService.getAnalytics(user.actorId, query); } @Get('version') diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index 1d120e5..a24598a 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -1,6 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import type { HqPermissionKey } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; +import { + HqPermissionsResolver, + assertHqCityInScope, + hqStoreCityWhere, + type HqCityScope, +} from '../../common/guards/hq-permission.guard'; import type { AdminDashboardAnalyticsQueryDto } from './dto/admin-query.dto'; function startOfDay(d: Date) { @@ -55,14 +62,63 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean { return now.getTime() > deadline.getTime(); } +function cityIdFilter(scope: HqCityScope): Prisma.BigIntFilter | undefined { + if (scope === null) return undefined; + if (!scope.length) return { equals: BigInt(0) }; + return { in: scope }; +} + +function emptyRows(): Promise { + return Promise.resolve([] as T[]); +} + @Injectable() export class AdminDashboardService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly hqPermissions: HqPermissionsResolver, + ) {} - async getStats() { + async getStats(actorId: bigint) { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); + const [{ keys, isSuperAdmin }, scope] = await Promise.all([ + this.hqPermissions.resolveAccess(actorId), + this.hqPermissions.resolveCityScope(actorId), + ]); + const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k); + const storeWhere = hqStoreCityWhere(scope) ?? {}; + const cityFilter = cityIdFilter(scope); + const orderWhere: Prisma.OrderWhereInput = cityFilter ? { cityId: cityFilter } : {}; + const partnerWhere: Prisma.PartnerAccountWhereInput = { + isPrimary: 1, + ...(cityFilter ? { cityId: cityFilter } : {}), + }; + + let userWhere: Prisma.UserWhereInput = { status: 1, mergedIntoUserId: null }; + if (scope !== null) { + if (!scope.length) { + userWhere = { id: { equals: BigInt(0) } }; + } else { + const cities = await this.prisma.commonCity.findMany({ + where: { id: { in: scope } }, + select: { code: true }, + }); + const codes = cities.map((c) => c.code); + userWhere = { + status: 1, + mergedIntoUserId: null, + cityPreference: { selectedCityCode: { in: codes.length ? codes : [''] } }, + }; + } + } + + const redeemTodayWhere: Prisma.RedeemRecordWhereInput = { + createdAt: { gte: todayStart }, + ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), + }; + const [ usersTotal, guestUsers, @@ -79,32 +135,112 @@ export class AdminDashboardService { pendingPartnerDraftBills, openTickets, pendingWithdrawRows, + pendingStoreOnboard, + pendingStorePackageAudits, + pendingStoreInfoChanges, ] = await Promise.all([ - this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), - this.prisma.user.count({ - where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: null }, - }), - this.prisma.user.count({ - where: { status: 1, mergedIntoUserId: null, phoneVerifiedAt: { not: null } }, - }), - this.prisma.user.count({ where: { mergedIntoUserId: { not: null } } }), - this.prisma.order.count({ where: { createdAt: { gte: todayStart } } }), - this.prisma.order.groupBy({ - by: ['status'], - _count: { status: true }, - }), - this.prisma.store.count(), - this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }), - this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }), - this.prisma.orderDelivery.count(), - this.prisma.storePayout.count({ where: { status: 'PENDING' } }), - this.prisma.partnerBill.count({ where: { status: 'UNPAID' } }), - this.prisma.partnerBill.count({ where: { status: 'PENDING_REVIEW' } }), - this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), - this.prisma.storeWithdrawRequest.findMany({ - where: { status: 'PENDING_REVIEW' }, - select: { appliedAt: true }, - }), + can('users') + ? this.prisma.user.count({ where: userWhere }) + : Promise.resolve(0), + can('users') + ? this.prisma.user.count({ + where: { ...userWhere, phoneVerifiedAt: null }, + }) + : Promise.resolve(0), + can('users') + ? this.prisma.user.count({ + where: { ...userWhere, phoneVerifiedAt: { not: null } }, + }) + : Promise.resolve(0), + can('users') + ? this.prisma.user.count({ + where: + scope === null + ? { mergedIntoUserId: { not: null } } + : { id: { equals: BigInt(0) } }, + }) + : Promise.resolve(0), + can('orders') + ? this.prisma.order.count({ + where: { ...orderWhere, createdAt: { gte: todayStart } }, + }) + : Promise.resolve(0), + can('orders') + ? this.prisma.order.groupBy({ + by: ['status'], + where: orderWhere, + _count: { status: true }, + }) + : emptyRows<{ status: string; _count: { status: number } }>(), + can('stores') ? this.prisma.store.count({ where: storeWhere }) : Promise.resolve(0), + can('partners') + ? this.prisma.partnerAccount.count({ where: partnerWhere }) + : Promise.resolve(0), + can('benefit') + ? this.prisma.redeemRecord.count({ where: redeemTodayWhere }) + : Promise.resolve(0), + can('deliveries') + ? this.prisma.orderDelivery.count({ + where: cityFilter ? { order: { cityId: cityFilter } } : undefined, + }) + : Promise.resolve(0), + can('finance') + ? this.prisma.storePayout.count({ + where: { + status: 'PENDING', + ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), + }, + }) + : Promise.resolve(0), + can('finance') + ? this.prisma.partnerBill.count({ + where: { + status: 'UNPAID', + ...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}), + }, + }) + : Promise.resolve(0), + can('finance') + ? this.prisma.partnerBill.count({ + where: { + status: 'PENDING_REVIEW', + ...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}), + }, + }) + : Promise.resolve(0), + can('tickets') && scope === null + ? this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }) + : Promise.resolve(0), + can('finance') + ? this.prisma.storeWithdrawRequest.findMany({ + where: { + status: 'PENDING_REVIEW', + ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), + }, + select: { appliedAt: true }, + }) + : emptyRows<{ appliedAt: Date }>(), + can('stores') + ? this.prisma.store.count({ + where: { auditStatus: 'PENDING', ...storeWhere }, + }) + : Promise.resolve(0), + can('store_audits') + ? this.prisma.storePackageChangeRequest.count({ + where: { + status: 'PENDING', + ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), + }, + }) + : Promise.resolve(0), + can('store_audits') + ? this.prisma.storeInfoChangeRequest.count({ + where: { + status: 'PENDING', + ...(Object.keys(storeWhere).length ? { store: storeWhere } : {}), + }, + }) + : Promise.resolve(0), ]); const now = new Date(); @@ -129,6 +265,9 @@ export class AdminDashboardService { openTickets, pendingStoreWithdrawals, overdueStoreWithdrawals, + pendingStoreOnboard, + pendingStorePackageAudits, + pendingStoreInfoChanges, ordersByStatus: ordersByStatus.map((row) => ({ status: row.status, count: row._count.status, @@ -152,7 +291,7 @@ export class AdminDashboardService { }; } - async getAnalytics(query: AdminDashboardAnalyticsQueryDto) { + async getAnalytics(actorId: bigint, query: AdminDashboardAnalyticsQueryDto) { const today = startOfDay(new Date()); const defaultFrom = new Date(today); defaultFrom.setDate(defaultFrom.getDate() - 29); @@ -164,9 +303,18 @@ export class AdminDashboardService { const rangeStart = startOfDay(from <= to ? from : to); const rangeEnd = endOfDay(from <= to ? to : from); - let filterCityCode: string | null | undefined; - let filterCityId: bigint | null | undefined; + const [{ keys, isSuperAdmin }, scope] = await Promise.all([ + this.hqPermissions.resolveAccess(actorId), + this.hqPermissions.resolveCityScope(actorId), + ]); + const can = (k: HqPermissionKey) => isSuperAdmin || keys.includes(k); + + let filterCityCode: string | string[] | null | undefined; + let filterCityId: bigint | bigint[] | null | undefined; if (query.cityId === 'none') { + if (scope !== null) { + throw new ForbiddenException('无权按未选城筛选'); + } filterCityCode = null; filterCityId = null; } else if (query.cityId) { @@ -174,20 +322,47 @@ export class AdminDashboardService { where: { id: BigInt(query.cityId) }, select: { id: true, code: true }, }); - if (city) { - filterCityCode = city.code; - filterCityId = city.id; + if (!city) { + throw new ForbiddenException('无权访问该城市的门店'); + } + assertHqCityInScope(scope, city.id); + filterCityCode = city.code; + filterCityId = city.id; + } else if (scope !== null) { + if (!scope.length) { + filterCityId = []; + filterCityCode = []; + } else { + const scopedCities = await this.prisma.commonCity.findMany({ + where: { id: { in: scope } }, + select: { id: true, code: true }, + }); + filterCityId = scope; + filterCityCode = scopedCities.map((c) => c.code); } } const filterPromoNone = query.promoCodeId === 'none'; const filterPromoId = - query.promoCodeId && query.promoCodeId !== 'none' + can('promo_codes') && query.promoCodeId && query.promoCodeId !== 'none' ? BigInt(query.promoCodeId) : undefined; - const filterPartnerId = query.partnerAccountId - ? BigInt(query.partnerAccountId) - : undefined; + const filterPartnerId = + can('partners') && query.partnerAccountId + ? BigInt(query.partnerAccountId) + : undefined; + + if (filterPartnerId !== undefined && scope !== null) { + const partner = await this.prisma.partnerAccount.findUnique({ + where: { id: filterPartnerId }, + select: { cityId: true }, + }); + if (partner?.cityId) { + assertHqCityInScope(scope, partner.cityId); + } else if (scope !== null) { + throw new ForbiddenException('无权访问该城市的门店'); + } + } const userWhere: Prisma.UserWhereInput = { status: 1, @@ -199,10 +374,14 @@ export class AdminDashboardService { { cityPreference: null }, { cityPreference: { selectedCityCode: null } }, ]; + } else if (Array.isArray(filterCityCode)) { + userWhere.cityPreference = { + selectedCityCode: { in: filterCityCode.length ? filterCityCode : [''] }, + }; } else if (filterCityCode) { userWhere.cityPreference = { selectedCityCode: filterCityCode }; } - if (filterPromoNone) { + if (can('promo_codes') && filterPromoNone) { userWhere.promoTouch = { is: null }; } else if (filterPromoId !== undefined) { userWhere.promoTouch = { promoCodeId: filterPromoId }; @@ -211,10 +390,14 @@ export class AdminDashboardService { const orderWhere: Prisma.OrderWhereInput = { createdAt: { gte: rangeStart, lte: rangeEnd }, }; - if (filterCityId !== undefined && filterCityId !== null) { + if (filterCityId === null) { + orderWhere.id = { in: [] }; + } else if (Array.isArray(filterCityId)) { + orderWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] }; + } else if (filterCityId !== undefined) { orderWhere.cityId = filterCityId; } - if (filterPromoNone) { + if (can('promo_codes') && filterPromoNone) { orderWhere.promoCodeId = null; } else if (filterPromoId !== undefined) { orderWhere.promoCodeId = filterPromoId; @@ -226,6 +409,8 @@ export class AdminDashboardService { }; if (filterCityId === null) { partnerWhere.cityId = null; + } else if (Array.isArray(filterCityId)) { + partnerWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] }; } else if (filterCityId !== undefined) { partnerWhere.cityId = filterCityId; } @@ -237,8 +422,9 @@ export class AdminDashboardService { createdAt: { gte: rangeStart, lte: rangeEnd }, }; if (filterCityId === null) { - // 门店必有 cityId storeWhere.id = { in: [] }; + } else if (Array.isArray(filterCityId)) { + storeWhere.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] }; } else if (filterCityId !== undefined) { storeWhere.cityId = filterCityId; } @@ -253,28 +439,56 @@ export class AdminDashboardService { redeemWhere.id = { in: [] }; } else { const storeFilter: Prisma.StoreWhereInput = {}; - if (filterCityId !== undefined) storeFilter.cityId = filterCityId; + if (Array.isArray(filterCityId)) { + storeFilter.cityId = { in: filterCityId.length ? filterCityId : [BigInt(0)] }; + } else if (filterCityId !== undefined) { + storeFilter.cityId = filterCityId; + } if (filterPartnerId !== undefined) storeFilter.partnerAccountId = filterPartnerId; if (Object.keys(storeFilter).length) { redeemWhere.store = storeFilter; } } - const skipOrders = filterCityId === null; + const skipOrders = filterCityId === null || !can('orders'); + const cityListWhere: Prisma.CommonCityWhereInput = + scope === null ? {} : { id: { in: scope.length ? scope : [BigInt(0)] } }; const [users, orders, partners, stores, redeems, cities, promos, partnerNames] = await Promise.all([ - this.prisma.user.findMany({ - where: userWhere, - select: { - id: true, - createdAt: true, - cityPreference: { select: { selectedCityCode: true } }, - promoTouch: { select: { promoCodeId: true } }, - }, - }), + can('users') + ? this.prisma.user.findMany({ + where: userWhere, + select: { + id: true, + createdAt: true, + cityPreference: { select: { selectedCityCode: true } }, + promoTouch: { select: { promoCodeId: true } }, + }, + }) + : emptyRows< + Prisma.UserGetPayload<{ + select: { + id: true; + createdAt: true; + cityPreference: { select: { selectedCityCode: true } }; + promoTouch: { select: { promoCodeId: true } }; + }; + }> + >(), skipOrders - ? Promise.resolve([]) + ? emptyRows< + Prisma.OrderGetPayload<{ + select: { + id: true; + userId: true; + createdAt: true; + cityId: true; + promoCodeId: true; + payStatus: true; + }; + }> + >() : this.prisma.order.findMany({ where: orderWhere, select: { @@ -286,45 +500,81 @@ export class AdminDashboardService { payStatus: true, }, }), - this.prisma.partnerAccount.findMany({ - where: partnerWhere, - select: { - id: true, - createdAt: true, - cityId: true, - companyName: true, - name: true, - }, - }), - this.prisma.store.findMany({ - where: storeWhere, - select: { - id: true, - createdAt: true, - cityId: true, - partnerAccountId: true, - }, - }), - this.prisma.redeemRecord.findMany({ - where: redeemWhere, - select: { - id: true, - createdAt: true, - amount: true, - settleAmount: true, - store: { select: { cityId: true, partnerAccountId: true } }, - }, - }), + can('partners') + ? this.prisma.partnerAccount.findMany({ + where: partnerWhere, + select: { + id: true, + createdAt: true, + cityId: true, + companyName: true, + name: true, + }, + }) + : emptyRows< + Prisma.PartnerAccountGetPayload<{ + select: { id: true; createdAt: true; cityId: true; companyName: true; name: true }; + }> + >(), + can('stores') + ? this.prisma.store.findMany({ + where: storeWhere, + select: { + id: true, + createdAt: true, + cityId: true, + partnerAccountId: true, + }, + }) + : emptyRows< + Prisma.StoreGetPayload<{ + select: { id: true; createdAt: true; cityId: true; partnerAccountId: true }; + }> + >(), + can('benefit') + ? this.prisma.redeemRecord.findMany({ + where: redeemWhere, + select: { + id: true, + createdAt: true, + amount: true, + settleAmount: true, + store: { select: { cityId: true, partnerAccountId: true } }, + }, + }) + : emptyRows< + Prisma.RedeemRecordGetPayload<{ + select: { + id: true; + createdAt: true; + amount: true; + settleAmount: true; + store: { select: { cityId: true; partnerAccountId: true } }; + }; + }> + >(), this.prisma.commonCity.findMany({ + where: cityListWhere, select: { id: true, code: true, name: true }, }), - this.prisma.commonPromoCode.findMany({ - select: { id: true, code: true, name: true }, - }), - this.prisma.partnerAccount.findMany({ - where: { isPrimary: 1 }, - select: { id: true, companyName: true, name: true }, - }), + can('promo_codes') + ? this.prisma.commonPromoCode.findMany({ + select: { id: true, code: true, name: true }, + }) + : emptyRows>(), + can('partners') || can('stores') || can('benefit') + ? this.prisma.partnerAccount.findMany({ + where: { + isPrimary: 1, + ...(cityIdFilter(scope) ? { cityId: cityIdFilter(scope) } : {}), + }, + select: { id: true, companyName: true, name: true }, + }) + : emptyRows< + Prisma.PartnerAccountGetPayload<{ + select: { id: true; companyName: true; name: true }; + }> + >(), ]); const cityByCode = new Map(cities.map((c) => [c.code, c])); diff --git a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts index 6217234..44deff5 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts @@ -1,11 +1,25 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import type { HqAdminRoleValue } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto'; import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto'; import { hashPassword } from '../../common/crypto/password.util'; +const HQ_ACCOUNT_SELECT = { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + cities: { select: { cityId: true } }, +} satisfies Prisma.HqAccountSelect; + function mapHqAccountRow(account: { id: bigint; phone: string; @@ -16,6 +30,7 @@ function mapHqAccountRow(account: { status: string; lastLoginAt: Date | null; createdAt: Date; + cities?: { cityId: bigint }[]; }) { return { id: account.id, @@ -27,6 +42,7 @@ function mapHqAccountRow(account: { status: account.status, lastLoginAt: account.lastLoginAt, createdAt: account.createdAt, + cityIds: (account.cities ?? []).map((c) => c.cityId.toString()), }; } @@ -34,6 +50,31 @@ function mapHqAccountRow(account: { export class AdminHqAccountsService { constructor(private readonly prisma: PrismaService) {} + private async assertCityIds(cityIds: string[]) { + const unique = [...new Set(cityIds.map((id) => id.trim()).filter(Boolean))]; + if (!unique.length) return [] as bigint[]; + const ids = unique.map((id) => BigInt(id)); + const count = await this.prisma.commonCity.count({ where: { id: { in: ids } } }); + if (count !== ids.length) { + throw new BadRequestException('存在无效城市'); + } + return ids; + } + + private assertCityRequirement(adminRole: string, cityIds: bigint[]) { + if (adminRole === 'CITY_STORE_SERVICE' && !cityIds.length) { + throw new BadRequestException('城市门店服务须至少勾选一个负责城市'); + } + } + + private async replaceCities(tx: Prisma.TransactionClient, hqAccountId: bigint, cityIds: bigint[]) { + await tx.hqAccountCity.deleteMany({ where: { hqAccountId } }); + if (!cityIds.length) return; + await tx.hqAccountCity.createMany({ + data: cityIds.map((cityId) => ({ hqAccountId, cityId })), + }); + } + async list(query: AdminHqAccountsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; @@ -48,17 +89,7 @@ export class AdminHqAccountsService { orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, - select: { - id: true, - phone: true, - loginName: true, - passwordHash: true, - name: true, - adminRole: true, - status: true, - lastLoginAt: true, - createdAt: true, - }, + select: HQ_ACCOUNT_SELECT, }), this.prisma.hqAccount.count({ where }), ]); @@ -73,34 +104,31 @@ export class AdminHqAccountsService { async detail(id: bigint) { const account = await this.prisma.hqAccount.findUnique({ where: { id }, - select: { - id: true, - phone: true, - loginName: true, - passwordHash: true, - name: true, - adminRole: true, - status: true, - lastLoginAt: true, - createdAt: true, - }, + select: HQ_ACCOUNT_SELECT, }); if (!account) throw new NotFoundException('HQ 账号不存在'); return serializeBigInt(mapHqAccountRow(account)); } async create(dto: CreateHqAccountDto) { - const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; + const adminRole = (dto.adminRole ?? 'OPS') as HqAdminRoleValue; + const cityIds = await this.assertCityIds(dto.cityIds ?? []); + this.assertCityRequirement(adminRole, cityIds); if (dto.credentialType === 'phone') { if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号'); const phone = dto.phone.trim(); const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); if (exists) throw new BadRequestException('手机号已存在'); - const account = await this.prisma.hqAccount.create({ - data: { phone, name: dto.name, adminRole }, + const account = await this.prisma.$transaction(async (tx) => { + const created = await tx.hqAccount.create({ + data: { phone, name: dto.name, adminRole }, + select: HQ_ACCOUNT_SELECT, + }); + await this.replaceCities(tx, created.id, cityIds); + return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT }); }); - return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null })); + return serializeBigInt(mapHqAccountRow(account)); } if (!dto.loginName?.trim() || !dto.password) { @@ -114,27 +142,34 @@ export class AdminHqAccountsService { const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } }); if (phoneTaken) throw new BadRequestException('手机号已存在'); - const account = await this.prisma.hqAccount.create({ - data: { - phone, - loginName, - passwordHash: hashPassword(dto.password), - name: dto.name, - adminRole, - }, + const account = await this.prisma.$transaction(async (tx) => { + const created = await tx.hqAccount.create({ + data: { + phone, + loginName, + passwordHash: hashPassword(dto.password!), + name: dto.name, + adminRole, + }, + select: HQ_ACCOUNT_SELECT, + }); + await this.replaceCities(tx, created.id, cityIds); + return tx.hqAccount.findUniqueOrThrow({ where: { id: created.id }, select: HQ_ACCOUNT_SELECT }); }); return serializeBigInt(mapHqAccountRow(account)); } async update(id: bigint, dto: UpdateHqAccountDto) { - const current = await this.prisma.hqAccount.findUnique({ where: { id } }); + const current = await this.prisma.hqAccount.findUnique({ + where: { id }, + include: { cities: { select: { cityId: true } } }, + }); if (!current) throw new NotFoundException('HQ 账号不存在'); - if (dto.loginName !== undefined) { - const loginName = dto.loginName.trim(); - if (!loginName) throw new BadRequestException('用户名不能为空'); + const loginNameInput = dto.loginName === undefined ? undefined : dto.loginName.trim(); + if (loginNameInput) { const conflict = await this.prisma.hqAccount.findFirst({ - where: { loginName, id: { not: id } }, + where: { loginName: loginNameInput, id: { not: id } }, }); if (conflict) throw new BadRequestException('用户名已存在'); } @@ -150,29 +185,34 @@ export class AdminHqAccountsService { } } - const account = await this.prisma.hqAccount.update({ - where: { id }, - data: { - ...(dto.name !== undefined ? { name: dto.name } : {}), - ...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}), - ...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}), - ...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}), - ...(dto.adminRole !== undefined - ? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' } - : {}), - ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), - }, - select: { - id: true, - phone: true, - loginName: true, - passwordHash: true, - name: true, - adminRole: true, - status: true, - lastLoginAt: true, - createdAt: true, - }, + const nextRole = (dto.adminRole ?? current.adminRole) as HqAdminRoleValue; + const nextCityIds = + dto.cityIds !== undefined + ? await this.assertCityIds(dto.cityIds) + : current.cities.map((c) => c.cityId); + this.assertCityRequirement(nextRole, nextCityIds); + + const roleChanged = dto.adminRole !== undefined && dto.adminRole !== current.adminRole; + + const account = await this.prisma.$transaction(async (tx) => { + await tx.hqAccount.update({ + where: { id }, + data: { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}), + ...(loginNameInput ? { loginName: loginNameInput } : {}), + ...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}), + ...(dto.adminRole !== undefined ? { adminRole: nextRole } : {}), + ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), + }, + }); + if (dto.cityIds !== undefined) { + await this.replaceCities(tx, id, nextCityIds); + } + if (roleChanged) { + await tx.hqAccountPermission.deleteMany({ where: { hqAccountId: id } }); + } + return tx.hqAccount.findUniqueOrThrow({ where: { id }, select: HQ_ACCOUNT_SELECT }); }); return serializeBigInt(mapHqAccountRow(account)); } diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts index 345bb38..770925f 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts @@ -45,6 +45,7 @@ export class AdminHqPermissionsController { includeBody: true, }) saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) { - return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys); + const grantKeys = dto.grantKeys ?? dto.permissionKeys ?? []; + return this.service.saveAccountPermissions(BigInt(id), grantKeys, dto.denyKeys ?? []); } } diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts index 942be9b..4903b16 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts @@ -1,9 +1,12 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { + HQ_ADMIN_ROLE_VALUES, HQ_PERMISSION_CATALOG, HQ_ROLE_DEFAULT_PERMISSIONS, LEGACY_SYSTEM_SETTINGS_KEY, + computeHqEffectivePermissionKeys, expandHqPermissionKeys, + type HqAdminRoleValue, type HqPermissionKey, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; @@ -14,6 +17,10 @@ const VALID_PERMISSION_KEYS = new Set([ LEGACY_SYSTEM_SETTINGS_KEY, ]); +const EDITABLE_ROLES = new Set( + HQ_ADMIN_ROLE_VALUES.filter((r) => r !== 'SUPER_ADMIN'), +); + function assertPermissionKeys(keys: string[]) { const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key)); if (invalid.length) { @@ -21,6 +28,13 @@ function assertPermissionKeys(keys: string[]) { } } +function asAdminRole(role: string): HqAdminRoleValue { + if (!(HQ_ADMIN_ROLE_VALUES as readonly string[]).includes(role)) { + throw new BadRequestException(`无效角色: ${role}`); + } + return role as HqAdminRoleValue; +} + @Injectable() export class AdminHqPermissionsService { constructor(private readonly prisma: PrismaService) {} @@ -28,32 +42,36 @@ export class AdminHqPermissionsService { catalog() { return { permissions: HQ_PERMISSION_CATALOG, - roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({ + roles: HQ_ADMIN_ROLE_VALUES.map((role) => ({ role, - permissionKeys, + permissionKeys: HQ_ROLE_DEFAULT_PERMISSIONS[role], })), }; } async getRolePermissions(role: string) { + const adminRole = asAdminRole(role); const rows = await this.prisma.hqRolePermission.findMany({ - where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }, + where: { adminRole }, select: { permissionKey: true }, }); const permissionKeys = rows.length > 0 ? expandHqPermissionKeys(rows.map((r) => r.permissionKey)) - : [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])]; - return { role, permissionKeys }; + : [...(HQ_ROLE_DEFAULT_PERMISSIONS[adminRole] ?? [])]; + return { role: adminRole, permissionKeys }; } async saveRolePermissions(role: string, permissionKeys: string[]) { - if (role === 'SUPER_ADMIN') { + const adminRole = asAdminRole(role); + if (adminRole === 'SUPER_ADMIN') { throw new BadRequestException('超级管理员基础权限固定,危险操作请按用户单独授权'); } + if (!EDITABLE_ROLES.has(adminRole)) { + throw new BadRequestException(`无效角色: ${role}`); + } assertPermissionKeys(permissionKeys); const normalized = expandHqPermissionKeys(permissionKeys); - const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; await this.prisma.$transaction([ this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }), ...(normalized.length @@ -76,51 +94,84 @@ export class AdminHqPermissionsService { const userPerms = await this.prisma.hqAccountPermission.findMany({ where: { hqAccountId: accountId }, - select: { permissionKey: true }, + select: { permissionKey: true, effect: true }, }); - const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey)); + const grantKeys = expandHqPermissionKeys( + userPerms.filter((p) => p.effect !== 'DENY').map((p) => p.permissionKey), + ); + const denyKeys = expandHqPermissionKeys( + userPerms.filter((p) => p.effect === 'DENY').map((p) => p.permissionKey), + ); if (account.adminRole === 'SUPER_ADMIN') { - const rolePermissionKeys = HQ_PERMISSION_CATALOG.map( - (p) => p.key, - ) as HqPermissionKey[]; - const effectivePermissionKeys = [ - ...new Set([...rolePermissionKeys, ...userPermissionKeys]), - ] as HqPermissionKey[]; + const rolePermissionKeys = HQ_PERMISSION_CATALOG.map((p) => p.key) as HqPermissionKey[]; + const effectivePermissionKeys = computeHqEffectivePermissionKeys( + rolePermissionKeys, + grantKeys, + [], + ); return serializeBigInt({ account, - permissionKeys: userPermissionKeys, + permissionKeys: grantKeys, rolePermissionKeys, - userPermissionKeys, + grantKeys, + denyKeys: [] as HqPermissionKey[], + userPermissionKeys: grantKeys, effectivePermissionKeys, }); } const rolePerms = await this.getRolePermissions(account.adminRole); - const effectivePermissionKeys = [ - ...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), - ] as HqPermissionKey[]; + const effectivePermissionKeys = computeHqEffectivePermissionKeys( + rolePerms.permissionKeys, + grantKeys, + denyKeys, + ); return serializeBigInt({ account, - permissionKeys: userPermissionKeys, + permissionKeys: grantKeys, rolePermissionKeys: rolePerms.permissionKeys, - userPermissionKeys, + grantKeys, + denyKeys, + userPermissionKeys: grantKeys, effectivePermissionKeys, }); } - async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) { + async saveAccountPermissions( + accountId: bigint, + grantKeysInput: string[], + denyKeysInput: string[] = [], + ) { const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } }); if (!account) throw new NotFoundException('HQ 账号不存在'); - assertPermissionKeys(permissionKeys); - const normalized = expandHqPermissionKeys(permissionKeys); + assertPermissionKeys(grantKeysInput); + assertPermissionKeys(denyKeysInput); + const grantKeys = expandHqPermissionKeys(grantKeysInput); + const denyKeys = + account.adminRole === 'SUPER_ADMIN' ? [] : expandHqPermissionKeys(denyKeysInput); + const overlap = grantKeys.filter((k) => denyKeys.includes(k)); + if (overlap.length) { + throw new BadRequestException(`同一权限不能同时追加和撤销: ${overlap.join(', ')}`); + } await this.prisma.$transaction([ this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }), - ...(normalized.length + ...(grantKeys.length || denyKeys.length ? [ this.prisma.hqAccountPermission.createMany({ - data: normalized.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })), + data: [ + ...grantKeys.map((permissionKey) => ({ + hqAccountId: accountId, + permissionKey, + effect: 'GRANT' as const, + })), + ...denyKeys.map((permissionKey) => ({ + hqAccountId: accountId, + permissionKey, + effect: 'DENY' as const, + })), + ], }), ] : []), diff --git a/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts b/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts index 0285678..d984f58 100644 --- a/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-store-categories.controller.ts @@ -1,5 +1,9 @@ import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { StoreCategoryService } from '../store/store-category.service'; @@ -9,7 +13,8 @@ import { } from './dto/admin-mutate.dto'; @Controller('admin/store-categories') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('store_categories') export class AdminStoreCategoriesController { constructor(private readonly categories: StoreCategoryService) {} @@ -24,6 +29,7 @@ export class AdminStoreCategoriesController { } @Post('ensure-defaults') + @RequireHqPermissions('store_categories_delete') @HqOperation({ action: HqOperationAction.STORE_CATEGORY_ENSURE, refType: 'STORE_CATEGORY', @@ -57,6 +63,7 @@ export class AdminStoreCategoriesController { } @Delete(':id') + @RequireHqPermissions('store_categories_delete') @HqOperation({ action: HqOperationAction.STORE_CATEGORY_DELETE, refType: 'STORE_CATEGORY', diff --git a/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts b/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts index 4e8208f..645b74e 100644 --- a/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-store-ratings.controller.ts @@ -1,15 +1,22 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { AdminStoreRatingsService } from './admin-store-ratings.service'; import { AdminStoreRatingsQueryDto } from './dto/admin-query.dto'; @Controller('admin/store-ratings') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('store_ratings') export class AdminStoreRatingsController { constructor(private readonly service: AdminStoreRatingsService) {} @Get() - list(@Query() query: AdminStoreRatingsQueryDto) { - return this.service.list(query); + list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreRatingsQueryDto) { + return this.service.list(query, user.actorId); } } diff --git a/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts b/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts index 2def44d..ae5a5fa 100644 --- a/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts +++ b/server/dukang-api/src/modules/ops/admin-store-ratings.service.ts @@ -3,15 +3,26 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminStoreRatingsQueryDto } from './dto/admin-query.dto'; +import { HqPermissionsResolver, mergeHqStoreCityWhere } from '../../common/guards/hq-permission.guard'; @Injectable() export class AdminStoreRatingsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly hqPermissions: HqPermissionsResolver, + ) {} - async list(query: AdminStoreRatingsQueryDto) { + async list(query: AdminStoreRatingsQueryDto, actorId: bigint) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.StoreRatingWhereInput = {}; + const scope = await this.hqPermissions.resolveCityScope(actorId); + if (query.storeId) { + await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId)); + } + const storeWhere = mergeHqStoreCityWhere({}, scope, query.cityId); + const where: Prisma.StoreRatingWhereInput = { + store: storeWhere, + }; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.redeemNo) { where.redeemRecord = { redeemNo: { contains: query.redeemNo } }; diff --git a/server/dukang-api/src/modules/ops/admin-stores.controller.ts b/server/dukang-api/src/modules/ops/admin-stores.controller.ts index 068c874..4a7d847 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.controller.ts @@ -1,7 +1,13 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { AdminStoresService } from './admin-stores.service'; import { AdminStoreAccountsQueryDto, @@ -19,58 +25,64 @@ import { } from './dto/admin-mutate.dto'; @Controller('admin/stores') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('stores') export class AdminStoresController { constructor(private readonly service: AdminStoresService) {} @Get() - list(@Query() query: AdminStoresQueryDto) { - return this.service.listStores(query); + list(@CurrentUser() user: AuthUser, @Query() query: AdminStoresQueryDto) { + return this.service.listStores(query, user.actorId); } @Get(':id') - detail(@Param('id') id: string) { - return this.service.detailStore(BigInt(id)); + detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.service.detailStore(BigInt(id), user.actorId); } @Post() @HqOperation({ action: HqOperationAction.STORE_CREATE, refType: 'STORE', refIdField: 'id', includeBody: true }) - create(@Body() dto: CreateStoreDto) { - return this.service.createStore(dto); + create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreDto) { + return this.service.createStore(dto, user.actorId); } @Put(':id') @HqOperation({ action: HqOperationAction.STORE_UPDATE, refType: 'STORE', refIdParam: 'id', includeBody: true }) - update(@Param('id') id: string, @Body() dto: UpdateStoreDto) { - return this.service.updateStore(BigInt(id), dto); + update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreDto) { + return this.service.updateStore(BigInt(id), dto, user.actorId); } @Put(':id/status') @HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true }) - updateStatus(@Param('id') id: string, @Body() dto: UpdateStoreStatusDto) { - return this.service.updateStoreStatus(BigInt(id), dto); + updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) { + return this.service.updateStoreStatus(BigInt(id), dto, user.actorId); } @Put(':id/audit') @HqOperation({ action: HqOperationAction.STORE_AUDIT, refType: 'STORE', refIdParam: 'id', includeBody: true }) - audit(@Param('id') id: string, @Body() body: { approved: boolean; remark?: string }) { - return this.service.auditStore(BigInt(id), body); + audit( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() body: { approved: boolean; remark?: string }, + ) { + return this.service.auditStore(BigInt(id), body, user.actorId); } } @Controller('admin/store-accounts') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('store_accounts') export class AdminStoreAccountsController { constructor(private readonly service: AdminStoresService) {} @Get() - list(@Query() query: AdminStoreAccountsQueryDto) { - return this.service.listStoreAccounts(query); + list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreAccountsQueryDto) { + return this.service.listStoreAccounts(query, user.actorId); } @Get(':id') - detail(@Param('id') id: string) { - return this.service.detailStoreAccount(BigInt(id)); + detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.service.detailStoreAccount(BigInt(id), user.actorId); } @Post() @@ -80,8 +92,8 @@ export class AdminStoreAccountsController { refIdField: 'id', includeBody: true, }) - create(@Body() dto: CreateStoreAccountDto) { - return this.service.createStoreAccount(dto); + create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreAccountDto) { + return this.service.createStoreAccount(dto, user.actorId); } @Put(':id') @@ -91,8 +103,8 @@ export class AdminStoreAccountsController { refIdParam: 'id', includeBody: true, }) - update(@Param('id') id: string, @Body() dto: UpdateStoreAccountDto) { - return this.service.updateStoreAccount(BigInt(id), dto); + update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreAccountDto) { + return this.service.updateStoreAccount(BigInt(id), dto, user.actorId); } @Delete(':id/staff/:staffId') @@ -101,19 +113,20 @@ export class AdminStoreAccountsController { refType: 'STORE_ACCOUNT', refIdParam: 'staffId', }) - deleteStaff(@Param('id') id: string, @Param('staffId') staffId: string) { - return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId)); + deleteStaff(@CurrentUser() user: AuthUser, @Param('id') id: string, @Param('staffId') staffId: string) { + return this.service.deleteStoreStaff(BigInt(id), BigInt(staffId), user.actorId); } } @Controller('admin/store-media') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('store_media') export class AdminStoreMediaController { constructor(private readonly service: AdminStoresService) {} @Get() - list(@Query() query: AdminStoreMediaQueryDto) { - return this.service.listStoreMedia(query); + list(@CurrentUser() user: AuthUser, @Query() query: AdminStoreMediaQueryDto) { + return this.service.listStoreMedia(query, user.actorId); } @Post() @@ -123,8 +136,8 @@ export class AdminStoreMediaController { refIdField: 'id', includeBody: true, }) - create(@Body() dto: CreateStoreMediaDto) { - return this.service.createStoreMedia(dto); + create(@CurrentUser() user: AuthUser, @Body() dto: CreateStoreMediaDto) { + return this.service.createStoreMedia(dto, user.actorId); } @Put(':id') @@ -132,15 +145,14 @@ export class AdminStoreMediaController { action: HqOperationAction.STORE_MEDIA_UPDATE, refType: 'STORE_MEDIA', refIdParam: 'id', - includeBody: true, }) - update(@Param('id') id: string, @Body() dto: UpdateStoreMediaDto) { - return this.service.updateStoreMedia(BigInt(id), dto); + update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreMediaDto) { + return this.service.updateStoreMedia(BigInt(id), dto, user.actorId); } @Delete(':id') @HqOperation({ action: HqOperationAction.STORE_MEDIA_DELETE, refType: 'STORE_MEDIA', refIdParam: 'id' }) - remove(@Param('id') id: string) { - return this.service.deleteStoreMedia(BigInt(id)); + remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.service.deleteStoreMedia(BigInt(id), user.actorId); } } diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 8e6dade..c2e59fc 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; @@ -19,6 +19,7 @@ import type { UpdateStoreStatusDto, } from './dto/admin-mutate.dto'; import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service'; +import { HqPermissionsResolver, hqStoreCityWhere, mergeHqStoreCityWhere, type HqCityScope } from '../../common/guards/hq-permission.guard'; /** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */ function normalizeStoreOptionalText(value: unknown): string | null { @@ -54,21 +55,54 @@ export class AdminStoresService { private readonly storeCategoryService: StoreCategoryService, private readonly analyticsService: AnalyticsService, private readonly testWhitelist: TestWhitelistService, + private readonly hqPermissions: HqPermissionsResolver, ) {} - async listStores(query: AdminStoresQueryDto) { + private async storeIdsInScope(scope: HqCityScope): Promise { + const where = hqStoreCityWhere(scope); + if (!where) return null; + const rows = await this.prisma.store.findMany({ where, select: { id: true } }); + return rows.length ? rows.map((r) => r.id) : [BigInt(0)]; + } + + private async assertStoreAccountInScope(actorId: bigint, accountId: bigint) { + const account = await this.prisma.storeAccount.findUnique({ + where: { id: accountId }, + include: { bindings: { include: { store: { select: { cityId: true } } } } }, + }); + if (!account) throw new NotFoundException('门店账号不存在'); + const scope = await this.hqPermissions.resolveCityScope(actorId); + if (scope === null) return account; + const ok = account.bindings.some((b) => scope.some((id) => id === b.store.cityId)); + if (!ok) throw new ForbiddenException('无权访问该城市的门店'); + return account; + } + + private async assertMediaInScope(actorId: bigint, mediaId: bigint) { + const media = await this.prisma.commonResource.findUnique({ + where: { id: mediaId }, + select: { ownerType: true, ownerId: true }, + }); + if (!media) throw new NotFoundException('资源不存在'); + if (media.ownerType === 'STORE') { + await this.hqPermissions.assertStoreIdInScope(actorId, media.ownerId); + } + } + + async listStores(query: AdminStoresQueryDto, actorId: bigint) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.StoreWhereInput = {}; + const scope = await this.hqPermissions.resolveCityScope(actorId); + let where: Prisma.StoreWhereInput = {}; if (query.name) where.name = { contains: query.name }; if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals']; if (query.auditStatus) { where.auditStatus = query.auditStatus as Prisma.EnumStoreAuditStatusFilter['equals']; } - if (query.cityId) where.cityId = BigInt(query.cityId); if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.phone) where.phone = { contains: query.phone }; if (query.excludeTest) where.isTest = false; + where = mergeHqStoreCityWhere(where, scope, query.cityId); const [items, total] = await Promise.all([ this.prisma.store.findMany({ @@ -98,9 +132,10 @@ export class AdminStoresService { const pendingByStore = new Map(); // 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用 const pendingInfoByStore = new Map(); + const redeemedByStore = new Map(); if (items.length) { const storeIds = items.map((s) => s.id); - const [pendingReqs, pendingInfoReqs] = await Promise.all([ + const [pendingReqs, pendingInfoReqs, redeemSums] = await Promise.all([ this.prisma.storePackageChangeRequest.findMany({ where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id: true, storeId: true }, @@ -109,9 +144,17 @@ export class AdminStoresService { where: { storeId: { in: storeIds }, status: 'PENDING' }, select: { id: true, storeId: true }, }), + this.prisma.redeemRecord.groupBy({ + by: ['storeId'], + where: { storeId: { in: storeIds } }, + _sum: { amount: true }, + }), ]); for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString()); for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString()); + for (const r of redeemSums) { + redeemedByStore.set(r.storeId.toString(), r._sum.amount != null ? Number(r._sum.amount) : 0); + } } return serializeBigInt({ @@ -124,6 +167,7 @@ export class AdminStoresService { // 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃 pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null, pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null, + totalRedeemedBenefitAmount: redeemedByStore.get(s.id.toString()) ?? 0, partner: s.partnerAccount, account: s.bindings[0]?.storeAccount ?? null, bindings: undefined, @@ -135,7 +179,8 @@ export class AdminStoresService { }); } - async detailStore(id: bigint) { + async detailStore(id: bigint, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, id); const store = await this.prisma.store.findUnique({ where: { id }, include: { @@ -184,7 +229,8 @@ export class AdminStoresService { })); } - async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto) { + async updateStoreStatus(id: bigint, dto: UpdateStoreStatusDto, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, id); const store = await this.prisma.store.update({ where: { id }, data: { status: dto.status as 'OPEN' | 'PAUSED' | 'CLOSED' }, @@ -192,7 +238,8 @@ export class AdminStoresService { return serializeBigInt(store); } - async auditStore(id: bigint, dto: { approved: boolean; remark?: string }) { + async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, id); const store = await this.prisma.store.findUnique({ where: { id } }); if (!store) throw new NotFoundException('门店不存在'); if (store.auditStatus !== 'PENDING' && store.auditStatus !== 'REJECTED') { @@ -258,7 +305,8 @@ export class AdminStoresService { }); } - async updateStore(id: bigint, dto: UpdateStoreDto) { + async updateStore(id: bigint, dto: UpdateStoreDto, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, id); const current = await this.prisma.store.findUnique({ where: { id } }); if (!current) throw new NotFoundException('门店不存在'); @@ -524,10 +572,11 @@ export class AdminStoresService { } }); - return this.detailStore(id); + return this.detailStore(id, actorId); } - async createStore(dto: CreateStoreDto) { + async createStore(dto: CreateStoreDto, actorId: bigint) { + await this.hqPermissions.assertStoreCityInScope(actorId, BigInt(dto.cityId)); const normalizedPhone = dto.phone.trim(); if (!isMobilePhone(normalizedPhone)) { throw new BadRequestException('请输入正确的手机号码'); @@ -727,10 +776,11 @@ export class AdminStoresService { }); } - return this.detailStore(store.id); + return this.detailStore(store.id, actorId); } - async createStoreAccount(dto: CreateStoreAccountDto) { + async createStoreAccount(dto: CreateStoreAccountDto, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId)); const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) }, include: { bindings: true }, @@ -768,15 +818,24 @@ export class AdminStoresService { return serializeBigInt(account); } - async listStoreMedia(query: AdminStoreMediaQueryDto) { + async listStoreMedia(query: AdminStoreMediaQueryDto, actorId: bigint) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; + const scope = await this.hqPermissions.resolveCityScope(actorId); + if (query.storeId) { + await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId)); + } const where: Prisma.CommonResourceWhereInput = { ownerType: 'STORE', status: 'ACTIVE', }; if (query.storeId) where.ownerId = BigInt(query.storeId); if (query.mediaType) where.mediaType = query.mediaType as Prisma.EnumResourceMediaTypeFilter['equals']; + const storeScope = hqStoreCityWhere(scope); + if (storeScope && !query.storeId) { + const ids = await this.storeIdsInScope(scope); + if (ids) where.ownerId = { in: ids }; + } const [items, total] = await Promise.all([ this.prisma.commonResource.findMany({ @@ -790,7 +849,8 @@ export class AdminStoresService { return serializeBigInt({ items, total, page, pageSize }); } - async createStoreMedia(dto: CreateStoreMediaDto) { + async createStoreMedia(dto: CreateStoreMediaDto, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(dto.storeId)); const store = await this.prisma.store.findUnique({ where: { id: BigInt(dto.storeId) } }); if (!store) throw new BadRequestException('门店不存在'); const media = await this.prisma.commonResource.create({ @@ -808,7 +868,8 @@ export class AdminStoresService { return serializeBigInt(media); } - async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto) { + async updateStoreMedia(id: bigint, dto: UpdateStoreMediaDto, actorId: bigint) { + await this.assertMediaInScope(actorId, id); const media = await this.prisma.commonResource.update({ where: { id }, data: { @@ -820,7 +881,8 @@ export class AdminStoresService { return serializeBigInt(media); } - async deleteStoreMedia(id: bigint) { + async deleteStoreMedia(id: bigint, actorId: bigint) { + await this.assertMediaInScope(actorId, id); await this.prisma.commonResource.update({ where: { id }, data: { status: 'DELETED' }, @@ -828,13 +890,20 @@ export class AdminStoresService { return { ok: true }; } - async listStoreAccounts(query: AdminStoreAccountsQueryDto) { + async listStoreAccounts(query: AdminStoreAccountsQueryDto, actorId: bigint) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; + if (query.storeId) { + await this.hqPermissions.assertStoreIdInScope(actorId, BigInt(query.storeId)); + } + const scope = await this.hqPermissions.resolveCityScope(actorId); const where: Prisma.StoreAccountWhereInput = { isPrimary: 1 }; if (query.phone) where.phone = { contains: query.phone }; if (query.storeId) { where.bindings = { some: { storeId: BigInt(query.storeId) } }; + } else { + const storeWhere = hqStoreCityWhere(scope); + if (storeWhere) where.bindings = { some: { store: storeWhere } }; } if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; if (query.excludeTest) where.isTest = false; @@ -866,7 +935,8 @@ export class AdminStoresService { return serializeBigInt({ items: mapped, total, page, pageSize }); } - async detailStoreAccount(id: bigint) { + async detailStoreAccount(id: bigint, actorId: bigint) { + await this.assertStoreAccountInScope(actorId, id); const account = await this.prisma.storeAccount.findUnique({ where: { id }, include: { @@ -895,7 +965,8 @@ export class AdminStoresService { }); } - async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto) { + async updateStoreAccount(id: bigint, dto: UpdateStoreAccountDto, actorId: bigint) { + await this.assertStoreAccountInScope(actorId, id); const account = await this.prisma.storeAccount.update({ where: { id }, data: { @@ -908,7 +979,8 @@ export class AdminStoresService { } /** HQ 删除门店子账号(非主账号) */ - async deleteStoreStaff(parentAccountId: bigint, staffId: bigint) { + async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) { + await this.assertStoreAccountInScope(actorId, parentAccountId); const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } }); if (!parent || parent.isPrimary !== 1) { throw new BadRequestException('主账号不存在'); diff --git a/server/dukang-api/src/modules/ops/admin-users.controller.ts b/server/dukang-api/src/modules/ops/admin-users.controller.ts index 1df998a..7d4ced9 100644 --- a/server/dukang-api/src/modules/ops/admin-users.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-users.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqPermissionGuard, @@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminUsersService } from './admin-users.service'; import { AdminUsersQueryDto } from './dto/admin-query.dto'; -import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto } from './dto/admin-mutate.dto'; +import { BatchDeleteUsersConfirmDto, BatchDeleteUsersDto, UpdateAdminUserDto } from './dto/admin-mutate.dto'; @Controller('admin/users') @UseGuards(HqAuthGuard) @@ -48,6 +48,17 @@ export class AdminUsersController { return this.usersService.detail(BigInt(id)); } + @Put(':id') + @HqOperation({ + action: HqOperationAction.USER_UPDATE, + refType: 'USER', + refIdParam: 'id', + includeBody: true, + }) + update(@Param('id') id: string, @Body() dto: UpdateAdminUserDto) { + return this.usersService.updateUser(BigInt(id), dto); + } + @Delete(':id') @UseGuards(HqPermissionGuard) @RequireHqPermissions('users_delete') diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index 27008b7..b66a577 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -188,6 +188,18 @@ export class AdminUsersService { }); } + async updateUser(id: bigint, dto: { nickname: string }) { + const user = await this.prisma.user.findUnique({ where: { id }, select: { id: true } }); + if (!user) throw new NotFoundException('用户不存在'); + const nickname = dto.nickname.trim() || null; + const updated = await this.prisma.user.update({ + where: { id }, + data: { nickname }, + select: { id: true, userNo: true, nickname: true }, + }); + return serializeBigInt(updated); + } + async previewBatchDelete(ids: bigint[]) { const uniqueIds = [...new Set(ids)]; if (!uniqueIds.length) { diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 6ad038b..9abf5cf 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -949,6 +949,12 @@ export class BatchDeleteUsersConfirmDto extends BatchDeleteUsersDto { confirmRisk: boolean; } +export class UpdateAdminUserDto { + @IsString() + @MaxLength(64) + nickname: string; +} + /** HQ 订单发货(目前仅小飞侠 XFX) */ export class AdminShipOrderDto { @IsIn(['XFX']) @@ -1109,8 +1115,13 @@ export class CreateHqAccountDto { name: string; @IsOptional() - @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE']) + @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE']) adminRole?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + cityIds?: string[]; } export class UpdateHqAccountDto { @@ -1131,12 +1142,17 @@ export class UpdateHqAccountDto { name?: string; @IsOptional() - @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE']) + @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE']) adminRole?: string; @IsOptional() @IsIn(['ACTIVE', 'DISABLED']) status?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + cityIds?: string[]; } export class SaveHqRolePermissionsDto { @@ -1146,9 +1162,20 @@ export class SaveHqRolePermissionsDto { } export class SaveHqAccountPermissionsDto { + @IsOptional() @IsArray() @IsString({ each: true }) - permissionKeys: string[]; + permissionKeys?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + grantKeys?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + denyKeys?: string[]; } export class CreateProductDto { diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index fcfb005..d6f3639 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -352,6 +352,10 @@ export class AdminStoreRatingsQueryDto extends PaginationQueryDto { @IsString() storeId?: string; + @IsOptional() + @IsString() + cityId?: string; + @IsOptional() @IsString() redeemNo?: string; diff --git a/server/dukang-api/src/modules/store/store-info-change.controller.ts b/server/dukang-api/src/modules/store/store-info-change.controller.ts index bc155a1..70c30ad 100644 --- a/server/dukang-api/src/modules/store/store-info-change.controller.ts +++ b/server/dukang-api/src/modules/store/store-info-change.controller.ts @@ -13,6 +13,10 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { StoreInfoChangeService } from './store-info-change.service'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; @Controller('partner/stores') @UseGuards(JwtAuthGuard) @@ -56,17 +60,19 @@ export class ShopStoreInfoChangeController { } @Controller('admin/store-info-change-requests') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('store_audits') export class AdminStoreInfoChangeController { constructor(private readonly svc: StoreInfoChangeService) {} @Get('summary') - summary() { - return this.svc.adminSummary(); + summary(@CurrentUser() user: AuthUser) { + return this.svc.adminSummary(user.actorId); } @Get() list( + @CurrentUser() user: AuthUser, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, @@ -75,12 +81,13 @@ export class AdminStoreInfoChangeController { status: (status as 'PENDING' | 'APPROVED' | 'REJECTED') || undefined, page: page ? Number(page) : undefined, pageSize: pageSize ? Number(pageSize) : undefined, + actorId: user.actorId, }); } @Get(':id') - detail(@Param('id') id: string) { - return this.svc.adminDetail(BigInt(id)); + detail(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.svc.adminDetail(BigInt(id), user.actorId); } @Put(':id/audit') diff --git a/server/dukang-api/src/modules/store/store-info-change.service.ts b/server/dukang-api/src/modules/store/store-info-change.service.ts index 85242fa..519f033 100644 --- a/server/dukang-api/src/modules/store/store-info-change.service.ts +++ b/server/dukang-api/src/modules/store/store-info-change.service.ts @@ -21,6 +21,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator' import { StoreService } from './store.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; +import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard'; type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number]; @@ -145,6 +146,7 @@ export class StoreInfoChangeService { private readonly storeService: StoreService, private readonly partnerCityService: PartnerCityService, private readonly wecomPush: WecomMessagePushService, + private readonly hqPermissions: HqPermissionsResolver, ) {} private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) { @@ -373,10 +375,16 @@ export class StoreInfoChangeService { status?: StoreInfoChangeStatus; page?: number; pageSize?: number; + actorId: bigint; }): Promise<{ items: StoreInfoChangeRequestDto[]; total: number; page: number; pageSize: number }> { const page = Math.max(1, opts.page || 1); const pageSize = Math.min(Math.max(opts.pageSize || 20, 1), 100); - const where = opts.status ? { status: opts.status } : {}; + const scope = await this.hqPermissions.resolveCityScope(opts.actorId); + const storeWhere = hqStoreCityWhere(scope); + const where = { + ...(opts.status ? { status: opts.status } : {}), + ...(storeWhere ? { store: storeWhere } : {}), + }; const [rows, total] = await Promise.all([ this.prisma.storeInfoChangeRequest.findMany({ where, @@ -395,20 +403,24 @@ export class StoreInfoChangeService { return { items, total, page, pageSize }; } - async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> { + async adminSummary(actorId: bigint): Promise<{ pendingCount: number; packagePendingCount: number }> { + const scope = await this.hqPermissions.resolveCityScope(actorId); + const storeWhere = hqStoreCityWhere(scope); + const cityFilter = storeWhere ? { store: storeWhere } : {}; const [infoPending, packagePending] = await Promise.all([ - this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }), - this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' } }), + this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING', ...cityFilter } }), + this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING', ...cityFilter } }), ]); return { pendingCount: infoPending, packagePendingCount: packagePending }; } - async adminDetail(id: bigint): Promise { + async adminDetail(id: bigint, actorId: bigint): Promise { const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id }, - include: { store: { select: { name: true } } }, + include: { store: { select: { name: true, cityId: true } } }, }); if (!row) throw new NotFoundException('变更请求不存在'); + await this.hqPermissions.assertStoreCityInScope(actorId, row.store.cityId); const dto = this.toDto( row as unknown as Record, (row as { store?: { name?: string } }).store?.name, @@ -430,8 +442,12 @@ export class StoreInfoChangeService { rejectReason?: string; reviewerId: bigint; }): Promise { - const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id: input.id } }); + const row = await this.prisma.storeInfoChangeRequest.findUnique({ + where: { id: input.id }, + include: { store: { select: { cityId: true } } }, + }); if (!row) throw new NotFoundException('变更请求不存在'); + await this.hqPermissions.assertStoreCityInScope(input.reviewerId, row.store.cityId); if (row.status !== 'PENDING') { throw new BadRequestException('该变更请求已处理'); } diff --git a/server/dukang-api/src/modules/store/store-package.controller.ts b/server/dukang-api/src/modules/store/store-package.controller.ts index 2cb62cd..0f5aac8 100644 --- a/server/dukang-api/src/modules/store/store-package.controller.ts +++ b/server/dukang-api/src/modules/store/store-package.controller.ts @@ -4,6 +4,10 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { ShopStoreGuard } from '../../common/guards/shop-store.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { StorePackageService } from './store-package.service'; +import { + HqPermissionGuard, + RequireHqPermissions, +} from '../../common/guards/hq-permission.guard'; @Controller('partner/stores') @UseGuards(JwtAuthGuard) @@ -49,48 +53,58 @@ export class ShopStorePackageController { } @Controller('admin/stores') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('stores') export class AdminStorePackageController { constructor(private readonly packages: StorePackageService) {} @Get(':storeId/packages') - getPackages(@Param('storeId') storeId: string) { - return this.packages.adminGetPackages(BigInt(storeId)); + getPackages(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) { + return this.packages.adminGetPackages(BigInt(storeId), user.actorId); } @Put(':storeId/packages') - savePackages(@Param('storeId') storeId: string, @Body() body: { packages: unknown }) { + savePackages( + @CurrentUser() user: AuthUser, + @Param('storeId') storeId: string, + @Body() body: { packages: unknown }, + ) { const normalized = this.packages.normalizePackages(body.packages); - return this.packages.adminDirectSave(BigInt(storeId), normalized); + return this.packages.adminDirectSave(BigInt(storeId), normalized, user.actorId); } } @Controller('admin/store-package-audits') -@UseGuards(HqAuthGuard) +@UseGuards(HqAuthGuard, HqPermissionGuard) +@RequireHqPermissions('store_audits') export class AdminStorePackageAuditController { constructor(private readonly packages: StorePackageService) {} @Get('summary') - summary() { - return this.packages.adminAuditSummary(); + summary(@CurrentUser() user: AuthUser) { + return this.packages.adminAuditSummary(user.actorId); } @Get() list( + @CurrentUser() user: AuthUser, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.packages.adminListAudits({ - status: status || undefined, - page: page ? Number(page) : undefined, - pageSize: pageSize ? Number(pageSize) : undefined, - }); + return this.packages.adminListAudits( + { + status: status || undefined, + page: page ? Number(page) : undefined, + pageSize: pageSize ? Number(pageSize) : undefined, + }, + user.actorId, + ); } @Get(':requestId') - detail(@Param('requestId') requestId: string) { - return this.packages.adminGetAuditDetail(BigInt(requestId)); + detail(@CurrentUser() user: AuthUser, @Param('requestId') requestId: string) { + return this.packages.adminGetAuditDetail(BigInt(requestId), user.actorId); } @Put(':requestId/audit') diff --git a/server/dukang-api/src/modules/store/store-package.service.ts b/server/dukang-api/src/modules/store/store-package.service.ts index 938bd93..e00a77a 100644 --- a/server/dukang-api/src/modules/store/store-package.service.ts +++ b/server/dukang-api/src/modules/store/store-package.service.ts @@ -15,6 +15,7 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { StoreService } from './store.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; +import { HqPermissionsResolver, hqStoreCityWhere } from '../../common/guards/hq-permission.guard'; type PackageInput = Record; @@ -26,6 +27,7 @@ export class StorePackageService { private readonly prisma: PrismaService, private readonly storeService: StoreService, private readonly wecomPush: WecomMessagePushService, + private readonly hqPermissions: HqPermissionsResolver, ) {} normalizePackages(raw: unknown): StorePackageItemDto[] { @@ -237,14 +239,16 @@ export class StorePackageService { ); } - async adminGetPackages(storeId: bigint) { + async adminGetPackages(storeId: bigint, actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, storeId); const store = await this.prisma.store.findUnique({ where: { id: storeId } }); if (!store) throw new NotFoundException('门店不存在'); const live = await this.listLivePackages(storeId); return serializeBigInt({ live }); } - async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[]) { + async adminDirectSave(storeId: bigint, packages: StorePackageItemDto[], actorId: bigint) { + await this.hqPermissions.assertStoreIdInScope(actorId, storeId); const store = await this.prisma.store.findUnique({ where: { id: storeId } }); if (!store) throw new NotFoundException('门店不存在'); await this.replaceLivePackages(storeId, packages); @@ -285,12 +289,13 @@ export class StorePackageService { ]); } - async adminGetAuditDetail(requestId: bigint) { + async adminGetAuditDetail(requestId: bigint, actorId: bigint) { const req = await this.prisma.storePackageChangeRequest.findUnique({ where: { id: requestId }, - include: { store: { select: { id: true, name: true } } }, + include: { store: { select: { id: true, name: true, cityId: true } } }, }); if (!req) throw new NotFoundException('审核记录不存在'); + await this.hqPermissions.assertStoreCityInScope(actorId, req.store.cityId); const livePackages = await this.listLivePackages(req.storeId); return serializeBigInt({ id: req.id.toString(), @@ -307,13 +312,19 @@ export class StorePackageService { }); } - async adminListAudits(query: { status?: string; page?: number; pageSize?: number }) { + async adminListAudits( + query: { status?: string; page?: number; pageSize?: number }, + actorId: bigint, + ) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; + const scope = await this.hqPermissions.resolveCityScope(actorId); + const storeWhere = hqStoreCityWhere(scope); const where: Prisma.StorePackageChangeRequestWhereInput = {}; if (query.status) { where.status = query.status as Prisma.EnumStorePackageChangeStatusFilter['equals']; } + if (storeWhere) where.store = storeWhere; const [items, total] = await Promise.all([ this.prisma.storePackageChangeRequest.findMany({ where, @@ -350,8 +361,10 @@ export class StorePackageService { ) { const req = await this.prisma.storePackageChangeRequest.findUnique({ where: { id: requestId }, + include: { store: { select: { cityId: true } } }, }); if (!req) throw new NotFoundException('审核记录不存在'); + await this.hqPermissions.assertStoreCityInScope(reviewerId, req.store.cityId); if (req.status !== 'PENDING') { throw new BadRequestException('该记录已处理'); } @@ -400,9 +413,11 @@ export class StorePackageService { return serializeBigInt({ id: requestId.toString(), status: 'APPROVED' }); } - async adminAuditSummary() { + async adminAuditSummary(actorId: bigint) { + const scope = await this.hqPermissions.resolveCityScope(actorId); + const storeWhere = hqStoreCityWhere(scope); const pendingCount = await this.prisma.storePackageChangeRequest.count({ - where: { status: 'PENDING' }, + where: { status: 'PENDING', ...(storeWhere ? { store: storeWhere } : {}) }, }); return { pendingCount }; }