From 9c8d5f2cad8fc44b15a2d3ad354f837748d56181 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 30 Aug 2026 14:35:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(assoc):=20v4.0.1=20=E5=90=88=E4=BC=99?= =?UTF-8?q?=E4=BA=BA=E5=85=B3=E8=81=94=E7=A0=81=E3=80=81=E5=88=86=E4=BD=A3?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E4=B8=8E=20H5=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor --- apps/admin-web/src/admin.css | 4 + .../src/components/CityPartnersPanel.tsx | 923 +++++++++--------- .../src/components/PartnerAssocPanel.tsx | 150 +++ .../src/components/ProxyOrderModal.tsx | 18 + .../RedeemRecordDetailDescriptions.tsx | 3 + apps/admin-web/src/lib/api.ts | 6 + apps/admin-web/src/lib/hq-log.ts | 1 + apps/admin-web/src/lib/omit-null-fields.ts | 9 + apps/admin-web/src/pages/CityPartnersPage.tsx | 36 +- .../admin-web/src/pages/HqPermissionsPage.tsx | 4 +- apps/admin-web/src/pages/OrdersPage.tsx | 66 ++ .../src/pages/PartnerAccountsPage.tsx | 8 + apps/admin-web/src/pages/PartnerBillsPage.tsx | 96 +- apps/admin-web/src/pages/PartnersPage.tsx | 9 +- apps/admin-web/src/pages/StoreRatingsPage.tsx | 7 + apps/admin-web/src/pages/UsersPage.tsx | 267 +++-- apps/h5-partner/src/PartnerAppRoutes.tsx | 9 + apps/h5-partner/src/layouts/TabLayout.tsx | 1 + apps/h5-partner/src/lib/proxyOrderDraft.ts | 2 + apps/h5-partner/src/pages/AssocOrdersPage.tsx | 68 ++ apps/h5-partner/src/pages/AssocQrcodePage.tsx | 96 ++ apps/h5-partner/src/pages/AssocUsersPage.tsx | 60 ++ apps/h5-partner/src/pages/BillsPage.tsx | 53 +- apps/h5-partner/src/pages/CenterPage.tsx | 18 + .../src/pages/CommissionOrdersPage.tsx | 67 ++ apps/h5-partner/src/pages/HomePage.tsx | 60 +- apps/h5-partner/src/pages/ProxyOrderPage.tsx | 18 + apps/h5-partner/src/pages/UsersManagePage.tsx | 267 +++++ apps/h5-partner/src/styles.css | 41 +- apps/mini-user/package.json | 2 +- apps/mini-user/src/app.config.ts | 3 +- apps/mini-user/src/app.css | 2 + apps/mini-user/src/app.tsx | 2 + apps/mini-user/src/app.weapp.tsx | 2 + .../src/assets/icons/store-benefit-y.png | Bin 0 -> 307 bytes apps/mini-user/src/assets/logo2.png | Bin 70897 -> 0 bytes .../src/components/BenefitFigure.tsx | 2 +- .../src/components/BenefitIntroCard.tsx | 53 + .../src/components/BenefitSloganBar.tsx | 15 + .../src/components/CategoryPicker.tsx | 2 +- apps/mini-user/src/components/JiuzuSplash.css | 266 +++++ apps/mini-user/src/components/JiuzuSplash.tsx | 140 +++ .../src/components/OrderQtyControls.tsx | 63 ++ .../src/components/StoreRedeemMarquee.tsx | 264 ++--- apps/mini-user/src/components/UserTabBar.tsx | 2 +- apps/mini-user/src/lib/benefit-copy.ts | 68 ++ apps/mini-user/src/lib/client-version.ts | 2 +- apps/mini-user/src/lib/jiuzu-dragon-canvas.ts | 316 ++++++ apps/mini-user/src/lib/jiuzu-splash.ts | 22 + apps/mini-user/src/lib/promo.ts | 75 +- apps/mini-user/src/lib/store-display.ts | 53 + apps/mini-user/src/lib/stores-session.ts | 9 +- apps/mini-user/src/lib/upload-rating-image.ts | 59 ++ .../src/pages/benefit-rules/index.config.ts | 4 + .../src/pages/benefit-rules/index.tsx | 83 ++ apps/mini-user/src/pages/benefit/index.tsx | 104 +- apps/mini-user/src/pages/home/index.tsx | 41 +- apps/mini-user/src/pages/mine/index.tsx | 11 +- .../src/pages/order-confirm-pickup/index.tsx | 21 +- .../src/pages/order-confirm/index.tsx | 24 +- .../src/pages/order-detail/index.tsx | 2 +- apps/mini-user/src/pages/orders/index.tsx | 2 +- apps/mini-user/src/pages/pay/index.tsx | 4 +- .../src/pages/pickup-receive/index.tsx | 2 +- .../src/pages/product-detail/index.tsx | 24 +- .../src/pages/redeem-success/index.config.ts | 2 +- .../src/pages/redeem-success/index.tsx | 373 +++++-- apps/mini-user/src/pages/redeem/index.tsx | 12 +- .../src/pages/store-detail/index.tsx | 129 ++- apps/mini-user/src/pages/stores/index.tsx | 198 ++-- apps/mini-user/src/styles/benefit-promo.css | 88 ++ apps/mini-user/src/styles/benefit.css | 86 +- apps/mini-user/src/styles/home.css | 89 ++ apps/mini-user/src/styles/legal.css | 78 ++ apps/mini-user/src/styles/mine.css | 14 +- apps/mini-user/src/styles/order.css | 21 + apps/mini-user/src/styles/product-detail.css | 4 + apps/mini-user/src/styles/redeem.css | 244 ++++- apps/mini-user/src/styles/store-detail.css | 119 ++- apps/mini-user/src/styles/stores.css | 90 +- docs/杜康好客-v3-PRD.md | 5 +- docs/杜康好客-v3编码手册.md | 3 + docs/杜康好客-v4-PRD.md | 68 ++ docs/杜康好客-v4-现状对照.md | 24 + docs/杜康好客-v4.0.1-开发文档.md | 62 ++ packages/domain/src/city-partner.test.ts | 40 - packages/domain/src/city-partner.ts | 61 -- packages/shared-types/src/config.ts | 6 + packages/shared-types/src/enums.ts | 2 + packages/shared-types/src/hq-permissions.ts | 2 + packages/shared-types/src/index.ts | 1 + packages/shared-types/src/partner-assoc.ts | 86 ++ packages/shared-types/src/redeem.ts | 23 + packages/shared-types/src/settlement.ts | 17 + packages/shared-types/src/trade.ts | 12 + scripts/dev-mini-user-h5.mjs | 3 +- server/dukang-api/prisma/init_v3.sql | 50 +- .../prisma/migrate-partner-assoc-v401.sql | 34 + .../prisma/migrate-partner-user-note-v401.sql | 16 + .../migrate-store-rating-content-v3512.sql | 5 + server/dukang-api/prisma/schema.prisma | 59 ++ .../scripts/upload-mini-user-static.mjs | 112 +++ .../hq-operation/hq-operation.constants.ts | 2 + .../interceptors/response.interceptor.ts | 16 +- .../system-config/system-config.registry.ts | 2 +- .../city-scope/partner-city.service.ts | 64 +- .../src/modules/ops/admin-orders.service.ts | 55 +- .../modules/ops/admin-partners.controller.ts | 42 +- .../src/modules/ops/admin-partners.service.ts | 88 +- .../ops/admin-store-ratings.service.ts | 3 + .../src/modules/ops/admin-users.controller.ts | 20 +- .../src/modules/ops/admin-users.service.ts | 71 +- .../src/modules/ops/dto/admin-mutate.dto.ts | 24 +- .../src/modules/ops/dto/admin-query.dto.ts | 19 + .../src/modules/ops/dto/hq-proxy-order.dto.ts | 4 + .../modules/redeem/dto/submit-rating.dto.ts | 52 + .../src/modules/redeem/redeem.controller.ts | 10 +- .../src/modules/redeem/redeem.service.ts | 84 +- .../modules/settlement/settlement.service.ts | 200 +++- .../modules/store/partner-assoc.controller.ts | 122 +++ .../modules/store/partner-assoc.service.ts | 419 ++++++++ .../src/modules/store/store.module.ts | 13 +- .../src/modules/store/store.service.ts | 43 +- .../trade/dto/partner-proxy-order.dto.ts | 4 + .../src/modules/trade/trade.service.ts | 85 +- 125 files changed, 6355 insertions(+), 1436 deletions(-) create mode 100644 apps/admin-web/src/components/PartnerAssocPanel.tsx create mode 100644 apps/admin-web/src/lib/omit-null-fields.ts create mode 100644 apps/h5-partner/src/pages/AssocOrdersPage.tsx create mode 100644 apps/h5-partner/src/pages/AssocQrcodePage.tsx create mode 100644 apps/h5-partner/src/pages/AssocUsersPage.tsx create mode 100644 apps/h5-partner/src/pages/CommissionOrdersPage.tsx create mode 100644 apps/h5-partner/src/pages/UsersManagePage.tsx create mode 100644 apps/mini-user/src/assets/icons/store-benefit-y.png delete mode 100644 apps/mini-user/src/assets/logo2.png create mode 100644 apps/mini-user/src/components/BenefitIntroCard.tsx create mode 100644 apps/mini-user/src/components/BenefitSloganBar.tsx create mode 100644 apps/mini-user/src/components/JiuzuSplash.css create mode 100644 apps/mini-user/src/components/JiuzuSplash.tsx create mode 100644 apps/mini-user/src/components/OrderQtyControls.tsx create mode 100644 apps/mini-user/src/lib/benefit-copy.ts create mode 100644 apps/mini-user/src/lib/jiuzu-dragon-canvas.ts create mode 100644 apps/mini-user/src/lib/jiuzu-splash.ts create mode 100644 apps/mini-user/src/lib/store-display.ts create mode 100644 apps/mini-user/src/lib/upload-rating-image.ts create mode 100644 apps/mini-user/src/pages/benefit-rules/index.config.ts create mode 100644 apps/mini-user/src/pages/benefit-rules/index.tsx create mode 100644 apps/mini-user/src/styles/benefit-promo.css create mode 100644 docs/杜康好客-v4-PRD.md create mode 100644 docs/杜康好客-v4-现状对照.md create mode 100644 docs/杜康好客-v4.0.1-开发文档.md create mode 100644 packages/shared-types/src/partner-assoc.ts create mode 100644 server/dukang-api/prisma/migrate-partner-assoc-v401.sql create mode 100644 server/dukang-api/prisma/migrate-partner-user-note-v401.sql create mode 100644 server/dukang-api/prisma/migrate-store-rating-content-v3512.sql create mode 100644 server/dukang-api/scripts/upload-mini-user-static.mjs create mode 100644 server/dukang-api/src/modules/redeem/dto/submit-rating.dto.ts create mode 100644 server/dukang-api/src/modules/store/partner-assoc.controller.ts create mode 100644 server/dukang-api/src/modules/store/partner-assoc.service.ts diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index f34531f..bcd21d8 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -105,6 +105,10 @@ body.admin-col-resizing * { width: 180px; } +.admin-users-filter-item--keyword { + width: 240px; +} + .admin-orders-filter-item--status { width: 220px; min-width: 180px; diff --git a/apps/admin-web/src/components/CityPartnersPanel.tsx b/apps/admin-web/src/components/CityPartnersPanel.tsx index 0ad3ba8..0635506 100644 --- a/apps/admin-web/src/components/CityPartnersPanel.tsx +++ b/apps/admin-web/src/components/CityPartnersPanel.tsx @@ -1,458 +1,465 @@ -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); - } - }}> -
- - - - - - - - - - - - - - - - -
- - ); -} +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 { omitNullFields } from '../lib/omit-null-fields'; +import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; +import { fmtTime } from '../lib/constants'; +import CityDistrictMultiSelect from './CityDistrictMultiSelect'; +import PartnerSubAccountList from './PartnerSubAccountList'; +import PartnerAssocPanel from './PartnerAssocPanel'; + +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({ + ...omitNullFields(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)} + /> + ), + }, + { + key: 'assoc', + label: '关联码', + children: , + }, + ]} + /> + )} + + + 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/PartnerAssocPanel.tsx b/apps/admin-web/src/components/PartnerAssocPanel.tsx new file mode 100644 index 0000000..7a6cb32 --- /dev/null +++ b/apps/admin-web/src/components/PartnerAssocPanel.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Button, Popconfirm, Space, Table, Typography, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types'; +import { request, type HqProfile, type Paginated } from '../lib/api'; +import { fmtTime } from '../lib/constants'; + +function formatNicknameWithRemark(row: PartnerAssocUserItem) { + const name = row.nickname?.trim() || '—'; + const remark = row.hqRemark?.trim(); + return remark ? `${name}(${remark})` : name; +} + +type PartnerAssocPanelProps = { + partnerId: string; +}; + +export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) { + const [summary, setSummary] = useState(null); + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [issuing, setIssuing] = useState(false); + const [canEditAssoc, setCanEditAssoc] = useState(false); + + const loadSummary = useCallback(async () => { + const data = await request(`/admin/partners/${partnerId}/assoc`); + setSummary(data); + }, [partnerId]); + + const loadUsers = useCallback(async (p = 1) => { + setLoading(true); + try { + const res = await request>( + `/admin/partners/${partnerId}/assoc/users?page=${p}&pageSize=20`, + ); + setUsers(res.items ?? []); + setTotal(res.total ?? 0); + setPage(p); + } finally { + setLoading(false); + } + }, [partnerId]); + + useEffect(() => { + void request('/admin/auth/me') + .then((p) => setCanEditAssoc((p.permissionKeys ?? []).includes('users_partner_assoc'))) + .catch(() => setCanEditAssoc(false)); + }, []); + + useEffect(() => { + void loadSummary().catch((e) => message.error(e instanceof Error ? e.message : '加载关联码失败')); + void loadUsers(1).catch(() => undefined); + }, [loadSummary, loadUsers]); + + async function reissue() { + setIssuing(true); + try { + await request(`/admin/partners/${partnerId}/assoc/qrcode`, { method: 'POST' }); + message.success('已重新生成关联码'); + await loadSummary(); + } catch (e) { + message.error(e instanceof Error ? e.message : '生成失败'); + } finally { + setIssuing(false); + } + } + + async function unbind(userId: string) { + try { + await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' }); + message.success('已解绑'); + await Promise.all([loadSummary(), loadUsers(page)]); + } catch (e) { + message.error(e instanceof Error ? e.message : '解绑失败'); + } + } + + const columns: ColumnsType = [ + { title: '用户编号', dataIndex: 'userNo', width: 140 }, + { + title: '用户', + dataIndex: 'nickname', + render: (_, row) => ( + + {formatNicknameWithRemark(row)} + + ), + }, + { title: '手机', dataIndex: 'phone', render: (v) => v || '—' }, + { title: '关联时间', dataIndex: 'boundAt', render: (v) => (v ? fmtTime(v) : '—') }, + { title: '已付订单', dataIndex: 'orderCount', width: 90 }, + ...(canEditAssoc + ? [ + { + title: '操作', + width: 90, + render: (_: unknown, row: PartnerAssocUserItem) => ( + void unbind(row.id)}> + + + ), + } satisfies ColumnsType[number], + ] + : []), + ]; + + return ( +
+ + {summary?.qrcodeUrl ? ( + 关联码 + ) : ( + 尚未生成关联码 + )} +
+ + + 关联用户 {summary?.userCount ?? 0} 人 + + + +
+
+
void loadUsers(p), + }} + /> + + ); +} diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx index e7b26ba..3f98516 100644 --- a/apps/admin-web/src/components/ProxyOrderModal.tsx +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -54,6 +54,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder const [skuId, setSkuId] = useState(); const [quantity, setQuantity] = useState(2); const [promoCodeId, setPromoCodeId] = useState(); + const [assocPartnerAccountId, setAssocPartnerAccountId] = useState(); const [deliveryMode, setDeliveryMode] = useState('ADDRESS'); const [autoReceive, setAutoReceive] = useState(false); const [preview, setPreview] = useState(null); @@ -168,6 +169,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder setProductId(undefined); setSkuId(undefined); setPromoCodeId(undefined); + setAssocPartnerAccountId(undefined); setDeliveryMode('ADDRESS'); setAutoReceive(false); setPreview(null); @@ -235,6 +237,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder quantity, promoCodeId: promoCodeId || undefined, skuId: skuId || undefined, + assocPartnerAccountId: assocPartnerAccountId || undefined, }; setSubmitting(true); @@ -388,6 +391,21 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder /> + + 服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '} {(detail.rating as { envScore?: number }).envScore ?? '—'} 分 + {(detail.rating as { comment?: string | null }).comment + ? ` · ${(detail.rating as { comment?: string }).comment}` + : ''} ) : null} diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 9cafb9e..658f69a 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -183,6 +183,12 @@ export type AdminUserRow = { benefitUsedAmount?: number; /** 好客权益·剩余未使用 */ benefitBalance?: number; + assocPartner?: { + id: string; + name: string; + companyName?: string | null; + phone?: string | null; + } | null; }; export type AdminOrderItem = { diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index 198485c..d61b340 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -16,6 +16,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, + { value: 'USER_ASSOC_UPDATE', label: '修改用户关联合伙人' }, { value: 'USER_DELETE', label: '删除用户' }, { value: 'USER_BATCH_DELETE', label: '批量删除用户' }, { value: 'ORDER_SHIP', label: '订单发货' }, diff --git a/apps/admin-web/src/lib/omit-null-fields.ts b/apps/admin-web/src/lib/omit-null-fields.ts new file mode 100644 index 0000000..c375bf6 --- /dev/null +++ b/apps/admin-web/src/lib/omit-null-fields.ts @@ -0,0 +1,9 @@ +/** 表单选填项常为 null,PUT 时去掉以免后端对 null 调 trim */ +export function omitNullFields>(input: T): Partial { + const out: Partial = {}; + for (const [key, value] of Object.entries(input)) { + if (value === null || value === undefined) continue; + (out as Record)[key] = value; + } + return out; +} diff --git a/apps/admin-web/src/pages/CityPartnersPage.tsx b/apps/admin-web/src/pages/CityPartnersPage.tsx index d97ea96..2547f41 100644 --- a/apps/admin-web/src/pages/CityPartnersPage.tsx +++ b/apps/admin-web/src/pages/CityPartnersPage.tsx @@ -31,11 +31,13 @@ import { type PartnerPermissionKey, } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; +import { omitNullFields } from '../lib/omit-null-fields'; 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 PartnerAssocPanel from '../components/PartnerAssocPanel'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; @@ -60,6 +62,7 @@ type Row = { managedWarehouseName?: string | null; maxPartnerCommissionRate?: number | null; storeCount: number; + assocUserCount?: number; accountCount: number; subAccounts?: SubRow[]; createdAt: string; @@ -209,7 +212,7 @@ export default function CityPartnersPage() { await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify({ - ...v, + ...omitNullFields(v), orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, @@ -332,6 +335,19 @@ export default function CityPartnersPage() { ), }, + { + title: '关联用户', + dataIndex: 'assocUserCount', + width: 80, + 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 }, { @@ -355,7 +371,9 @@ export default function CityPartnersPage() { title="城市合伙人" settings={settingsButton} actions={ - + } /> @@ -486,6 +505,14 @@ export default function CityPartnersPage() { {detail.storeCount ?? 0} + + + {detail.assocUserCount ?? 0} + + {detail.managedWarehouseName ?? ( @@ -570,6 +597,11 @@ export default function CityPartnersPage() { /> ), }, + { + key: 'assoc', + label: '关联码', + children: , + }, ]} /> diff --git a/apps/admin-web/src/pages/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx index aed30aa..4b03d93 100644 --- a/apps/admin-web/src/pages/HqPermissionsPage.tsx +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -244,7 +244,7 @@ export default function HqPermissionsPage() { 权限分配 按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。 - 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。 + 超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。 运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。 「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。 @@ -274,7 +274,7 @@ export default function HqPermissionsPage() { ) : ( <> diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 7fd052c..61009a9 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -57,6 +57,7 @@ type ShipDefaults = { }; type CityOption = { id: string; name: string; code: string }; +type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string }; type WarehouseOption = { id: string; @@ -93,6 +94,20 @@ type OrderRedeemSummary = { redeemRecordSum: number; }; +type AssocPartnerBrief = { + id: string; + name: string; + companyName?: string | null; + phone?: string | null; + orderCommissionRate?: number | null; +}; + +function formatAssocPartner(p?: AssocPartnerBrief | null) { + if (!p) return '—'; + const title = p.companyName || p.name; + return [title, p.phone].filter(Boolean).join(' / ') || '—'; +} + type OrderDetail = AdminOrderRow & { receiverAddress?: string; receiverProvince?: string; @@ -136,6 +151,11 @@ type OrderDetail = AdminOrderRow & { lng?: number | null; lat?: number | null; } | null; + assocPartnerAtPay?: AssocPartnerBrief | null; + orderCommissionRateAtPay?: number | null; + user?: AdminOrderRow['user'] & { + assocPartner?: AssocPartnerBrief | null; + }; }; type ShipMode = 'WAREHOUSE' | 'EXPRESS'; @@ -154,6 +174,7 @@ type OrderExportFilters = { fulfillmentHold?: boolean; deliveryType?: string; promoCodeId?: string; + assocPartnerAccountId?: string; dateRange?: [Dayjs, Dayjs]; }; @@ -261,6 +282,7 @@ function buildExportPayload( if (filters.deliveryType) payload.deliveryType = filters.deliveryType; if (filters.fulfillmentHold) payload.fulfillmentHold = true; if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId; + if (filters.assocPartnerAccountId) payload.assocPartnerAccountId = filters.assocPartnerAccountId; if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD'); if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD'); return payload; @@ -330,6 +352,7 @@ export default function OrdersPage() { const [shipping, setShipping] = useState(false); const [logisticsShipping, setLogisticsShipping] = useState(false); const [cities, setCities] = useState([]); + const [partners, setPartners] = useState([]); const [shipModalOpen, setShipModalOpen] = useState(false); const [shipTarget, setShipTarget] = useState(null); const [shipMode, setShipMode] = useState('EXPRESS'); @@ -418,6 +441,7 @@ export default function OrdersPage() { if (values.productKeyword) qs.set('productKeyword', values.productKeyword); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.deliveryType) qs.set('deliveryType', values.deliveryType); + if (values.assocPartnerAccountId) qs.set('assocPartnerAccountId', values.assocPartnerAccountId); if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId); if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD')); if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD')); @@ -437,6 +461,9 @@ export default function OrdersPage() { void request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) .then((res) => setCities(res.items ?? [])) .catch(() => {}); + void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setPartners(res.items ?? [])) + .catch(() => setPartners([])); }, []); function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) { @@ -888,6 +915,30 @@ export default function OrdersPage() { ({ + value: p.id, + label: formatAssocPartner({ + id: p.id, + name: p.name || p.id, + companyName: p.companyName, + phone: p.phone, + }), + })), + ]} + /> + @@ -1082,6 +1133,21 @@ export default function OrdersPage() { {[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'} ) : null} + + {detail.assocPartnerAtPay + ? `${formatAssocPartner(detail.assocPartnerAtPay)}${ + detail.assocPartnerAtPay.orderCommissionRate != null + ? ` · 费率 ${(Number(detail.assocPartnerAtPay.orderCommissionRate) * 100).toFixed(2)}%` + : '' + }` + : '—'} + + {detail.user?.assocPartner && + detail.user.assocPartner.id !== detail.assocPartnerAtPay?.id ? ( + + {formatAssocPartner(detail.user.assocPartner)} + + ) : null} {detail.user?.id ? ( ), }, + ...(!detail.parentAccountId + ? [{ + key: 'assoc', + label: '关联码', + children: , + }] + : []), ]} /> )} diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index 00dc492..7c4464e 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -12,6 +12,7 @@ import { Space, Statistic, Table, + Tabs, Tag, Typography, message, @@ -27,6 +28,19 @@ import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; +type BillItemRow = { + id: string; + kind: 'ORDER' | 'REDEEM'; + refId: string; + refNo: string; + title?: string | null; + extra?: string | null; + baseAmount: number; + rate: number; + commission: number; + occurredAt: string; +}; + type Row = { id: string; billNo: string; @@ -39,6 +53,8 @@ type Row = { rejectReason?: string | null; paymentRef?: string | null; paidAt?: string | null; + orderItems?: BillItemRow[]; + redeemItems?: BillItemRow[]; partner?: { companyName?: string; phone?: string; @@ -531,7 +547,7 @@ export default function PartnerBillsPage() { title="合伙人账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} - width={560} + width={720} > {detail && ( <> @@ -562,6 +578,84 @@ export default function PartnerBillsPage() { {detail.paymentRef ? String(detail.paymentRef) : '—'} + +
`¥${Number(v).toFixed(2)}`, + }, + { + title: '费率', + dataIndex: 'rate', + render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`, + }, + { + title: '佣金', + dataIndex: 'commission', + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + ]} + /> + + 酒单佣金 ¥{Number(detail.orderCommission).toFixed(2)} + + + ), + }, + { + key: 'redeems', + label: `核销订单 (${detail.redeemItems?.length ?? 0})`, + children: ( + <> +
`¥${Number(v).toFixed(2)}`, + }, + { + title: '费率', + dataIndex: 'rate', + render: (v: number) => `${(Number(v) * 100).toFixed(2)}%`, + }, + { + title: '佣金', + dataIndex: 'commission', + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + ]} + /> + + 核销佣金 ¥{Number(detail.redeemCommission).toFixed(2)} + + + ), + }, + ]} + /> 收款账户 diff --git a/apps/admin-web/src/pages/PartnersPage.tsx b/apps/admin-web/src/pages/PartnersPage.tsx index 8a833a3..eb7f9b4 100644 --- a/apps/admin-web/src/pages/PartnersPage.tsx +++ b/apps/admin-web/src/pages/PartnersPage.tsx @@ -23,6 +23,7 @@ import { type PartnerPermissionKey, } from '@dukang/shared-types'; import { request, type Paginated } from '../lib/api'; +import { omitNullFields } from '../lib/omit-null-fields'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; @@ -30,6 +31,7 @@ import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; +import PartnerAssocPanel from '../components/PartnerAssocPanel'; type Row = { @@ -162,7 +164,7 @@ export default function PartnersPage() { try { const v = await editForm.validateFields(); const body = { - ...v, + ...omitNullFields(v), orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, @@ -320,6 +322,11 @@ export default function PartnersPage() { ), }, + { + key: 'assoc', + label: '关联码', + children: , + }, ]} /> )} diff --git a/apps/admin-web/src/pages/StoreRatingsPage.tsx b/apps/admin-web/src/pages/StoreRatingsPage.tsx index add4351..8c7e5af 100644 --- a/apps/admin-web/src/pages/StoreRatingsPage.tsx +++ b/apps/admin-web/src/pages/StoreRatingsPage.tsx @@ -13,6 +13,7 @@ type Row = { id: string; serviceScore: number; envScore: number; + comment?: string | null; createdAt: string; redeemNo: string; redeemAmount: number; @@ -62,6 +63,12 @@ export default function StoreRatingsPage() { { title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` }, { title: '服务分', dataIndex: 'serviceScore', width: 80 }, { title: '环境分', dataIndex: 'envScore', width: 80 }, + { + title: '评语', + dataIndex: 'comment', + ellipsis: true, + render: (v: string | null) => v || '—', + }, { title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime }, ]; diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 8134796..bdc3af8 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, @@ -17,12 +17,13 @@ import { message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import { DownOutlined, UpOutlined } from '@ant-design/icons'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; -import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants'; import { clientAppLabel, eventNameLabel, userEventCategoryLabel } from '../lib/display-labels'; type UserOrderRow = { @@ -120,6 +121,24 @@ type UserBehaviorLog = { extraJson?: Record | null; }; +type AssocPartnerBrief = { + id: string; + name: string; + companyName?: string | null; + phone?: string | null; +}; + +type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string }; + +const FILTERS_COLLAPSED_KEY = 'admin-users-filters-collapsed'; +const ASSOC_UNBOUND = 'none'; + +function formatAssocPartner(p?: AssocPartnerBrief | PartnerOption | null) { + if (!p) return '—'; + const title = p.companyName || p.name || p.id; + return [title, p.phone].filter(Boolean).join(' / ') || '—'; +} + type UserDetail = AdminUserRow & { wxUnionId?: string | null; cityPref?: Record | null; @@ -128,6 +147,8 @@ type UserDetail = AdminUserRow & { orders?: UserOrderRow[]; mergedFromCount?: number; addressCount?: number; + assocPartner?: AssocPartnerBrief | null; + assocBoundAt?: string | null; }; type BatchDeletePreviewItem = { @@ -176,11 +197,36 @@ export default function UsersPage() { const [batchDeleting, setBatchDeleting] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false); const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete'); + const canEditAssoc = (profile?.permissionKeys ?? []).includes('users_partner_assoc'); + const [partners, setPartners] = useState([]); + const [assocPartnerId, setAssocPartnerId] = useState(); + const [savingAssoc, setSavingAssoc] = useState(false); + const [filtersCollapsed, setFiltersCollapsed] = useState(() => { + try { + return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1'; + } catch { + return false; + } + }); + + const partnerOptions = useMemo(() => { + const map = new Map(partners.map((p) => [p.id, p])); + if (detail?.assocPartner && !map.has(detail.assocPartner.id)) { + map.set(detail.assocPartner.id, detail.assocPartner); + } + return [...map.values()]; + }, [partners, detail?.assocPartner]); useEffect(() => { request('/admin/auth/me').then(setProfile).catch(() => {}); }, []); + useEffect(() => { + void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setPartners(res.items ?? [])) + .catch(() => setPartners([])); + }, []); + const load = useCallback(async () => { setLoading(true); try { @@ -190,24 +236,32 @@ export default function UsersPage() { if (userId) qs.set('userId', userId); if (values.phone) qs.set('phone', values.phone); if (values.userNo) qs.set('userNo', values.userNo); - if (values.deviceKey) qs.set('deviceKey', values.deviceKey); if (values.phoneVerified !== undefined && values.phoneVerified !== '') { qs.set('phoneVerified', values.phoneVerified); } if (values.status !== undefined && values.status !== '') { qs.set('status', String(values.status)); } - if (values.excludeTest) qs.set('excludeTest', 'true'); + const keyword = String(values.keyword || '').trim(); + if (keyword) qs.set('keyword', keyword); + const assocPartnerAccountId = String( + values.assocPartnerAccountId || searchParams.get('assocPartnerAccountId') || '', + ).trim(); + if (assocPartnerAccountId) qs.set('assocPartnerAccountId', assocPartnerAccountId); const res = await request>(`/admin/users?${qs}`); setData(res); } finally { setLoading(false); } - }, [form, page, pageSize]); + }, [form, page, pageSize, searchParams]); useEffect(() => { const userId = searchParams.get('userId'); - if (userId) form.setFieldsValue({ userId }); + const assocPartnerAccountId = searchParams.get('assocPartnerAccountId'); + form.setFieldsValue({ + ...(userId ? { userId } : {}), + ...(assocPartnerAccountId ? { assocPartnerAccountId } : {}), + }); }, [searchParams, form]); useEffect(() => { @@ -234,10 +288,29 @@ export default function UsersPage() { ), ]); setDetail(res); + setAssocPartnerId(res.assocPartner?.id); setBehaviorLogs(logs.items ?? []); setDrawerOpen(true); } + async function saveAssoc() { + if (!detail) return; + setSavingAssoc(true); + try { + await request(`/admin/users/${detail.id}/assoc`, { + method: 'PUT', + body: JSON.stringify({ partnerAccountId: assocPartnerId || null }), + }); + message.success(assocPartnerId ? '已更新关联合伙人' : '已解除关联'); + await openDetail(detail.id); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSavingAssoc(false); + } + } + function openDeleteModal() { setDeleteConfirm(''); setDeleteOpen(true); @@ -420,6 +493,12 @@ export default function UsersPage() { width: 120, render: (v) => v || '—', }, + { + title: '关联合伙人', + dataIndex: 'assocPartner', + width: 180, + render: (p: AdminUserRow['assocPartner']) => formatAssocPartner(p), + }, { title: 'deviceKey', dataIndex: 'deviceKey', @@ -512,64 +591,106 @@ export default function UsersPage() { />
{ setPage(1); + const next = new URLSearchParams(); const userId = String(form.getFieldValue('userId') || '').trim(); - const current = searchParams.get('userId') ?? ''; - if (userId !== current) { - if (userId) setSearchParams({ userId }, { replace: true }); - else setSearchParams({}, { replace: true }); + const assoc = String(form.getFieldValue('assocPartnerAccountId') || '').trim(); + if (userId) next.set('userId', userId); + if (assoc) next.set('assocPartnerAccountId', assoc); + if (next.toString() !== searchParams.toString()) { + setSearchParams(next, { replace: true }); } void load(); }} > - - - - - - - - - - - - - - - - - 过滤测试账号 - - - - - - - +
+ + + + + + + + + + + + + + + +
+ ) : null}
{detail.sourceLabel || '—'} + + {canEditAssoc ? ( + + setAssocEnabled(e.target.checked)} + /> + 关联合伙人(本单计订单佣金;取消则本单不计) + + + {(options?.promoCodes.length ?? 0) > 0 && (
diff --git a/apps/h5-partner/src/pages/UsersManagePage.tsx b/apps/h5-partner/src/pages/UsersManagePage.tsx new file mode 100644 index 0000000..2efe6de --- /dev/null +++ b/apps/h5-partner/src/pages/UsersManagePage.tsx @@ -0,0 +1,267 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; +import type { + PartnerAssocSummary, + PartnerAssocUserItem, + PartnerAssocUserSort, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { toastError, toastSuccess } from '../lib/toast'; +import { isWechatEnv } from '../lib/weixin'; +import { usePartnerPageView } from '../lib/usePageView'; + +type ListRes = { items: PartnerAssocUserItem[]; total: number }; + +const SORTS: { key: PartnerAssocUserSort; label: string }[] = [ + { key: 'boundAt', label: '关联时间' }, + { key: 'createdAt', label: '注册时间' }, + { key: 'orderCount', label: '订单数' }, +]; + +function formatUserLabel(u: PartnerAssocUserItem) { + const name = u.nickname?.trim() || u.userNo || '—'; + const remark = u.partnerRemark?.trim(); + return remark ? `${name}(${remark})` : name; +} + +function fmtTime(iso?: string | null) { + if (!iso) return '—'; + return iso.slice(0, 16).replace('T', ' '); +} + +export default function UsersManagePage() { + usePartnerPageView('partner_users_manage_view'); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [summary, setSummary] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [keyword, setKeyword] = useState(''); + const [appliedKeyword, setAppliedKeyword] = useState(''); + const [sort, setSort] = useState('boundAt'); + const [editing, setEditing] = useState(null); + const [remarkDraft, setRemarkDraft] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + document.title = '用户管理'; + }, []); + + useEffect(() => () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }, [previewUrl]); + + const loadSummary = useCallback(async () => { + const data = await request('PARTNER_H5', '/partner/assoc'); + setSummary(data); + }, []); + + const loadUsers = useCallback(async () => { + const qs = new URLSearchParams({ page: '1', pageSize: '50', sort }); + const q = appliedKeyword.trim(); + if (q) qs.set('keyword', q); + const res = await request('PARTNER_H5', `/partner/assoc/users?${qs}`); + setItems(res.items ?? []); + setTotal(res.total ?? 0); + }, [appliedKeyword, sort]); + + const load = useCallback(async () => { + setLoading(true); + try { + await Promise.all([loadSummary(), loadUsers()]); + } catch (e) { + toastError(e instanceof Error ? e.message : '加载失败'); + } finally { + setLoading(false); + } + }, [loadSummary, loadUsers]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + const t = window.setTimeout(() => setAppliedKeyword(keyword.trim()), 400); + return () => window.clearTimeout(t); + }, [keyword]); + + useEffect(() => { + const uid = searchParams.get('userId'); + if (uid) navigate(`/users/${uid}/orders`, { replace: true }); + }, [searchParams, navigate]); + + async function downloadQr() { + const token = localStorage.getItem('accessToken'); + try { + const res = await fetch('/api/v1/partner/assoc/qrcode', { + headers: { + Authorization: token ? `Bearer ${token}` : '', + 'X-Client-App': 'PARTNER_H5', + }, + }); + if (!res.ok) throw new Error('下载失败'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + if (isWechatEnv()) { + setPreviewUrl(url); + toastSuccess('请长按图片保存到相册'); + return; + } + const a = document.createElement('a'); + a.href = url; + a.download = `partner-assoc-${summary?.partnerId || 'qr'}.png`; + a.click(); + URL.revokeObjectURL(url); + toastSuccess('已开始下载'); + } catch (e) { + if (summary?.qrcodeUrl) { + setPreviewUrl(summary.qrcodeUrl); + toastSuccess('请长按图片保存到相册'); + return; + } + toastError(e instanceof Error ? e.message : '下载失败'); + } + } + + async function saveRemark() { + if (!editing) return; + setSaving(true); + try { + await request(`PARTNER_H5`, `/partner/assoc/users/${editing.id}/remark`, { + method: 'PUT', + body: JSON.stringify({ remark: remarkDraft }), + }); + toastSuccess(remarkDraft.trim() ? '备注已保存' : '已清除备注'); + setEditing(null); + await loadUsers(); + } catch (e) { + toastError(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + return ( + +
+

用户管理

+ +
+

+ 用户扫码后首次锁定,后续购酒计入关联订单 +

+ {summary?.qrcodeUrl ? ( + 关联码 + ) : ( +

{loading ? '加载中…' : '关联码尚未生成'}

+ )} +

+ 已关联 {summary?.userCount ?? total} 人 +

+ + {previewUrl && isWechatEnv() && ( +

微信内请长按上方图片保存

+ )} +
+ +

已关联用户

+
+ search + setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void loadUsers(); + }} + /> +
+
+ {SORTS.map((s) => ( + + ))} +
+

+ {loading ? '加载中…' : `共 ${total} 人`} +

+ {!loading && items.length === 0 &&
暂无关联用户
} + {items.map((u) => ( +
+
+
+

{formatUserLabel(u)}

+

{u.phone || '未绑定手机'}

+

注册 {fmtTime(u.createdAt)}

+
+ +
+ +
+ ))} +
+ + {editing && ( +
setEditing(null)}> +
e.stopPropagation()}> +

用户备注

+

仅自己可见,总部看不到

+