From bde85a7b828e9e34488281113d8ddb0d8c090d8b Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 16 Jul 2026 21:40:25 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E4=BB=93=E5=BA=93=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20=E5=B0=8F=E9=A3=9E=E4=BE=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/FulfillmentProvidersPage.tsx | 138 ++++++++++++---- .../src/pages/XiaofeixiaTestPage.tsx | 14 +- .../shared-types/src/fulfillment-provider.ts | 29 ++++ server/dukang-api/prisma/seed-v31.ts | 6 + .../system-config/system-config.registry.ts | 15 -- .../integrations/courier/courier.service.ts | 29 ++-- .../src/integrations/courier/courier.types.ts | 20 ++- .../courier/xiaofeixia/xiaofeixia.client.ts | 10 +- .../courier/xiaofeixia/xiaofeixia.provider.ts | 111 ++++++++----- .../fulfillment-provider.service.ts | 152 +++++++++++++++++- .../fulfillment/fulfillment.service.ts | 97 ++++++----- .../admin-fulfillment-providers.controller.ts | 7 + .../src/modules/ops/admin-orders.service.ts | 81 +++++++--- .../modules/ops/admin-xiaofeixia.service.ts | 48 ++++-- .../src/modules/ops/dto/admin-mutate.dto.ts | 20 +++ 15 files changed, 580 insertions(+), 197 deletions(-) diff --git a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx index 6533f96..588196c 100644 --- a/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx +++ b/apps/admin-web/src/pages/FulfillmentProvidersPage.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { + Alert, Button, Form, Input, @@ -17,6 +18,7 @@ import { FULFILLMENT_PROVIDER_TYPE_LABELS, FulfillmentProviderStatus, FulfillmentProviderType, + isXfxProviderCode, type FulfillmentProviderDto, } from '@dukang/shared-types'; import { request } from '../lib/api'; @@ -30,6 +32,12 @@ const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([ value, label, })); +const SIGN_OPTIONS = [ + { value: 'MD5', label: 'MD5' }, + { value: 'HMAC-SHA256', label: 'HMAC-SHA256' }, +]; + +const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do'; export default function FulfillmentProvidersPage() { const [rows, setRows] = useState([]); @@ -37,6 +45,13 @@ export default function FulfillmentProvidersPage() { const [open, setOpen] = useState(false); const [editRow, setEditRow] = useState(null); const [form] = Form.useForm(); + const watchedCode = Form.useWatch('code', form); + const watchedType = Form.useWatch('type', form); + + const showXfxFields = useMemo( + () => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API, + [watchedCode, watchedType], + ); const load = useCallback(async () => { setLoading(true); @@ -56,48 +71,64 @@ export default function FulfillmentProvidersPage() { setEditRow(null); form.resetFields(); form.setFieldsValue({ + code: 'XFX', + name: '小飞侠', type: FulfillmentProviderType.API, status: FulfillmentProviderStatus.ACTIVE, - capabilitiesJson: JSON.stringify( - { createShipment: true, getTrack: true, callback: true }, - null, - 2, - ), + apiUrl: DEFAULT_XFX_API_URL, + signType: 'MD5', }); setOpen(true); } function openEdit(row: FulfillmentProviderDto) { setEditRow(row); + const xfx = row.xiaofeixiaConfig; form.setFieldsValue({ code: row.code, name: row.name, type: row.type, status: row.status, - capabilitiesJson: row.capabilities ? JSON.stringify(row.capabilities, null, 2) : '', - configJson: '', + apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL, + mchId: xfx?.mchId || '', + apiKey: '', + signType: xfx?.signType || 'MD5', + appId: xfx?.appId || '', }); setOpen(true); } async function submit() { const v = await form.validateFields(); + const payload: Record = { + name: v.name, + type: v.type, + status: v.status, + }; + + if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) { + payload.xiaofeixiaConfig = { + apiUrl: v.apiUrl, + mchId: v.mchId, + apiKey: v.apiKey || undefined, + signType: v.signType, + appId: v.appId || undefined, + }; + } + if (editRow) { await request(`/admin/fulfillment-providers/${editRow.id}`, { method: 'PUT', - body: JSON.stringify({ - name: v.name, - type: v.type, - status: v.status, - configJson: v.configJson || undefined, - capabilitiesJson: v.capabilitiesJson || undefined, - }), + body: JSON.stringify(payload), }); message.success('已更新'); } else { await request('/admin/fulfillment-providers', { method: 'POST', - body: JSON.stringify(v), + body: JSON.stringify({ + code: v.code, + ...payload, + }), }); message.success('已创建'); } @@ -123,14 +154,18 @@ export default function FulfillmentProvidersPage() { ), }, { - title: '能力', + title: '接口配置', render: (_, row) => { - const caps = row.capabilities; - if (!caps) return '—'; - return Object.entries(caps) - .filter(([, on]) => on) - .map(([k]) => k) - .join('、') || '—'; + if (isXfxProviderCode(row.code)) { + const cfg = row.xiaofeixiaConfig; + if (!cfg?.apiUrl) return 未配置; + return ( + + {cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'} + + ); + } + return row.hasConfig ? '已配置' : '—'; }, }, { title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime }, @@ -153,7 +188,7 @@ export default function FulfillmentProvidersPage() { 仓配管理 - 注册第三方履约接口后,仓库设置中方可选择对应承运商 + 注册第三方履约接口并填写凭证;仓库绑定后,推单将使用此处配置的 API 地址与密钥 + + setOpen(false)} onOk={() => void submit()} width={560} + destroyOnClose >
@@ -183,12 +226,47 @@ export default function FulfillmentProvidersPage() { + + + + + + + + + + + + )}
diff --git a/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx index 7da077e..de92dd3 100644 --- a/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx +++ b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space, Tabs, Tag, Typography, message, @@ -15,6 +16,8 @@ type XfxConfig = { hasApiKey: boolean; signType: string; ready: boolean; + source?: string; + hint?: string; }; type ApiResult = { @@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
小飞侠接口联调 - 通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置 - {' '}XIAOFEIXIA_MCH_IDXIAOFEIXIA_API_KEY。 + 通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。凭证请在 + 仓配管理 + 中配置;联调会优先使用仓配管理里启用的小飞侠承运商。 {config && ( @@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() { {config.ready ? 可调用 : 配置不完整} + + {config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'} + {config.hint ? ` · ${config.hint}` : ''} + {!config.ready && ( )} diff --git a/packages/shared-types/src/fulfillment-provider.ts b/packages/shared-types/src/fulfillment-provider.ts index 2883eab..8976be1 100644 --- a/packages/shared-types/src/fulfillment-provider.ts +++ b/packages/shared-types/src/fulfillment-provider.ts @@ -1,6 +1,24 @@ import type { FulfillmentProviderStatus, FulfillmentProviderType } from './enums'; import type { WarehouseFulfillmentMode } from './enums'; +/** 小飞侠仓配凭证(存 FulfillmentProvider.configJson) */ +export interface XiaofeixiaProviderConfig { + apiUrl: string; + mchId: string; + apiKey: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; +} + +/** 回显用(不含明文 apiKey) */ +export interface XiaofeixiaProviderConfigPublic { + apiUrl: string; + mchId: string; + signType: 'MD5' | 'HMAC-SHA256'; + appId?: string; + hasApiKey: boolean; +} + export interface FulfillmentProviderDto { id: string; code: string; @@ -14,6 +32,8 @@ export interface FulfillmentProviderDto { cancel?: boolean; } | null; hasConfig: boolean; + /** 小飞侠等承运商结构化配置(脱敏) */ + xiaofeixiaConfig?: XiaofeixiaProviderConfigPublic | null; createdAt: string; updatedAt: string; } @@ -25,6 +45,8 @@ export interface CreateFulfillmentProviderInput { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + /** 结构化小飞侠配置;有则覆盖写入 configJson */ + xiaofeixiaConfig?: Partial; } export interface UpdateFulfillmentProviderInput { @@ -33,6 +55,7 @@ export interface UpdateFulfillmentProviderInput { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + xiaofeixiaConfig?: Partial; } export interface ManualShipOrderInput { @@ -49,3 +72,9 @@ export interface WarehouseFulfillmentConfig { lng?: number | null; lat?: number | null; } + +export const XFX_PROVIDER_CODES = ['XFX', 'XIAOFEIXIA'] as const; + +export function isXfxProviderCode(code: string): boolean { + return (XFX_PROVIDER_CODES as readonly string[]).includes(code.trim().toUpperCase()); +} diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index f798a86..e88b2eb 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -189,6 +189,12 @@ async function main() { callback: true, cancel: true, }), + configJson: JSON.stringify({ + apiUrl: 'https://beta.51xiaoju.cn/app/api/interface.do', + mchId: '', + apiKey: '', + signType: 'MD5', + }), }, }); diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts index b78d739..701fa2f 100644 --- a/server/dukang-api/src/common/system-config/system-config.registry.ts +++ b/server/dukang-api/src/common/system-config/system-config.registry.ts @@ -6,7 +6,6 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [ { key: 'sms', label: '短信' }, { key: 'wechat', label: '微信' }, { key: 'oss', label: '对象存储 OSS' }, - { key: 'courier', label: '同城配送' }, { key: 'app', label: '应用链接' }, { key: 'deploy', label: '发布部署' }, ]; @@ -16,7 +15,6 @@ const G = { sms: 'sms', wechat: 'wechat', oss: 'oss', - courier: 'courier', app: 'app', deploy: 'deploy', } as const; @@ -79,19 +77,6 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ { key: 'OSS_UPLOAD_EXPIRE_SECONDS', label: '直传凭证有效期(秒)', group: G.oss, type: 'number', requiresRestart: false }, { key: 'OSS_MAX_UPLOAD_BYTES', label: '单文件上限(字节)', group: G.oss, type: 'number', requiresRestart: false }, - { key: 'COURIER_PROVIDER', label: '配送服务商', group: G.courier, type: 'string', requiresRestart: true, placeholder: 'xiaofeixia' }, - { key: 'XIAOFEIXIA_API_URL', label: '小飞侠 API 地址', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'XIAOFEIXIA_MCH_ID', label: '小飞侠商户号', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'XIAOFEIXIA_API_KEY', label: '小飞侠 API Key', group: G.courier, type: 'password', secret: true, requiresRestart: false }, - { key: 'XIAOFEIXIA_SIGN_TYPE', label: '小飞侠签名类型', group: G.courier, type: 'string', requiresRestart: false, placeholder: 'MD5' }, - { key: 'XIAOFEIXIA_APP_ID', label: '小飞侠 AppID', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_NAME', label: '默认寄件人', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_MOBILE', label: '默认寄件手机', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_ADDRESS', label: '默认寄件地址', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_ADDRESS_DETAIL', label: '默认寄件门牌', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_LNG', label: '默认寄件经度', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'SHIP_FROM_LAT', label: '默认寄件纬度', group: G.courier, type: 'string', requiresRestart: false }, - { key: 'USER_H5_URL', label: 'C 端 H5 落地页', group: G.app, type: 'string', requiresRestart: false }, { key: 'TENCENT_LBS_KEY', label: '腾讯位置服务 Key', group: G.app, type: 'password', secret: true, requiresRestart: false }, diff --git a/server/dukang-api/src/integrations/courier/courier.service.ts b/server/dukang-api/src/integrations/courier/courier.service.ts index e01acb3..d0cc76d 100644 --- a/server/dukang-api/src/integrations/courier/courier.service.ts +++ b/server/dukang-api/src/integrations/courier/courier.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { COURIER_PROVIDER } from './courier.constants'; import type { BatchShipmentQuery, + CourierCallOptions, CreateShipmentInput, CreateShipmentResult, DeliveryCoverageResult, @@ -24,32 +25,32 @@ export class CourierService { return this.provider.code; } - createShipment(input: CreateShipmentInput): Promise { - return this.provider.createShipment(input); + createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise { + return this.provider.createShipment(input, options); } - cancelShipment(query: ShipmentQuery): Promise { - return this.provider.cancelShipment(query); + cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.cancelShipment(query, options); } - getShipment(query: ShipmentQuery): Promise { - return this.provider.getShipment(query); + getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.getShipment(query, options); } - batchGetShipments(query: BatchShipmentQuery): Promise { - return this.provider.batchGetShipments(query); + batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.batchGetShipments(query, options); } - getTrack(query: ShipmentQuery): Promise { - return this.provider.getTrack(query); + getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise { + return this.provider.getTrack(query, options); } - checkDeliveryCoverage(toAddress: string): Promise { - return this.provider.checkDeliveryCoverage(toAddress); + checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise { + return this.provider.checkDeliveryCoverage(toAddress, options); } - estimateFreight(weight: number): Promise { - return this.provider.estimateFreight(weight); + estimateFreight(weight: number, options?: CourierCallOptions): Promise { + return this.provider.estimateFreight(weight, options); } buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse { diff --git a/server/dukang-api/src/integrations/courier/courier.types.ts b/server/dukang-api/src/integrations/courier/courier.types.ts index 4d52d15..58cfdda 100644 --- a/server/dukang-api/src/integrations/courier/courier.types.ts +++ b/server/dukang-api/src/integrations/courier/courier.types.ts @@ -1,4 +1,10 @@ import { CourierProviderCode } from './courier.constants'; +import type { XiaofeixiaConfig } from './courier.config'; + +/** 调用时覆盖默认 env 配置(仓配管理里维护的凭证) */ +export interface CourierCallOptions { + xiaofeixia?: XiaofeixiaConfig; +} export interface CourierCoordinate { lng: number; @@ -106,12 +112,12 @@ export interface TrackCallbackResponse { export interface ICourierProvider { readonly code: CourierProviderCode; - createShipment(input: CreateShipmentInput): Promise; - cancelShipment(query: ShipmentQuery): Promise; - getShipment(query: ShipmentQuery): Promise; - batchGetShipments(query: BatchShipmentQuery): Promise; - getTrack(query: ShipmentQuery): Promise; - checkDeliveryCoverage(toAddress: string): Promise; - estimateFreight(weight: number): Promise; + createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise; + cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise; + getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise; + batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise; + getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise; + checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise; + estimateFreight(weight: number, options?: CourierCallOptions): Promise; buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse; } diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts index 659f3bc..7dd7e35 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.client.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { CourierApiError } from '../courier.error'; -import { CourierConfigService } from '../courier.config'; +import { CourierConfigService, type XiaofeixiaConfig } from '../courier.config'; import { PrismaService } from '../../../common/prisma/prisma.module'; import { logCourierCall, @@ -21,12 +21,12 @@ export class XiaofeixiaClient { private readonly prisma: PrismaService, ) {} - async request(cmd: string, bizParams: RequestParams): Promise { - const cfg = this.courierConfig.load().xiaofeixia; + async request(cmd: string, bizParams: RequestParams, override?: XiaofeixiaConfig): Promise { + const cfg = override ?? this.courierConfig.load().xiaofeixia; const scene = sceneForXfxCmd(cmd); const externalNo = this.pickExternalNo(bizParams); - if (!cfg.mchId || !cfg.apiKey) { + if (!cfg.mchId || !cfg.apiKey || !cfg.apiUrl) { await logCourierCall(this.prisma, { scene, requestUrl: cfg.apiUrl || '(未配置)', @@ -36,7 +36,7 @@ export class XiaofeixiaClient { externalNo, }); throw new CourierApiError( - '小飞侠商户配置不完整,请设置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY', + '小飞侠商户配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key', 'CONFIG_ERROR', 'XIAOFEIXIA', ); diff --git a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts index ea67094..4de6b96 100644 --- a/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts +++ b/server/dukang-api/src/integrations/courier/xiaofeixia/xiaofeixia.provider.ts @@ -3,6 +3,7 @@ import { CourierProviderCode } from '../courier.constants'; import { CourierApiError } from '../courier.error'; import type { BatchShipmentQuery, + CourierCallOptions, CreateShipmentInput, CreateShipmentResult, DeliveryCoverageResult, @@ -29,28 +30,32 @@ export class XiaofeixiaProvider implements ICourierProvider { constructor(private readonly client: XiaofeixiaClient) {} - async createShipment(input: CreateShipmentInput): Promise { - const data = await this.client.request(XIAOFEIXIA_CMD.CREATE_ORDER, { - customerId: input.customerId, - outNumber: input.outNumber, - fromAddress: input.from.address, - fromAddressDetail: input.from.addressDetail, - fromCoordinate: this.formatCoordinate(input.from.coordinate), - fromMobile: input.from.mobile, - fromName: input.from.name, - toAddress: input.to.address, - toAddressDetail: input.to.addressDetail, - toCoordinate: this.formatCoordinate(input.to.coordinate), - toMobile: input.to.mobile, - toName: input.to.name, - goodsName: input.goodsName, - goodsNum: input.goodsNum, - weight: input.weight, - insuredSumPrice: input.insuredSumPrice, - collectionPrice: input.collectionPrice, - payMode: input.payMode, - remark: input.remark, - }); + async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise { + const data = await this.client.request( + XIAOFEIXIA_CMD.CREATE_ORDER, + { + customerId: input.customerId, + outNumber: input.outNumber, + fromAddress: input.from.address, + fromAddressDetail: input.from.addressDetail, + fromCoordinate: this.formatCoordinate(input.from.coordinate), + fromMobile: input.from.mobile, + fromName: input.from.name, + toAddress: input.to.address, + toAddressDetail: input.to.addressDetail, + toCoordinate: this.formatCoordinate(input.to.coordinate), + toMobile: input.to.mobile, + toName: input.to.name, + goodsName: input.goodsName, + goodsNum: input.goodsNum, + weight: input.weight, + insuredSumPrice: input.insuredSumPrice, + collectionPrice: input.collectionPrice, + payMode: input.payMode, + remark: input.remark, + }, + options?.xiaofeixia, + ); return { providerShipmentId: data.id, @@ -58,24 +63,32 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - async cancelShipment(query: ShipmentQuery): Promise { + async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - await this.client.request(XIAOFEIXIA_CMD.CANCEL_ORDER, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + await this.client.request( + XIAOFEIXIA_CMD.CANCEL_ORDER, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); } - async getShipment(query: ShipmentQuery): Promise { + async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - const data = await this.client.request(XIAOFEIXIA_CMD.GET_ORDER, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.GET_ORDER, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); return this.mapOrderDetail(data); } - async batchGetShipments(query: BatchShipmentQuery): Promise { + async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise { const number = query.trackingNumbers?.join(','); const outNumber = query.outNumbers?.join(','); @@ -83,27 +96,36 @@ export class XiaofeixiaProvider implements ICourierProvider { throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code); } - const data = await this.client.request(XIAOFEIXIA_CMD.BATCH_GET_ORDER, { - number, - outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.BATCH_GET_ORDER, + { + number, + outNumber, + }, + options?.xiaofeixia, + ); return (data ?? []).map((item) => this.mapOrderDetail(item)); } - async getTrack(query: ShipmentQuery): Promise { + async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise { this.assertShipmentQuery(query); - const data = await this.client.request(XIAOFEIXIA_CMD.TRACK_ROUTE, { - number: query.trackingNumber, - outNumber: query.outNumber, - }); + const data = await this.client.request( + XIAOFEIXIA_CMD.TRACK_ROUTE, + { + number: query.trackingNumber, + outNumber: query.outNumber, + }, + options?.xiaofeixia, + ); return data ?? []; } - async checkDeliveryCoverage(toAddress: string): Promise { + async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise { const data = await this.client.request( XIAOFEIXIA_CMD.DELIVERY_COVERAGE, { toAddress }, + options?.xiaofeixia, ); return { @@ -113,10 +135,11 @@ export class XiaofeixiaProvider implements ICourierProvider { }; } - async estimateFreight(weight: number): Promise { + async estimateFreight(weight: number, options?: CourierCallOptions): Promise { const data = await this.client.request( XIAOFEIXIA_CMD.ESTIMATE_FREIGHT, { weight }, + options?.xiaofeixia, ); return { freightPrice: data.freightPrice }; diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts index a4b4042..cb82e16 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts @@ -1,7 +1,13 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client'; +import { + isXfxProviderCode, + type XiaofeixiaProviderConfig, + type XiaofeixiaProviderConfigPublic, +} from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config'; export type CreateFulfillmentProviderInput = { code: string; @@ -10,6 +16,7 @@ export type CreateFulfillmentProviderInput = { status?: FulfillmentProviderStatus; configJson?: string; capabilitiesJson?: string; + xiaofeixiaConfig?: Partial; }; export type UpdateFulfillmentProviderInput = Partial; @@ -21,6 +28,8 @@ type Capabilities = { cancel?: boolean; }; +const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do'; + @Injectable() export class FulfillmentProviderService { constructor(private readonly prisma: PrismaService) {} @@ -46,6 +55,44 @@ export class FulfillmentProviderService { return this.toDto(row); } + /** 供推单使用:解析完整小飞侠凭证(含 apiKey) */ + async resolveXiaofeixiaConfig(providerId: bigint): Promise { + const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } }); + if (!row) throw new NotFoundException('仓配承运商不存在'); + if (!isXfxProviderCode(row.code)) { + throw new BadRequestException('该承运商不是小飞侠'); + } + const cfg = this.parseXiaofeixiaConfig(row.configJson); + if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) { + throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key'); + } + return { + apiUrl: cfg.apiUrl, + mchId: cfg.mchId, + apiKey: cfg.apiKey, + signType: this.resolveSignType(cfg.signType), + appId: cfg.appId || undefined, + }; + } + + /** 取第一个启用的小飞侠承运商配置(联调/兼容) */ + async resolveDefaultXiaofeixiaConfig(): Promise { + const row = await this.prisma.fulfillmentProvider.findFirst({ + where: { + status: 'ACTIVE', + type: 'API', + code: { in: ['XFX', 'XIAOFEIXIA'] }, + }, + orderBy: { updatedAt: 'desc' }, + }); + if (!row?.configJson) return null; + try { + return await this.resolveXiaofeixiaConfig(row.id); + } catch { + return null; + } + } + async create(input: CreateFulfillmentProviderInput) { const code = input.code.trim().toUpperCase(); if (!/^[A-Z0-9_]+$/.test(code)) { @@ -54,28 +101,54 @@ export class FulfillmentProviderService { const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } }); if (existing) throw new BadRequestException('承运商编码已存在'); + const configJson = this.resolveConfigJsonForWrite(code, null, input); + if (isXfxProviderCode(code) && input.type === 'API') { + this.assertXiaofeixiaConfigComplete(configJson, true); + } + const row = await this.prisma.fulfillmentProvider.create({ data: { code, name: input.name.trim(), type: input.type, status: input.status ?? 'ACTIVE', - configJson: input.configJson?.trim() || null, - capabilitiesJson: input.capabilitiesJson?.trim() || null, + configJson, + capabilitiesJson: + input.capabilitiesJson?.trim() || + (isXfxProviderCode(code) + ? JSON.stringify({ + createShipment: true, + getTrack: true, + callback: true, + cancel: true, + }) + : null), }, }); return this.toDto(row); } async update(id: bigint, input: UpdateFulfillmentProviderInput) { - await this.getById(id); + const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('仓配承运商不存在'); + + const code = current.code; + const configJson = + input.xiaofeixiaConfig !== undefined || input.configJson !== undefined + ? this.resolveConfigJsonForWrite(code, current.configJson, input) + : undefined; + + if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') { + this.assertXiaofeixiaConfigComplete(configJson, false); + } + const row = await this.prisma.fulfillmentProvider.update({ where: { id }, data: { ...(input.name !== undefined ? { name: input.name.trim() } : {}), ...(input.type !== undefined ? { type: input.type } : {}), ...(input.status !== undefined ? { status: input.status } : {}), - ...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}), + ...(configJson !== undefined ? { configJson } : {}), ...(input.capabilitiesJson !== undefined ? { capabilitiesJson: input.capabilitiesJson?.trim() || null } : {}), @@ -93,6 +166,74 @@ export class FulfillmentProviderService { } } + parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed !== 'object') return null; + return { + apiUrl: String(parsed.apiUrl ?? '').trim(), + mchId: String(parsed.mchId ?? '').trim(), + apiKey: String(parsed.apiKey ?? '').trim(), + signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5', + appId: parsed.appId ? String(parsed.appId).trim() : undefined, + }; + } catch { + return null; + } + } + + private resolveConfigJsonForWrite( + code: string, + existingRaw: string | null, + input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput, + ): string | null { + if (isXfxProviderCode(code) && input.xiaofeixiaConfig) { + const existing = this.parseXiaofeixiaConfig(existingRaw); + const next: XiaofeixiaProviderConfig = { + apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(), + mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(), + apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(), + signType: + input.xiaofeixiaConfig.signType === 'HMAC-SHA256' + ? 'HMAC-SHA256' + : input.xiaofeixiaConfig.signType === 'MD5' + ? 'MD5' + : existing?.signType ?? 'MD5', + appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined, + }; + return JSON.stringify(next); + } + if (input.configJson !== undefined) { + return input.configJson?.trim() || null; + } + return existingRaw; + } + + private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) { + const cfg = this.parseXiaofeixiaConfig(configJson); + if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址'); + if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号'); + if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key'); + if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写'); + } + + private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null { + const cfg = this.parseXiaofeixiaConfig(raw); + if (!cfg) return null; + return { + apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL, + mchId: cfg.mchId, + signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5', + appId: cfg.appId, + hasApiKey: Boolean(cfg.apiKey), + }; + } + + private resolveSignType(raw?: string): XiaofeixiaSignType { + return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5'; + } + private toDto(row: { id: bigint; code: string; @@ -112,6 +253,9 @@ export class FulfillmentProviderService { status: row.status, capabilities: this.parseCapabilities(row.capabilitiesJson), hasConfig: Boolean(row.configJson), + xiaofeixiaConfig: isXfxProviderCode(row.code) + ? this.toPublicXiaofeixiaConfig(row.configJson) + : null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }); diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index 90ce54f..df0a52c 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; +import { isXfxProviderCode } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { CourierService } from '../../integrations/courier/courier.service'; import { CourierPayMode } from '../../integrations/courier/courier.types'; +import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import { TradeService } from '../trade/trade.service'; +import { FulfillmentProviderService } from './fulfillment-provider.service'; export type ManualShipInput = { logisticsCompany: string; @@ -14,16 +16,14 @@ export type ManualShipInput = { export type HqLogisticsShipInput = ManualShipInput; -const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']); - @Injectable() export class FulfillmentService { private readonly logger = new Logger(FulfillmentService.name); constructor( private readonly prisma: PrismaService, - private readonly config: ConfigService, private readonly courier: CourierService, + private readonly fulfillmentProviderService: FulfillmentProviderService, @Inject(forwardRef(() => TradeService)) private readonly tradeService: TradeService, ) {} @@ -69,38 +69,50 @@ export class FulfillmentService { } async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { - if (!XFX_CODES.has(provider.code)) { + if (!isXfxProviderCode(provider.code)) { this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); return; } - const defaults = this.getShipDefaults(); - const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng; - const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat; + let xfxConfig: XiaofeixiaConfig; + try { + xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await this.logDispatchFailure(order, provider, message); + await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); + return; + } + + const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665; + const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757; try { - const result = await this.courier.createShipment({ - outNumber: order.orderNo, - from: { - name: warehouse.contactName, - mobile: warehouse.contactPhone, - address: warehouse.address, - addressDetail: warehouse.name, - coordinate: { lng: fromLng, lat: fromLat }, + const result = await this.courier.createShipment( + { + outNumber: order.orderNo, + from: { + name: warehouse.contactName, + mobile: warehouse.contactPhone, + address: warehouse.address, + addressDetail: warehouse.name, + coordinate: { lng: fromLng, lat: fromLat }, + }, + to: { + name: order.receiverName, + mobile: order.receiverPhone, + address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, + addressDetail: order.receiverAddress, + }, + goodsName: order.productName, + goodsNum: order.quantity, + weight: 2, + payMode: CourierPayMode.SENDER, + remark: `仓配自动发货 ${order.orderNo}`, }, - to: { - name: order.receiverName, - mobile: order.receiverPhone, - address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, - addressDetail: order.receiverAddress, - }, - goodsName: order.productName, - goodsNum: order.quantity, - weight: defaults.weight, - payMode: defaults.payMode, - remark: `仓配自动发货 ${order.orderNo}`, - }); + { xiaofeixia: xfxConfig }, + ); const now = new Date(); await this.prisma.$transaction(async (tx) => { @@ -196,10 +208,20 @@ export class FulfillmentService { if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) { try { - const nodes = await this.courier.getTrack({ - trackingNumber: order.delivery.trackingNo ?? undefined, - outNumber: order.orderNo, - }); + const options = order.delivery.fulfillmentProviderId + ? { + xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig( + order.delivery.fulfillmentProviderId, + ), + } + : undefined; + const nodes = await this.courier.getTrack( + { + trackingNumber: order.delivery.trackingNo ?? undefined, + outNumber: order.orderNo, + }, + options, + ); return { nodes, manualQueryUrl: order.delivery.manualQueryUrl, @@ -208,7 +230,7 @@ export class FulfillmentService { logisticsCompany: order.delivery.logisticsCompany, }; } catch { - // fall through to manual fields + // fall through } } @@ -294,13 +316,4 @@ export class FulfillmentService { if (!tpl) return undefined; return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo)); } - - private getShipDefaults() { - return { - fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), - fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), - weight: 2, - payMode: CourierPayMode.SENDER, - }; - } } diff --git a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts index 98fd5d2..c79005b 100644 --- a/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-fulfillment-providers.controller.ts @@ -39,9 +39,15 @@ export class AdminFulfillmentProvidersController { status: dto.status as FulfillmentProviderStatus | undefined, configJson: dto.configJson, capabilitiesJson: dto.capabilitiesJson, + xiaofeixiaConfig: dto.xiaofeixiaConfig, }); } + @Get(':id') + detail(@Param('id') id: string) { + return this.service.getById(BigInt(id)); + } + @Put(':id') @HqOperation({ action: HqOperationAction.WAREHOUSE_UPDATE, @@ -56,6 +62,7 @@ export class AdminFulfillmentProvidersController { status: dto.status as FulfillmentProviderStatus | undefined, configJson: dto.configJson, capabilitiesJson: dto.capabilitiesJson, + xiaofeixiaConfig: dto.xiaofeixiaConfig, }); } } diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index f36e2ab..6fa57d0 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; @@ -11,6 +10,7 @@ import type { AdminOrdersQueryDto } from './dto/admin-query.dto'; import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; @Injectable() export class AdminOrdersService { @@ -19,7 +19,7 @@ export class AdminOrdersService { private readonly tradeService: TradeService, private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly fulfillmentService: FulfillmentService, - private readonly config: ConfigService, + private readonly fulfillmentProviderService: FulfillmentProviderService, ) {} async list(query: AdminOrdersQueryDto) { @@ -96,21 +96,6 @@ export class AdminOrdersService { return this.detail(id); } - getShipDefaults() { - return { - provider: 'XFX', - providerLabel: '小飞侠', - fromName: this.config.get('SHIP_FROM_NAME') || '杜康仓库', - fromMobile: this.config.get('SHIP_FROM_MOBILE') || '13800000000', - fromAddress: this.config.get('SHIP_FROM_ADDRESS') || '河南省郑州市金水区', - fromAddressDetail: this.config.get('SHIP_FROM_ADDRESS_DETAIL') || '杜康酒业仓', - fromLng: Number(this.config.get('SHIP_FROM_LNG') || 113.665), - fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), - weight: 2, - payMode: '1', - }; - } - async shipOrder(id: bigint, dto: AdminShipOrderDto) { if (dto.provider !== 'XFX') { throw new BadRequestException('暂仅支持小飞侠配送'); @@ -118,7 +103,10 @@ export class AdminOrdersService { const order = await this.prisma.order.findUnique({ where: { id }, - include: { delivery: true }, + include: { + delivery: true, + fulfillmentWarehouse: true, + }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -129,7 +117,23 @@ export class AdminOrdersService { throw new BadRequestException('该订单已有运单号,请勿重复发货'); } - const defaults = this.getShipDefaults(); + const warehouse = order.fulfillmentWarehouse; + const providerId = + order.delivery?.fulfillmentProviderId ?? + warehouse?.fulfillmentProviderId ?? + null; + + let xfxConfig; + if (providerId) { + xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId); + } else { + xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + if (!xfxConfig) { + throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商'); + } + } + + const defaults = this.getShipDefaults(warehouse); const shipmentDto: XiaofeixiaCreateShipmentDto = { outNumber: order.orderNo, fromName: dto.fromName || defaults.fromName, @@ -149,13 +153,14 @@ export class AdminOrdersService { remark: dto.remark || `HQ发货 ${order.orderNo}`, }; - const result = await this.xiaofeixiaService.createShipment(shipmentDto); + const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig); if (!result.ok || !result.data) { throw new BadRequestException(result.error || '小飞侠创建运单失败'); } const { providerShipmentId, trackingNumber } = result.data; const now = new Date(); + const resolvedProviderId = providerId; await this.prisma.$transaction(async (tx) => { if (order.delivery) { @@ -163,6 +168,7 @@ export class AdminOrdersService { where: { orderId: id }, data: { provider: 'XFX', + fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, @@ -173,6 +179,7 @@ export class AdminOrdersService { data: { orderId: id, provider: 'XFX', + fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, @@ -185,6 +192,40 @@ export class AdminOrdersService { return this.detail(id); } + getShipDefaults(warehouse?: { + contactName: string; + contactPhone: string; + address: string; + name: string; + lng: { toNumber?: () => number } | number | null; + lat: { toNumber?: () => number } | number | null; + } | null) { + const lng = + warehouse?.lng != null + ? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng + ? Number(warehouse.lng) + : Number(warehouse.lng) + : 113.665; + const lat = + warehouse?.lat != null + ? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat + ? Number(warehouse.lat) + : Number(warehouse.lat) + : 34.757; + return { + provider: 'XFX', + providerLabel: '小飞侠', + fromName: warehouse?.contactName || '杜康仓库', + fromMobile: warehouse?.contactPhone || '13800000000', + fromAddress: warehouse?.address || '河南省郑州市金水区', + fromAddressDetail: warehouse?.name || '杜康酒业仓', + fromLng: lng, + fromLat: lat, + weight: 2, + payMode: '1', + }; + } + /** 总部传统快递填单(同城无仓 / 跨城) */ async shipLogistics(id: bigint, dto: HqLogisticsShipDto) { await this.fulfillmentService.shipHqLogistics(id, dto); diff --git a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts index 4f9bef2..4c2bc46 100644 --- a/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts +++ b/server/dukang-api/src/modules/ops/admin-xiaofeixia.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { CourierConfigService } from '../../integrations/courier/courier.config'; +import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config'; import { CourierApiError } from '../../integrations/courier/courier.error'; import { CourierService } from '../../integrations/courier/courier.service'; import { CourierPayMode } from '../../integrations/courier/courier.types'; @@ -8,6 +9,7 @@ import type { CreateShipmentInput, ShipmentQuery, } from '../../integrations/courier/courier.types'; +import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import type { XiaofeixiaBatchShipmentQueryDto, XiaofeixiaCheckCoverageDto, @@ -27,48 +29,61 @@ export class AdminXiaofeixiaService { constructor( private readonly courier: CourierService, private readonly courierConfig: CourierConfigService, + private readonly fulfillmentProviderService: FulfillmentProviderService, ) {} - getConfig() { - const cfg = this.courierConfig.load(); - const xfx = cfg.xiaofeixia; + async getConfig() { + const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + const envCfg = this.courierConfig.load().xiaofeixia; + const xfx = fromDb ?? envCfg; return { - provider: cfg.provider, + provider: this.courierConfig.load().provider, activeProvider: this.courier.activeProvider, + source: fromDb ? 'fulfillment_provider' : 'env', apiUrl: xfx.apiUrl, appId: xfx.appId ?? null, mchId: xfx.mchId || null, mchIdMasked: xfx.mchId ? maskSecret(xfx.mchId) : null, hasApiKey: Boolean(xfx.apiKey), signType: xfx.signType, - ready: Boolean(xfx.mchId && xfx.apiKey), + ready: Boolean(xfx.mchId && xfx.apiKey && xfx.apiUrl), + hint: fromDb + ? '凭证来自仓配管理中启用的小飞侠承运商' + : '未在仓配管理配置,回退到环境变量(请迁移至仓配管理)', }; } async estimateFreight(dto: XiaofeixiaEstimateFreightDto) { - return this.wrap(() => this.courier.estimateFreight(dto.weight)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.estimateFreight(dto.weight, options)); } async checkCoverage(dto: XiaofeixiaCheckCoverageDto) { - return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.checkDeliveryCoverage(dto.toAddress, options)); } - async createShipment(dto: XiaofeixiaCreateShipmentDto) { + async createShipment(dto: XiaofeixiaCreateShipmentDto, xfxOverride?: XiaofeixiaConfig) { const input = this.mapCreateInput(dto); - return this.wrap(() => this.courier.createShipment(input)); + const options = xfxOverride + ? { xiaofeixia: xfxOverride } + : await this.callOptions(); + return this.wrap(() => this.courier.createShipment(input, options)); } async cancelShipment(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); + const options = await this.callOptions(); return this.wrap(async () => { - await this.courier.cancelShipment(query); + await this.courier.cancelShipment(query, options); return { cancelled: true }; }); } async getShipment(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); - return this.wrap(() => this.courier.getShipment(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.getShipment(query, options)); } async batchGetShipments(dto: XiaofeixiaBatchShipmentQueryDto) { @@ -76,12 +91,19 @@ export class AdminXiaofeixiaService { trackingNumbers: dto.trackingNumbers?.filter(Boolean), outNumbers: dto.outNumbers?.filter(Boolean), }; - return this.wrap(() => this.courier.batchGetShipments(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.batchGetShipments(query, options)); } async getTrack(dto: XiaofeixiaShipmentQueryDto) { const query = this.mapShipmentQuery(dto); - return this.wrap(() => this.courier.getTrack(query)); + const options = await this.callOptions(); + return this.wrap(() => this.courier.getTrack(query, options)); + } + + private async callOptions() { + const fromDb = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); + return fromDb ? { xiaofeixia: fromDb } : undefined; } private mapShipmentQuery(dto: XiaofeixiaShipmentQueryDto): ShipmentQuery { 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 c9d3e30..a1ede94 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 @@ -583,6 +583,16 @@ export class CreateFulfillmentProviderDto { @IsOptional() @IsString() capabilitiesJson?: string; + + @IsOptional() + @IsObject() + xiaofeixiaConfig?: { + apiUrl?: string; + mchId?: string; + apiKey?: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; + }; } export class UpdateFulfillmentProviderDto { @@ -605,6 +615,16 @@ export class UpdateFulfillmentProviderDto { @IsOptional() @IsString() capabilitiesJson?: string; + + @IsOptional() + @IsObject() + xiaofeixiaConfig?: { + apiUrl?: string; + mchId?: string; + apiKey?: string; + signType?: 'MD5' | 'HMAC-SHA256'; + appId?: string; + }; } export class ManualShipOrderDto { From 7fa2c6fbed1e96194a48b2aedb0f2db8c8b439d7 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 07:45:20 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E5=9F=8E=E5=B8=82=E5=90=88=E4=BC=99?= =?UTF-8?q?=E4=BA=BA=E7=AB=AF=E9=A1=B5=E9=9D=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/contexts/PartnerSessionContext.tsx | 2 + apps/h5-partner/src/lib/api.ts | 15 +- apps/h5-partner/src/lib/wechat-auth.ts | 4 + apps/h5-partner/src/pages/CenterPage.tsx | 307 ++++++++++++++---- apps/h5-partner/src/pages/HomePage.tsx | 140 +++----- apps/h5-partner/src/pages/StoreListPage.tsx | 6 +- apps/h5-partner/src/styles.css | 273 ++++++++++++++-- packages/shared-types/src/partner.ts | 4 + server/dukang-api/prisma/schema.prisma | 2 + .../wechat/wechat.mock.provider.ts | 2 +- .../src/modules/iam/auth.service.ts | 45 ++- .../settlement/settlement.controller.ts | 2 + 12 files changed, 601 insertions(+), 201 deletions(-) diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index dd20eb2..3478f57 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -53,6 +53,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount { primaryPhone: profile.primaryPhone, primaryName: profile.primaryName, hasWechat: profile.hasWechat, + wxNickname: profile.wxNickname, + wxAvatarUrl: profile.wxAvatarUrl, }; } diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index 232f0fc..f8258e7 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -24,7 +24,18 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [ export type PartnerSessionProfile = Pick< PartnerMe, - 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'primaryPhone' | 'primaryName' | 'hasWechat' + | 'id' + | 'name' + | 'phone' + | 'companyName' + | 'isPrimary' + | 'permissions' + | 'primaryAccountId' + | 'primaryPhone' + | 'primaryName' + | 'hasWechat' + | 'wxNickname' + | 'wxAvatarUrl' > & { staffRole?: PartnerStaffRole; }; @@ -112,6 +123,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile { primaryPhone: me.primaryPhone, primaryName: me.primaryName, hasWechat: me.hasWechat, + wxNickname: me.wxNickname, + wxAvatarUrl: me.wxAvatarUrl, }; } diff --git a/apps/h5-partner/src/lib/wechat-auth.ts b/apps/h5-partner/src/lib/wechat-auth.ts index 47e721a..5a14d7b 100644 --- a/apps/h5-partner/src/lib/wechat-auth.ts +++ b/apps/h5-partner/src/lib/wechat-auth.ts @@ -14,6 +14,8 @@ export type PartnerProfile = { phone: string; companyName?: string; hasWechat?: boolean; + wxNickname?: string | null; + wxAvatarUrl?: string | null; isPrimary?: boolean; }; @@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio companyName: partner.companyName ? String(partner.companyName) : undefined, isPrimary: partner.isPrimary !== false, hasWechat: true, + wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined, + wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined, } : undefined, }; diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index fbb34f2..58535ad 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -1,10 +1,19 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import type { PartnerBillDto } from '@dukang/shared-types'; +import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { request } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { contactSupport } from '../lib/contact'; import { isPrimaryAccount } from '../lib/partnerAccess'; -import { toastError } from '../lib/toast'; +import { listPartnerStaff } from '../lib/staff'; +import { toastError, toastSuccess } from '../lib/toast'; +import { + authorizePartnerWechat, + fetchClientConfig, + handlePartnerWechatLoginResult, +} from '../lib/wechat-auth'; +import { isWechatEnv } from '../lib/weixin'; type CenterPageProps = { /** 子账号个人中心复用 */ @@ -12,11 +21,18 @@ type CenterPageProps = { roleLabel?: string; }; +function fmtMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) { - const { account, logout, refresh } = usePartnerSession(); + const { account, logout, refresh, applySession } = usePartnerSession(); const isPrimary = variant === 'primary' && isPrimaryAccount(account); const [name, setName] = useState(account?.name ?? ''); const [saving, setSaving] = useState(false); + const [authorizing, setAuthorizing] = useState(false); + const [bills, setBills] = useState([]); + const [staffCount, setStaffCount] = useState(0); useEffect(() => { document.title = isPrimary ? '合伙人中心' : '个人中心'; @@ -26,6 +42,29 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag setName(account?.name ?? ''); }, [account?.name]); + useEffect(() => { + if (!isPrimary) return; + void request('PARTNER_H5', '/partner/settlement/bills') + .then(setBills) + .catch(() => setBills([])); + void listPartnerStaff() + .then((list) => setStaffCount(list.length)) + .catch(() => setStaffCount(0)); + }, [isPrimary]); + + const finance = useMemo(() => { + const pending = bills.filter((b) => b.status === 'DRAFT'); + const settled = bills.filter((b) => b.status === 'PAID'); + const paidCount = settled.length; + return { + pendingBillCount: pending.length, + pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + paidCount, + }; + }, [bills]); + async function handleSave() { const trimmed = name.trim(); if (!trimmed) { @@ -57,86 +96,218 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag window.location.href = `tel:${account.primaryPhone}`; } + async function handleAvatarAuth() { + if (authorizing) return; + if (!isWechatEnv()) { + toastError('请在微信内打开以授权头像昵称'); + return; + } + setAuthorizing(true); + try { + const config = await fetchClientConfig(); + if (!isWxAuthorizeEnabled(config)) { + toastError('微信授权暂未开启'); + return; + } + const result = await authorizePartnerWechat(); + if (result) { + const session = handlePartnerWechatLoginResult(result); + if (session) applySession(session); + await refresh(); + toastSuccess('微信授权成功'); + } + // result 为空表示已跳转 OAuth,无需额外处理 + } catch (e) { + toastError(e instanceof Error ? e.message : '微信授权失败'); + } finally { + setAuthorizing(false); + } + } + + const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—'; + const displayName = account?.wxNickname?.trim() || account?.name || '合伙人'; + const avatarUrl = account?.wxAvatarUrl?.trim() || ''; + const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员'); + return (
-
- person -
-
-
-

{account?.name || '合伙人'}

- {roleLabel || (isPrimary ? '城市合伙人' : '拓店员')} + +
+
+

{displayName}

+ {badgeLabel}
-
+
location_on - {account?.companyName || '—'} + {locationLabel}
-

{account?.phone || ''}

-
-
- -
-

个人信息设置

-
- - setName(e.target.value)} - placeholder="请输入姓名" - maxLength={32} - /> - - -

手机号由管理员维护,如需修改请联系总部

- -
-
- -
-
- - - {isPrimary && ( - -
-
group
- 子账号管理 -
- chevron_right - + {!isPrimary && ( +

{account?.phone || ''}

)}
+ {isPrimary && ( + <> +
+
+

资产概览

+ + 明细 chevron_right + +
+ + {finance.pendingBillCount > 0 && ( + +
+
+ receipt_long +
+
+

待确认账单

+

+ 您有 {finance.pendingBillCount} 笔账单待确认 +

+
+
+ chevron_right + + )} + +
+
+

账户余额 (元)

+

{fmtMoney(finance.balance)}

+
+ +

待结算

+

¥ {fmtMoney(finance.pendingTotal)}

+ + +

+ 提现记录 + history +

+

{finance.paidCount} 笔

+ +
+
+ +
+
+

运营管理

+
+
+ +
+
+ badge +
+ 我的员工 +
+
+ {staffCount > 0 && {staffCount}人} + chevron_right +
+ + +
+
+ storefront +
+ 门店审核记录 +
+ chevron_right + + +
+
+ description +
+ 合同管理 +
+
+ 待续约 + chevron_right +
+ +
+
+ + )} + + {!isPrimary && ( + <> +
+

个人信息设置

+
+ + setName(e.target.value)} + placeholder="请输入姓名" + maxLength={32} + /> + + +

手机号由管理员维护,如需修改请联系总部

+ +
+
+ +
+
+ + +
+
+ + )} + -
+

传承千年 · 杜康好客

diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index a84ae54..519d9b5 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -5,12 +5,10 @@ import { isLoggedIn, request } from '../lib/api'; import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { getPartnerNavKind } from '../lib/partnerAccess'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; -import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -const HOME_STORE_PREVIEW_LIMIT = 6; const LEADERBOARD_PREVIEW_LIMIT = 3; @@ -92,67 +90,42 @@ function summarizeOrders(orders: Array>) { return { pendingShip, shipping, completed, todayCount }; } -function LeaderboardPreview({ - entries, - self, -}: { - entries: PartnerLeaderboardEntry[]; - self?: PartnerLeaderboardEntry; -}) { - const selfInList = self ? entries.some((e) => e.accountId === self.accountId) : false; - +function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) { return ( -
+

- military_tech + emoji_events 合伙人贡献榜

- 查看全部 + + 查看全部 chevron_right +
- {entries.length === 0 && !self ? ( + {entries.length === 0 ? (

暂无排行数据

) : ( - <> - {entries.map((entry) => ( -
-
{entry.rank}
-
-

- {entry.name} - {entry.isSelf ? 我的排名 : null} - ({entry.roleLabel}) -

-

累计拓店 {entry.totalStores} 间

-
-
-

{entry.periodStores} 间

-

本月新增

-
+ entries.map((entry) => ( +
+
+ person + {entry.rank}
- ))} - {self && !selfInList ? ( -
-
{self.rank}
-
-

- {self.name} - 我的排名 -

-

累计拓店 {self.totalStores} 间

-
-
-

{self.periodStores} 间

-

本月新增

-
+
+

+ {entry.name} + ({entry.roleLabel}) +

+

累计拓店: {entry.totalStores} 间

- ) : null} - +
+

{entry.periodStores} 间

+

本月新增

+
+
+ )) )} -
+
); } @@ -167,7 +140,6 @@ export default function HomePage() { const [stores, setStores] = useState>>([]); const [orders, setOrders] = useState>>([]); const [leaderboardEntries, setLeaderboardEntries] = useState([]); - const [leaderboardSelf, setLeaderboardSelf] = useState(); useEffect(() => { document.title = '工作台'; @@ -186,11 +158,9 @@ export default function HomePage() { fetchPartnerLeaderboard('month') .then((data) => { setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); - setLeaderboardSelf(data.self); }) .catch(() => { setLeaderboardEntries([]); - setLeaderboardSelf(undefined); }); } else { request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); @@ -207,8 +177,16 @@ export default function HomePage() { const now = new Date(); return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); }).length; + const lastMonthNew = stores.filter((s) => { + const created = new Date(String(s.createdAt || '')); + const now = new Date(); + const last = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear(); + }).length; + const monthGrowthPct = lastMonthNew > 0 + ? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100) + : (monthNew > 0 ? 100 : 0); - const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0); const revenue = orderCount * 128.45; const profit = revenue * 0.25; @@ -309,55 +287,31 @@ export default function HomePage() {
-
-
-

辖区门店

- 查看全部 -
- {previewStores.length === 0 ? ( -
-

暂无门店

- 录入新店 -
- ) : ( - previewStores.map((s) => { - const storeId = String(s.id); - const status = String(s.status || 'PAUSED').toUpperCase(); - const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); - return ( - -
-

{String(s.name || '未命名门店')}

-

{String(s.address || s.district || '')}

-
- - {audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)} - - - ); - }) - )} -
-

拓店情况

本月新增签约

- {monthNew} +
+ {monthNew} + {monthGrowthPct !== 0 && ( + 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}> + + {monthGrowthPct > 0 ? 'trending_up' : 'trending_down'} + + {Math.abs(monthGrowthPct)}% + + )} +

待审核门店

{pendingAuditCount}
- - - -

- {String(dash?.companyName || '郑州合伙人')} · 辖区管理 -

+ + )} diff --git a/apps/h5-partner/src/pages/StoreListPage.tsx b/apps/h5-partner/src/pages/StoreListPage.tsx index de8ff34..006b07b 100644 --- a/apps/h5-partner/src/pages/StoreListPage.tsx +++ b/apps/h5-partner/src/pages/StoreListPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { isSubAccount } from '../lib/partnerAccess'; @@ -25,11 +25,13 @@ const FILTERS: { key: StatusFilter; label: string }[] = [ export default function StoreListPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { account } = usePartnerSession(); const readonly = isSubAccount(account); const [stores, setStores] = useState>>([]); const [q, setQ] = useState(''); - const [filter, setFilter] = useState('ALL'); + const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter; + const [filter, setFilter] = useState(initialFilter); const [updatingId, setUpdatingId] = useState(null); const [error, setError] = useState(''); diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index be60113..b366bc0 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -621,9 +621,6 @@ html { .partner-expansion-split { display: flex; - border-bottom: 1px solid rgba(226, 190, 188, 0.2); - padding-bottom: var(--space-md); - margin-bottom: var(--space-md); } .partner-expansion-split > div { @@ -635,6 +632,28 @@ html { border-left: 1px solid rgba(226, 190, 188, 0.2); } +.partner-expansion-value-row { + display: flex; + align-items: center; + gap: 8px; +} + +.partner-expansion-trend { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12px; + font-weight: 600; +} + +.partner-expansion-trend--up { + color: #16a34a; +} + +.partner-expansion-trend--down { + color: var(--color-heritage-red); +} + .partner-form-error { margin: 0 20px 12px; padding: 10px 12px; @@ -845,6 +864,57 @@ html { color: #fff; } +.partner-leaderboard-preview .partner-leaderboard-header { + margin-bottom: 12px; +} + +.partner-leaderboard-avatar { + position: relative; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-surface-container-low); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.partner-leaderboard-avatar .material-symbols-outlined { + font-size: 22px; + color: var(--color-subtle-gray); + font-variation-settings: 'FILL' 1; +} + +.partner-leaderboard-avatar-badge { + position: absolute; + left: -2px; + top: -2px; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #9ca3af; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; + box-shadow: 0 0 0 2px #fff; +} + +.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge { + background: #d4a017; +} + +.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge { + background: #9ca3af; +} + +.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge { + background: #cd7f32; +} + .partner-leaderboard-info { flex: 1; min-width: 0; @@ -2402,6 +2472,14 @@ nav.app-tabbar .app-tabbar-label { justify-content: center; position: relative; flex-shrink: 0; + padding: 0; + cursor: pointer; + overflow: hidden; +} + +.partner-profile-avatar:disabled { + opacity: 0.7; + cursor: wait; } .partner-profile-avatar .material-symbols-outlined { @@ -2410,22 +2488,81 @@ nav.app-tabbar .app-tabbar-label { font-variation-settings: 'FILL' 1; } +.partner-profile-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.partner-profile-avatar-hint { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 2px 0; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + text-align: center; + color: #fff; + background: rgba(166, 29, 36, 0.82); +} + +.partner-profile-main { + flex: 1; + min-width: 0; +} + +.partner-profile-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.partner-profile-name { + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 1.3; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.partner-profile-location { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + color: var(--color-subtle-gray); +} + .partner-role-badge { + flex-shrink: 0; background: var(--color-secondary-container); color: var(--color-on-secondary-container); padding: 4px 8px; border-radius: 8px; font-family: var(--font-label); font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + line-height: 1.2; +} + +.partner-assets-section { + margin-top: var(--space-lg); } .partner-bills-banner { margin: 0 var(--space-page) 12px; padding: 16px; - background: rgba(166, 29, 36, 0.05); - border: 1px solid rgba(166, 29, 36, 0.1); + background: rgba(166, 29, 36, 0.06); + border: 1px solid rgba(166, 29, 36, 0.12); border-radius: var(--radius-md); display: flex; align-items: center; @@ -2464,13 +2601,100 @@ nav.app-tabbar .app-tabbar-label { border-radius: var(--radius-md); box-shadow: var(--shadow-card); border: 1px solid rgba(166, 29, 36, 0.05); + text-decoration: none; + color: inherit; + display: block; } .partner-finance-card--hero { - grid-column: span 2; - background: var(--color-heritage-red); + grid-column: 1 / -1; + background: linear-gradient(135deg, #a61d24 0%, #820012 100%); color: #fff; box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25); + border: none; + padding: 20px 24px; +} + +.partner-finance-hero-label { + font-size: 13px; + opacity: 0.88; + margin: 0 0 8px; +} + +.partner-finance-hero-amount { + font-size: 32px; + font-weight: 700; + letter-spacing: 0.02em; + margin: 0; + line-height: 1.2; +} + +.partner-finance-value { + margin-top: 8px; + font-size: 18px; + font-weight: 700; + color: var(--color-on-surface); +} + +.partner-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin: 0 var(--space-page) 8px; +} + +.partner-menu-section .partner-section-head { + margin: 0 0 8px; +} + +.partner-menu-item-right { + display: flex; + align-items: center; + gap: 6px; +} + +.partner-menu-badge { + font-size: 12px; + font-weight: 600; + color: var(--color-heritage-red); + background: rgba(166, 29, 36, 0.08); + padding: 2px 8px; + border-radius: 999px; +} + +.partner-menu-status { + font-size: 12px; + color: #2563eb; + font-weight: 500; +} + +.partner-logout-btn { + width: calc(100% - 2 * var(--space-page)); + margin: var(--space-lg) var(--space-page) 0; + padding: 16px; + border: 1px solid rgba(166, 29, 36, 0.08); + border-radius: var(--radius-md); + background: var(--color-card); + box-shadow: var(--shadow-card); + color: var(--color-heritage-red); + font-family: var(--font-headline); + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.partner-logout-btn .material-symbols-outlined { + color: var(--color-heritage-red); +} + +.partner-center-footer { + text-align: center; + opacity: 0.35; + padding: 24px 0 8px; } .partner-menu-section { @@ -2514,24 +2738,6 @@ nav.app-tabbar .app-tabbar-label { color: var(--color-heritage-red); } -.partner-logout-btn { - width: calc(100% - 40px); - margin: var(--space-lg) var(--space-page); - padding: 16px; - border: none; - border-radius: var(--radius-md); - background: var(--color-card); - box-shadow: var(--shadow-card); - color: var(--color-error); - font-size: 16px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - /* ── Settlement page ── */ .partner-settlement-page { min-height: 100vh; @@ -3067,22 +3273,19 @@ body { border-top: 1px dashed rgba(166, 29, 36, 0.15); } -.partner-home--flush-top .partner-home-body, +.partner-home--flush-top .partner-home-body { + padding-top: 20px; +} + .partner-home--flush-top.partner-store-page, .partner-home--flush-top.partner-center-page { - padding-top: 0; + padding-top: 12px; } .partner-home--flush-top .partner-sticky-filter { padding-top: 12px; } -.partner-leaderboard-row--self { - background: rgba(166, 29, 36, 0.08); - border: 1px solid rgba(166, 29, 36, 0.18); - border-radius: 10px; -} - .partner-leaderboard-self-tag { display: inline-block; margin-left: 6px; diff --git a/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts index 28f339d..30cc540 100644 --- a/packages/shared-types/src/partner.ts +++ b/packages/shared-types/src/partner.ts @@ -7,6 +7,10 @@ export interface PartnerMe { isPrimary: boolean; companyName?: string; hasWechat?: boolean; + /** 微信授权后的昵称 */ + wxNickname?: string | null; + /** 微信授权后的头像 URL */ + wxAvatarUrl?: string | null; staffRole?: PartnerStaffRole; permissions?: string[]; primaryAccountId?: string; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 856da00..b3fceef 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -567,6 +567,8 @@ model PartnerAccount { name String @db.VarChar(64) wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxUnionId String? @map("wx_union_id") @db.VarChar(64) + wxNickname String? @map("wx_nickname") @db.VarChar(64) + wxAvatarUrl String? @map("wx_avatar_url") @db.VarChar(512) isPrimary Int @default(0) @map("is_primary") @db.TinyInt parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt staffRole PartnerStaffRole? @map("staff_role") diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index ba44808..01e2e0d 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -48,7 +48,7 @@ export class WechatMockProvider implements IWechatProvider { return { openId, nickname: 'Mock微信用户', - headImgUrl: undefined, + headImgUrl: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(openId)}`, }; } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index fd67017..645bd8b 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -231,6 +231,9 @@ export class AuthService { isPrimary: number; staffRole: string | null; permissions?: unknown; + wxOpenId?: string | null; + wxNickname?: string | null; + wxAvatarUrl?: string | null; }, primary: { id: bigint; companyName: string | null }, ) { @@ -243,9 +246,37 @@ export class AuthService { staffRole: account.staffRole ?? undefined, companyName: primary.companyName ?? undefined, permissions: Array.isArray(account.permissions) ? account.permissions : undefined, + hasWechat: !!account.wxOpenId, + wxNickname: account.wxNickname ?? undefined, + wxAvatarUrl: account.wxAvatarUrl ?? undefined, }; } + /** 公众号 snsapi_userinfo:写入合伙人微信昵称与头像 */ + private async syncPartnerWechatProfile( + partnerAccountId: bigint, + accessToken: string | undefined, + openId: string, + ) { + if (!accessToken) return null; + try { + const info = await this.wechatProvider.fetchOAuthUserInfo(accessToken, openId, { + refType: 'PARTNER', + refId: partnerAccountId, + }); + const data: { wxNickname?: string; wxAvatarUrl?: string } = {}; + if (info.nickname?.trim()) data.wxNickname = info.nickname.trim().slice(0, 64); + if (info.headImgUrl?.trim()) data.wxAvatarUrl = info.headImgUrl.trim().slice(0, 512); + if (!data.wxNickname && !data.wxAvatarUrl) return null; + return this.prisma.partnerAccount.update({ + where: { id: partnerAccountId }, + data, + }); + } catch { + return null; + } + } + private async assertPartnerAccountByPhone(phone: string) { const account = await this.prisma.partnerAccount.findUnique({ where: { phone }, @@ -1547,7 +1578,7 @@ export class AuthService { throw new BadRequestException('该微信已绑定其他合伙人账号'); } - const updated = await this.prisma.partnerAccount.update({ + let updated = await this.prisma.partnerAccount.update({ where: { id: partnerAccountId }, data: { wxOpenId: session.openId, @@ -1555,6 +1586,12 @@ export class AuthService { lastLoginAt: new Date(), }, }); + const synced = await this.syncPartnerWechatProfile( + updated.id, + 'accessToken' in session ? session.accessToken : undefined, + session.openId, + ); + if (synced) updated = synced; const primary = await this.resolvePrimaryAccount(updated.id); this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform }); @@ -1593,6 +1630,12 @@ export class AuthService { lastLoginAt: new Date(), }, }); + const synced = await this.syncPartnerWechatProfile( + account.id, + 'accessToken' in session ? session.accessToken : undefined, + session.openId, + ); + if (synced) account = synced; const primary = await this.resolvePrimaryAccount(account.id); this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform }); diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 1cdf07c..1256233 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -267,6 +267,8 @@ export class PartnerMeController { primaryName: primary.name, companyName: primary.companyName ?? undefined, hasWechat: !!account.wxOpenId, + wxNickname: account.wxNickname ?? undefined, + wxAvatarUrl: account.wxAvatarUrl ?? undefined, }; } } From b36f2ea63a900a2b1724a426a071c6a67d37d4ef Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 07:56:43 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E5=9F=8E=E5=B8=82=E5=90=88=E4=BC=99?= =?UTF-8?q?=E4=BA=BAH5=E7=AB=AF=E7=BB=93=E7=AE=97=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/lib/api.ts | 5 + apps/admin-web/src/pages/DashboardPage.tsx | 42 ++++ apps/admin-web/src/pages/PartnerBillsPage.tsx | 109 ++++++++-- apps/h5-partner/src/pages/BillsPage.tsx | 197 +++++++++++++----- apps/h5-partner/src/pages/CenterPage.tsx | 16 +- apps/h5-partner/src/pages/SettlementPage.tsx | 17 +- apps/h5-partner/src/styles.css | 5 + packages/shared-types/src/partner-log.ts | 2 +- packages/shared-types/src/settlement.ts | 9 +- server/dukang-api/prisma/schema.prisma | 2 + .../hq-operation/hq-operation.constants.ts | 2 + .../modules/ops/admin-dashboard.service.ts | 6 +- .../settlement/settlement.controller.ts | 19 ++ .../modules/settlement/settlement.service.ts | 69 +++++- 14 files changed, 418 insertions(+), 82 deletions(-) diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 909870f..04fa6fa 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -79,6 +79,11 @@ export type DashboardStats = { partnersTotal: number; redeemToday: number; deliveriesTotal: number; + pendingPayouts?: number; + /** 合伙人已确认、待总部审核打款 */ + pendingBills?: number; + pendingPartnerDraftBills?: number; + openTickets?: number; ordersByStatus: Array<{ status: string; count: number }>; }; diff --git a/apps/admin-web/src/pages/DashboardPage.tsx b/apps/admin-web/src/pages/DashboardPage.tsx index 06b08ba..c9e6637 100644 --- a/apps/admin-web/src/pages/DashboardPage.tsx +++ b/apps/admin-web/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message, } from 'antd'; @@ -164,6 +165,47 @@ export default function DashboardPage() { + + +
+ 去处理} + > + 0 ? '#fa8c16' : undefined }} + /> + + 合伙人已确认申请,待总部通过或驳回 + {(stats?.pendingPartnerDraftBills ?? 0) > 0 + ? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)` + : ''} + + + + + 去处理} + > + 0 ? '#fa8c16' : undefined }} + /> + + + + + + + + + diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index d9cd155..8680957 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -4,6 +4,7 @@ import { Card, DatePicker, Form, + Input, Modal, Select, Space, @@ -29,6 +30,7 @@ type Row = { status: string; periodStart: string; periodEnd: string; + rejectReason?: string | null; partner?: { companyName?: string; phone?: string }; partnerAccount?: { companyName?: string; phone?: string }; }; @@ -36,15 +38,17 @@ type Row = { type PartnerOption = { id: string; companyName: string; phone?: string }; const STATUS_LABELS: Record = { - DRAFT: '草稿', - CONFIRMED: '已确认', + DRAFT: '待合伙人确认', + CONFIRMED: '待打款审核', PAID: '已打款', + REJECTED: '已驳回', }; const STATUS_COLORS: Record = { DRAFT: 'default', - CONFIRMED: 'blue', + CONFIRMED: 'orange', PAID: 'green', + REJECTED: 'red', }; export default function PartnerBillsPage() { @@ -66,6 +70,10 @@ export default function PartnerBillsPage() { 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); useEffect(() => { void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) @@ -95,7 +103,7 @@ export default function PartnerBillsPage() { async function confirmBill(id: string) { await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); - message.success('已确认'); + message.success('已代确认,进入待打款审核'); reload(); } @@ -104,10 +112,33 @@ export default function PartnerBillsPage() { method: 'POST', body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), }); - message.success('已标记打款'); + message.success('已通过并标记打款'); reload(); } + function openReject(row: Row) { + 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 exportExcel() { setExporting(true); try { @@ -163,24 +194,38 @@ export default function PartnerBillsPage() { { title: '状态', dataIndex: 'status', - width: 90, - render: (s) => {STATUS_LABELS[s] || s}, + width: 120, + render: (s, row) => ( + + {STATUS_LABELS[s] || s} + {s === 'REJECTED' && row.rejectReason ? ( + + {row.rejectReason} + + ) : null} + + ), }, { title: '操作', - width: 160, + width: 220, fixed: 'right', render: (_, row) => ( - + {row.status === 'DRAFT' && ( )} {row.status === 'CONFIRMED' && ( - + <> + + + )} ), @@ -194,7 +239,7 @@ export default function PartnerBillsPage() { 合伙人账单 - T+30 结算:按自然月汇总酒单佣金(酒单价 × 酒单佣金比例)与门店核销佣金(核销额 × 核销佣金比例) + T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由) @@ -242,7 +287,7 @@ export default function PartnerBillsPage() { setConfirmed(e.target.checked)} /> - 我已核对数据无误,同意根据此账单进行结算 - - - +
+ + +
+ )} ); diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index 58535ad..3e25753 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -53,12 +53,16 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag }, [isPrimary]); const finance = useMemo(() => { - const pending = bills.filter((b) => b.status === 'DRAFT'); + const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED'); const settled = bills.filter((b) => b.status === 'PAID'); const paidCount = settled.length; + const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length; return { pendingBillCount: pending.length, - pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + rejectedCount, + pendingTotal: bills + .filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED') + .reduce((s, b) => s + Number(b.totalAmount || 0), 0), settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), paidCount, @@ -180,9 +184,13 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag receipt_long
-

待确认账单

+

+ {finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'} +

- 您有 {finance.pendingBillCount} 笔账单待确认 + {finance.rejectedCount > 0 + ? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)` + : `您有 ${finance.pendingBillCount} 笔账单待确认`}

diff --git a/apps/h5-partner/src/pages/SettlementPage.tsx b/apps/h5-partner/src/pages/SettlementPage.tsx index 71b645c..f843c66 100644 --- a/apps/h5-partner/src/pages/SettlementPage.tsx +++ b/apps/h5-partner/src/pages/SettlementPage.tsx @@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader'; import type { PartnerBillDto } from '@dukang/shared-types'; import { isLoggedIn, request } from '../lib/api'; -type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing'; +type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); @@ -15,6 +15,7 @@ function billStatusLabel(status: string) { case 'DRAFT': return '待结算'; case 'CONFIRMED': return '审核中'; case 'PAID': return '已结算'; + case 'REJECTED': return '已驳回'; default: return status; } } @@ -24,6 +25,7 @@ function billStatusClass(status: string) { case 'DRAFT': return 'partner-settlement-status--pending'; case 'CONFIRMED': return 'partner-settlement-status--reviewing'; case 'PAID': return 'partner-settlement-status--settled'; + case 'REJECTED': return 'partner-settlement-status--rejected'; default: return ''; } } @@ -70,9 +72,10 @@ export default function SettlementPage() { const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; if (!matchMonth) return false; if (statusFilter === 'all') return true; - if (statusFilter === 'pending') return b.status === 'DRAFT'; + if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED'; if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; + if (statusFilter === 'rejected') return b.status === 'REJECTED'; return true; }); }, [bills, month, statusFilter]); @@ -93,8 +96,9 @@ export default function SettlementPage() { const statusTabs: Array<{ key: StatusFilter; label: string }> = [ { key: 'all', label: '全部' }, { key: 'pending', label: '待结算' }, - { key: 'settled', label: '已结算' }, { key: 'reviewing', label: '审核中' }, + { key: 'rejected', label: '已驳回' }, + { key: 'settled', label: '已结算' }, ]; return ( @@ -188,7 +192,12 @@ export default function SettlementPage() { 订单分佣 ¥{fmtMoney(Number(bill.orderCommission || 0))}

-

+ {bill.status === 'REJECTED' && bill.rejectReason ? ( +

+ 驳回:{bill.rejectReason} +

+ ) : null} +

¥{fmtMoney(Number(bill.totalAmount || 0))}

diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index b366bc0..de0138a 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -2985,6 +2985,11 @@ nav.app-tabbar .app-tabbar-label { color: var(--color-success-green); } +.partner-settlement-status--rejected { + background: rgba(166, 29, 36, 0.12); + color: var(--color-heritage-red); +} + .partner-settlement-item-meta { display: flex; align-items: center; diff --git a/packages/shared-types/src/partner-log.ts b/packages/shared-types/src/partner-log.ts index 900d660..b17fee7 100644 --- a/packages/shared-types/src/partner-log.ts +++ b/packages/shared-types/src/partner-log.ts @@ -25,7 +25,7 @@ export const PARTNER_LOG_EVENT_CATEGORIES: Record = { [HqOperationAction.PARTNER_BILL_GENERATE]: '生成合伙人账单', [HqOperationAction.PARTNER_BILL_CONFIRM]: '确认合伙人账单', [HqOperationAction.PARTNER_BILL_MARK_PAID]: '合伙人账单结算', + [HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请', [HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码', [HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销', [HqOperationAction.PROMO_CODE_CREATE]: '创建推广码', 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 95babe4..a1eb27a 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -22,6 +22,7 @@ export class AdminDashboardService { deliveriesTotal, pendingPayouts, pendingBills, + pendingPartnerDraftBills, openTickets, ] = await Promise.all([ this.prisma.user.count({ where: { status: 1, mergedIntoUserId: null } }), @@ -42,7 +43,8 @@ export class AdminDashboardService { 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: { in: ['DRAFT', 'CONFIRMED'] } } }), + this.prisma.partnerBill.count({ where: { status: 'CONFIRMED' } }), + this.prisma.partnerBill.count({ where: { status: 'DRAFT' } }), this.prisma.commonTicket.count({ where: { status: { in: ['PENDING', 'OPEN'] } } }), ]); @@ -57,7 +59,9 @@ export class AdminDashboardService { redeemToday, deliveriesTotal, pendingPayouts, + /** 合伙人已确认、待总部审核打款 */ pendingBills, + pendingPartnerDraftBills, openTickets, ordersByStatus: ordersByStatus.map((row) => ({ status: row.status, diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 1256233..4117757 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -25,6 +25,11 @@ export class SettlementController { bills(@CurrentUser() user: AuthUser) { return this.settlementService.listPartnerBills(user.actorId); } + + @Post('bills/:id/confirm') + confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id)); + } } @Controller('shop/payouts') @@ -193,6 +198,20 @@ export class AdminPartnerBillController { markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) { return this.settlementService.markPartnerBillPaid(BigInt(id), body); } + + @Post(':id/reject') + @HqOperation({ + action: HqOperationAction.PARTNER_BILL_REJECT, + refType: 'PARTNER_BILL', + refIdParam: 'id', + includeBody: true, + }) + reject(@Param('id') id: string, @Body() body: { reason?: string }) { + if (!body?.reason?.trim()) { + throw new BadRequestException('请填写驳回理由'); + } + return this.settlementService.rejectPartnerBill(BigInt(id), body.reason); + } } @Controller('admin/winery-bills') diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index ea8dae4..b32e329 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -529,11 +529,70 @@ export class SettlementService { async confirmPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认'); + if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { + throw new BadRequestException('仅待确认或已驳回账单可确认'); + } const updated = await this.prisma.partnerBill.update({ where: { id }, - data: { status: 'CONFIRMED', confirmedAt: new Date() }, + data: { + status: 'CONFIRMED', + confirmedAt: new Date(), + rejectReason: null, + }, + }); + + return serializeBigInt(updated); + } + + /** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED) */ + async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) { + const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } }); + if (!bill || bill.partnerAccountId !== primary.id) { + throw new NotFoundException('账单不存在'); + } + if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { + throw new BadRequestException('当前账单不可再次申请打款'); + } + + const updated = await this.prisma.partnerBill.update({ + where: { id: billId }, + data: { + status: 'CONFIRMED', + confirmedAt: new Date(), + rejectReason: null, + }, + }); + + this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { + partnerAccountId: primary.id, + eventName: 'partner_bill_confirm', + refType: 'PARTNER_BILL', + refId: billId, + extraJson: { billNo: bill.billNo }, + }); + + return serializeBigInt(updated); + } + + async rejectPartnerBill(id: bigint, reason: string) { + const rejectReason = reason.trim(); + if (!rejectReason) throw new BadRequestException('请填写驳回理由'); + if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字'); + + const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); + if (!bill) throw new NotFoundException('账单不存在'); + if (bill.status !== 'CONFIRMED') { + throw new BadRequestException('仅待打款审核的账单可驳回'); + } + + const updated = await this.prisma.partnerBill.update({ + where: { id }, + data: { + status: 'REJECTED', + rejectReason, + }, }); return serializeBigInt(updated); @@ -542,11 +601,11 @@ export class SettlementService { async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); - if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款'); + if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅待打款审核的账单可标记打款'); const updated = await this.prisma.partnerBill.update({ where: { id }, - data: { status: 'PAID', paidAt: new Date() }, + data: { status: 'PAID', paidAt: new Date(), rejectReason: null }, }); return serializeBigInt(updated); @@ -577,6 +636,7 @@ export class SettlementService { '状态', '确认时间', '打款时间', + '驳回理由', ].join(','); const rows = bills.map((b) => [ @@ -591,6 +651,7 @@ export class SettlementService { b.status, b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', + csvEscape(b.rejectReason ?? ''), ].join(','), ); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; From 608e40546816942d0dedd03eaaa6c151b056c8bd Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 08:04:53 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=9F=8E=E5=B8=82=E5=90=88=E4=BC=99?= =?UTF-8?q?=E4=BA=BA=E5=8E=BB=E6=8E=89=E8=AE=A2=E5=8D=95=E8=A1=A5=E5=8F=91?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=85=92=E7=9A=84=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=A6=82=E6=9E=9C=E6=B2=A1=E6=9C=89=E9=85=8D=E7=BD=AE=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=BA=94=E8=AF=A5=E6=98=AF=E7=9C=8B=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-partner/src/PartnerAppRoutes.tsx | 2 - .../src/contexts/PartnerSessionContext.tsx | 2 + apps/h5-partner/src/lib/api.ts | 4 ++ apps/h5-partner/src/lib/partnerAccess.ts | 9 ++++ apps/h5-partner/src/pages/HomePage.tsx | 31 ++++++----- apps/h5-partner/src/pages/OrderListPage.tsx | 54 +++++++++++++++++-- apps/h5-partner/src/styles.css | 13 ++++- packages/shared-types/src/partner.ts | 4 ++ .../city-scope/partner-city.service.ts | 12 +++-- .../settlement/settlement.controller.ts | 9 +++- .../src/modules/trade/trade.service.ts | 10 +++- 11 files changed, 124 insertions(+), 26 deletions(-) diff --git a/apps/h5-partner/src/PartnerAppRoutes.tsx b/apps/h5-partner/src/PartnerAppRoutes.tsx index 3c0f461..f7edfc6 100644 --- a/apps/h5-partner/src/PartnerAppRoutes.tsx +++ b/apps/h5-partner/src/PartnerAppRoutes.tsx @@ -13,7 +13,6 @@ import CenterPage from './pages/CenterPage'; import PartnerMePage from './pages/PartnerMePage'; import BillsPage from './pages/BillsPage'; import SettlementPage from './pages/SettlementPage'; -import ReshipPage from './pages/ReshipPage'; import WeeklyReportPage from './pages/WeeklyReportPage'; import ProxyOrderPage from './pages/ProxyOrderPage'; import StaffListPage from './pages/StaffListPage'; @@ -34,7 +33,6 @@ function PrimaryRoutes() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index 3478f57..197c14f 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -55,6 +55,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount { hasWechat: profile.hasWechat, wxNickname: profile.wxNickname, wxAvatarUrl: profile.wxAvatarUrl, + managedWarehouseId: profile.managedWarehouseId, + hasWarehouseAccess: profile.hasWarehouseAccess, }; } diff --git a/apps/h5-partner/src/lib/api.ts b/apps/h5-partner/src/lib/api.ts index f8258e7..2c7c8cb 100644 --- a/apps/h5-partner/src/lib/api.ts +++ b/apps/h5-partner/src/lib/api.ts @@ -36,6 +36,8 @@ export type PartnerSessionProfile = Pick< | 'hasWechat' | 'wxNickname' | 'wxAvatarUrl' + | 'managedWarehouseId' + | 'hasWarehouseAccess' > & { staffRole?: PartnerStaffRole; }; @@ -125,6 +127,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile { hasWechat: me.hasWechat, wxNickname: me.wxNickname, wxAvatarUrl: me.wxAvatarUrl, + managedWarehouseId: me.managedWarehouseId, + hasWarehouseAccess: me.hasWarehouseAccess, }; } diff --git a/apps/h5-partner/src/lib/partnerAccess.ts b/apps/h5-partner/src/lib/partnerAccess.ts index fd6c5cc..a8b32ca 100644 --- a/apps/h5-partner/src/lib/partnerAccess.ts +++ b/apps/h5-partner/src/lib/partnerAccess.ts @@ -35,6 +35,15 @@ export function getPartnerNavKind(account: PartnerMe | null | undefined): Partne return 'store_staff'; } +/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */ +export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean { + if (!account) return false; + if (account.hasWarehouseAccess === false) return false; + if (account.hasWarehouseAccess === true) return true; + // 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单 + return false; +} + export function partnerHomePath(account: PartnerMe | null | undefined): string { return '/'; } diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index 519d9b5..6e07efc 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import { isLoggedIn, request } from '../lib/api'; import { fetchPartnerLeaderboard } from '../lib/leaderboard'; -import { getPartnerNavKind } from '../lib/partnerAccess'; +import { getPartnerNavKind, hasWarehouseAccess } from '../lib/partnerAccess'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; function fmtMoney(n: number) { @@ -135,6 +135,7 @@ export default function HomePage() { const navKind = getPartnerNavKind(account); const isPrimary = navKind === 'primary'; const isWarehouse = navKind === 'warehouse_staff'; + const warehouseOk = hasWarehouseAccess(account); const [dash, setDash] = useState | null>(null); const [stores, setStores] = useState>>([]); @@ -147,10 +148,12 @@ export default function HomePage() { useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - if (isWarehouse || isPrimary) { + if ((isWarehouse || isPrimary) && warehouseOk) { request<{ list: Array> }>('PARTNER_H5', '/partner/orders') .then((data) => setOrders(Array.isArray(data.list) ? data.list : [])) .catch(() => setOrders([])); + } else { + setOrders([]); } if (!isWarehouse) { request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); @@ -165,7 +168,7 @@ export default function HomePage() { } else { request>('PARTNER_H5', '/partner/dashboard').then(setDash).catch(() => {}); } - }, [navigate, isWarehouse, isPrimary]); + }, [navigate, isWarehouse, isPrimary, warehouseOk]); const storeCount = Number(dash?.storeCount || stores.length || 0); const orderStats = useMemo(() => summarizeOrders(orders), [orders]); @@ -190,7 +193,6 @@ export default function HomePage() { const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0); const revenue = orderCount * 128.45; const profit = revenue * 0.25; - const pendingShipBadge = orderStats.pendingShip; return (
@@ -224,13 +226,6 @@ export default function HomePage() {
录入新店 - -
- assignment_return - {pendingShipBadge > 0 && {pendingShipBadge}} -
- 补发处理 -
account_balance @@ -252,7 +247,7 @@ export default function HomePage() {

订单管理

)} - {(isPrimary || isWarehouse) && ( + {(isPrimary || isWarehouse) && warehouseOk && ( )} + {(isPrimary || isWarehouse) && !warehouseOk && ( +
+
+ warehouse +

未配置仓库管理权限

+

+ 购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。 +

+
+
+ )} + {!isWarehouse && ( <>
diff --git a/apps/h5-partner/src/pages/OrderListPage.tsx b/apps/h5-partner/src/pages/OrderListPage.tsx index c9e683a..bd7ce97 100644 --- a/apps/h5-partner/src/pages/OrderListPage.tsx +++ b/apps/h5-partner/src/pages/OrderListPage.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import { isLoggedIn, request } from '../lib/api'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; +import { hasWarehouseAccess } from '../lib/partnerAccess'; type DateFilter = 'today' | '7d' | '30d'; type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL'; @@ -34,15 +36,24 @@ type OrderListPageProps = { tabRoot?: boolean; }; +type OrdersResponse = { + list: Array>; + hasWarehouseAccess?: boolean; + message?: string; +}; + export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { const navigate = useNavigate(); - const [data, setData] = useState<{ list: Array> }>({ list: [] }); + const { account } = usePartnerSession(); + const warehouseOk = hasWarehouseAccess(account); + const [data, setData] = useState({ list: [] }); const [tab, setTab] = useState<'orders' | 'coupons'>('orders'); const [dateFilter, setDateFilter] = useState('today'); const [statusFilter, setStatusFilter] = useState('ALL'); const [shippingId, setShippingId] = useState(null); const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' }); const [shipModalId, setShipModalId] = useState(null); + const [loaded, setLoaded] = useState(false); useEffect(() => { document.title = '订单管理'; @@ -50,8 +61,22 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - request<{ list: Array> }>('PARTNER_H5', '/partner/orders').then(setData); - }, [navigate]); + if (!warehouseOk) { + setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' }); + setLoaded(true); + return; + } + request('PARTNER_H5', '/partner/orders') + .then((res) => { + setData({ + list: Array.isArray(res.list) ? res.list : [], + hasWarehouseAccess: res.hasWarehouseAccess, + message: res.message, + }); + }) + .catch(() => setData({ list: [] })) + .finally(() => setLoaded(true)); + }, [navigate, warehouseOk]); const filtered = useMemo(() => data.list.filter((o) => { if (statusFilter === 'ALL') return true; @@ -83,8 +108,12 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { body: JSON.stringify(shipForm), }); setShipModalId(null); - const next = await request<{ list: Array> }>('PARTNER_H5', '/partner/orders'); - setData(next); + const next = await request('PARTNER_H5', '/partner/orders'); + setData({ + list: Array.isArray(next.list) ? next.list : [], + hasWarehouseAccess: next.hasWarehouseAccess, + message: next.message, + }); } catch (err) { window.alert(err instanceof Error ? err.message : '发货失败'); } finally { @@ -97,6 +126,21 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) { return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP'; } + if (loaded && !warehouseOk) { + return ( +
+ {!tabRoot && navigate('/')} />} +
+ warehouse +

未配置仓库管理权限

+

+ 购酒订单由总部履约,不会推送到本账号。如需管仓发货,请联系总部配置仓库。 +

+
+
+ ); + } + return (
{!tabRoot && navigate('/')} />} diff --git a/apps/h5-partner/src/styles.css b/apps/h5-partner/src/styles.css index de0138a..34d0b9c 100644 --- a/apps/h5-partner/src/styles.css +++ b/apps/h5-partner/src/styles.css @@ -3278,8 +3278,17 @@ body { border-top: 1px dashed rgba(166, 29, 36, 0.15); } -.partner-home--flush-top .partner-home-body { - padding-top: 20px; +.partner-warehouse-denied { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 20px 12px; +} + +.partner-warehouse-denied .material-symbols-outlined { + font-size: 36px; + font-variation-settings: 'FILL' 0; } .partner-home--flush-top.partner-store-page, diff --git a/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts index 30cc540..60a985e 100644 --- a/packages/shared-types/src/partner.ts +++ b/packages/shared-types/src/partner.ts @@ -17,6 +17,10 @@ export interface PartnerMe { /** 子账号联系主账号用 */ primaryPhone?: string; primaryName?: string; + /** 主账号绑定的管仓 ID(有则本城酒单推送至该仓) */ + managedWarehouseId?: string | null; + /** 是否已配置仓库管理(无则不展示/不推送酒订单) */ + hasWarehouseAccess?: boolean; } export interface UpdatePartnerMeRequest { diff --git a/server/dukang-api/src/modules/city-scope/partner-city.service.ts b/server/dukang-api/src/modules/city-scope/partner-city.service.ts index 461b6ca..69e94e4 100644 --- a/server/dukang-api/src/modules/city-scope/partner-city.service.ts +++ b/server/dukang-api/src/modules/city-scope/partner-city.service.ts @@ -103,10 +103,16 @@ export class PartnerCityService { if (!primary.cityId) return { id: -1n }; const warehouseIds = await this.resolveManagedWarehouseIds(primary.id); - if (warehouseIds.length > 0) { - return { fulfillmentWarehouseId: { in: warehouseIds } }; + // 未配置管仓:不推送/不展示酒订单(同城无仓由总部履约) + if (warehouseIds.length === 0) { + return { id: -1n }; } - return { cityId: primary.cityId }; + return { fulfillmentWarehouseId: { in: warehouseIds } }; + } + + async hasManagedWarehouse(partnerAccountId: bigint): Promise { + const ids = await this.resolveManagedWarehouseIds(partnerAccountId); + return ids.length > 0; } /** 合伙人可管仓库:主账号 managedWarehouseId + 绑定为管仓合伙人的仓 */ diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 4117757..011d715 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -9,6 +9,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { PrismaService } from '../../common/prisma/prisma.module'; +import { PartnerCityService } from '../city-scope/partner-city.service'; class UpdatePartnerMeDto { @IsOptional() @@ -243,7 +244,10 @@ export class AdminWineryBillController { @Controller('partner/me') @UseGuards(JwtAuthGuard) export class PartnerMeController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly partnerCityService: PartnerCityService, + ) {} @Get() async me(@CurrentUser() user: AuthUser) { @@ -274,6 +278,7 @@ export class PartnerMeController { where: { id: account.parentAccountId }, }); } + const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id); return { id: account.id.toString(), name: account.name, @@ -288,6 +293,8 @@ export class PartnerMeController { hasWechat: !!account.wxOpenId, wxNickname: account.wxNickname ?? undefined, wxAvatarUrl: account.wxAvatarUrl ?? undefined, + managedWarehouseId: primary.managedWarehouseId?.toString() ?? null, + hasWarehouseAccess, }; } } diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 31be90d..ffcce37 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -509,6 +509,7 @@ export class TradeService { async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); + const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id); const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const [list, total] = await Promise.all([ this.prisma.order.findMany({ @@ -520,7 +521,14 @@ export class TradeService { }), this.prisma.order.count({ where }), ]); - return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize }; + return { + list: serializeBigInt(list.map(mapOrderCompat)), + total, + page, + pageSize, + hasWarehouseAccess, + message: hasWarehouseAccess ? undefined : '未配置仓库管理权限,购酒订单由总部履约', + }; } async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) { From 57172d54d56af638ba940b636fbbab4aac862d20 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 08:20:31 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E5=90=88=E4=BC=99=E4=BA=BA=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/h5-partner/src/pages/CenterPage.tsx | 103 +++++++---------------- apps/h5-partner/src/styles.css | 14 +++ 2 files changed, 45 insertions(+), 72 deletions(-) diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index 3e25753..ca63791 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -28,8 +28,6 @@ function fmtMoney(n: number) { export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) { const { account, logout, refresh, applySession } = usePartnerSession(); const isPrimary = variant === 'primary' && isPrimaryAccount(account); - const [name, setName] = useState(account?.name ?? ''); - const [saving, setSaving] = useState(false); const [authorizing, setAuthorizing] = useState(false); const [bills, setBills] = useState([]); const [staffCount, setStaffCount] = useState(0); @@ -38,10 +36,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag document.title = isPrimary ? '合伙人中心' : '个人中心'; }, [isPrimary]); - useEffect(() => { - setName(account?.name ?? ''); - }, [account?.name]); - useEffect(() => { if (!isPrimary) return; void request('PARTNER_H5', '/partner/settlement/bills') @@ -69,25 +63,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag }; }, [bills]); - async function handleSave() { - const trimmed = name.trim(); - if (!trimmed) { - toastError('请输入姓名'); - return; - } - if (trimmed === account?.name) return; - setSaving(true); - try { - await request('PARTNER_H5', '/partner/me', { - method: 'PUT', - body: JSON.stringify({ name: trimmed }), - }); - await refresh(); - } finally { - setSaving(false); - } - } - function handleContactPartner() { if (isPrimary) { if (!contactSupport()) toastError('暂无客服电话'); @@ -255,59 +230,43 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag chevron_right
+
)} {!isPrimary && ( - <> -
-

个人信息设置

-
- - setName(e.target.value)} - placeholder="请输入姓名" - maxLength={32} - /> - - -

手机号由管理员维护,如需修改请联系总部

- -
-
- -
-
- - -
-
- +
+
+ + +
+
)} + + + {canShip(row) && ( + + )} + ), }, ]; @@ -255,6 +393,16 @@ export default function OrdersPage() { ({ value: c.id, label: c.name }))} + /> + @@ -271,7 +419,7 @@ export default function OrdersPage() { loading={loading} columns={columns} dataSource={data?.items ?? []} - scroll={{ x: 1200 }} + scroll={{ x: 1500 }} rowSelection={{ selectedRowKeys, onChange: (keys) => setSelectedRowKeys(keys as string[]), @@ -295,6 +443,11 @@ export default function OrdersPage() { onClose={() => setDrawerOpen(false)} extra={detail && ( + {canShip(detail) && ( + + )} 调试改状态
String(i)} + pagination={false} + dataSource={orderProductRows(detail)} + locale={{ emptyText: '无商品信息' }} + columns={[ + { + title: '商品', + dataIndex: 'productName', + render: (name: string, row) => ( + + {row.productImage ? ( + + ) : null} + + {name || '—'} + {row.productSpec ? ( + + {row.productSpec} + + ) : null} + + + ), + }, + { + title: '单价', + dataIndex: 'unitPrice', + width: 90, + render: (v: number) => `¥${v}`, + }, + { + title: '数量', + dataIndex: 'quantity', + width: 70, + }, + ]} + /> + {detail.receiverName} {detail.receiverPhone} {detail.receiverAddress} @@ -363,99 +568,6 @@ export default function OrdersPage() { )} - {['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && ( -
- {(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && ( - <> - 总部快递填单 - - 适用于跨城订单或同城无仓订单 - -
- - - - - - - - - - - - - )} - - {detail.fulfillmentWarehouseId && ( - <> - - 仓配小飞侠重试 - - - 仓配订单通常支付后自动推单;失败时可手动重试 - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - ), - }]} - /> - - - - )} -
- )} - {detail.statusLogs && detail.statusLogs.length > 0 && ( <> 状态流转 @@ -476,6 +588,134 @@ export default function OrdersPage() { )} + setShipModalOpen(false)} + width={560} + destroyOnClose + footer={ + + + {shipMode === 'WAREHOUSE' ? ( + + ) : ( + + )} + + } + > + {shipTarget && ( + <> + + {shipTarget.city?.name || '—'} + + {shipTarget.receiverName} {shipTarget.receiverPhone} + + + {shipTarget.productName || '—'} + {shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''} + + + + setShipMode(e.target.value as ShipMode)} + optionType="button" + buttonStyle="solid" + style={{ marginBottom: 16 }} + options={[ + { value: 'WAREHOUSE', label: '仓库配送(小飞侠)' }, + { value: 'EXPRESS', label: '填写快递单号' }, + ]} + /> + + {shipMode === 'WAREHOUSE' ? ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }]} + /> + + ) : ( +
+ + 与仓库端手动发货一致:填写快递公司与运单号即可完成发货 + + + + + + + + + + + + )} + + )} +
+ row.city?.name || '—', + }, + { + title: '商品', + width: 160, + ellipsis: true, + render: (_, row) => + row.productName + ? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}` + : '—', + }, { title: '状态', dataIndex: 'status', width: 100, render: (s) => ( - {ORDER_STATUS_LABELS[s] || s} - ), + {ORDER_STATUS_LABELS[s] || s} + ), }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, diff --git a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts index df0a52c..6915a83 100644 --- a/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts +++ b/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts @@ -178,11 +178,7 @@ export class FulfillmentService { }); if (!order) throw new NotFoundException('订单不存在'); - const isHqQueue = - order.deliveryType === 'CROSS_CITY' || - (order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId); - - if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货'); + // HQ 可对任意待发货单填快递单号(含仓配单手动填单) if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { throw new BadRequestException('当前订单状态不可发货'); } diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 6fa57d0..724b34e 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -30,6 +30,7 @@ export class AdminOrdersService { if (query.orderNo) where.orderNo = { contains: query.orderNo }; if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals']; if (query.userId) where.userId = BigInt(query.userId); + if (query.cityId) where.cityId = BigInt(query.cityId); if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.createdFrom || query.createdTo) { where.createdAt = {}; @@ -46,6 +47,8 @@ export class AdminOrdersService { include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } }, + city: { select: { id: true, name: true, code: true } }, + fulfillmentWarehouse: { select: { id: true, name: true } }, }, }), this.prisma.order.count({ where }), @@ -75,6 +78,18 @@ export class AdminOrdersService { city: { select: { id: true, name: true, code: true } }, product: { select: { id: true, name: true, skuCode: true, barcode69: true } }, imageResource: { select: { id: true, url: true } }, + fulfillmentWarehouse: { + select: { + id: true, + name: true, + contactName: true, + contactPhone: true, + address: true, + lng: true, + lat: true, + fulfillmentMode: true, + }, + }, }, }); if (!order) throw new NotFoundException('订单不存在'); @@ -101,7 +116,7 @@ export class AdminOrdersService { throw new BadRequestException('暂仅支持小飞侠配送'); } - const order = await this.prisma.order.findUnique({ + let order = await this.prisma.order.findUnique({ where: { id }, include: { delivery: true, @@ -117,7 +132,30 @@ export class AdminOrdersService { throw new BadRequestException('该订单已有运单号,请勿重复发货'); } + if (dto.warehouseId) { + const warehouseId = BigInt(dto.warehouseId); + const warehouseRow = await this.prisma.cityWarehouse.findFirst({ + where: { id: warehouseId, cityId: order.cityId, status: 'ACTIVE' }, + }); + if (!warehouseRow) { + throw new BadRequestException('仓库不存在或不属于该订单开城城市'); + } + if (order.fulfillmentWarehouseId !== warehouseId) { + await this.prisma.order.update({ + where: { id }, + data: { fulfillmentWarehouseId: warehouseId }, + }); + order = await this.prisma.order.findUniqueOrThrow({ + where: { id }, + include: { delivery: true, fulfillmentWarehouse: true }, + }); + } + } + const warehouse = order.fulfillmentWarehouse; + if (!warehouse) { + throw new BadRequestException('请先选择履约仓库'); + } const providerId = order.delivery?.fulfillmentProviderId ?? warehouse?.fulfillmentProviderId ?? 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 a1ede94..e8fe9c5 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 @@ -711,6 +711,11 @@ export class AdminShipOrderDto { @IsIn(['XFX']) provider: string; + /** 可选:指定/改派履约仓后再推仓配 */ + @IsOptional() + @IsString() + warehouseId?: string; + @IsOptional() @IsString() fromName?: string; 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 b9e9021..d1a0199 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 @@ -52,6 +52,10 @@ export class AdminOrdersQueryDto extends PaginationQueryDto { @IsString() userId?: string; + @IsOptional() + @IsString() + cityId?: string; + @IsOptional() @IsString() receiverPhone?: string; From c204fb0a923a54700a5b34adcb3b2ddc461c91f5 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 08:50:18 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=E4=BB=93=E5=BA=93=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=85=8D=E9=80=81=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/CityWarehousesPage.tsx | 149 ++++++++++-------- packages/shared-types/src/enums.ts | 4 +- server/dukang-api/prisma/seed-v31.ts | 6 +- 3 files changed, 85 insertions(+), 74 deletions(-) diff --git a/apps/admin-web/src/pages/CityWarehousesPage.tsx b/apps/admin-web/src/pages/CityWarehousesPage.tsx index 095f2a7..874b0d8 100644 --- a/apps/admin-web/src/pages/CityWarehousesPage.tsx +++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx @@ -11,6 +11,7 @@ import { Popconfirm, Select, Space, + Switch, Table, Tag, Typography, @@ -59,29 +60,54 @@ type PartnerOption = { id: string; companyName: string }; const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label })); -const FULFILLMENT_MODE_OPTIONS = Object.entries(WAREHOUSE_FULFILLMENT_MODE_LABELS).map(([value, label]) => ({ - value, - label, -})); - function FulfillmentFields({ mode, providerOptions, + onModeChange, }: { mode: WarehouseFulfillmentMode; providerOptions: FulfillmentProviderDto[]; + onModeChange: (mode: WarehouseFulfillmentMode) => void; }) { + const autoShip = mode === WarehouseFulfillmentMode.API_AUTO; return ( <> - {mode === WarehouseFulfillmentMode.API_AUTO && ( - + ({ checked: v === WarehouseFulfillmentMode.API_AUTO })} + getValueFromEvent={(checked: boolean) => + checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL + } + > + { + onModeChange( + checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL, + ); + }} + /> + + {autoShip && ( + @@ -183,18 +209,17 @@ export default function CityWarehousesPage() { } function onManagerTypeChange( - type: WarehouseManagerType, - form: typeof createForm | typeof editForm, - setType: (v: WarehouseManagerType) => void, + v: WarehouseManagerType, + form: typeof createForm, + setType: (t: WarehouseManagerType) => void, ) { - setType(type); - if (type !== WarehouseManagerType.PARTNER) { + setType(v); + if (v !== WarehouseManagerType.PARTNER) { form.setFieldValue('partnerAccountId', undefined); } } const columns: ColumnsType = [ - { title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 }, { title: '城市', width: 100, @@ -204,17 +229,17 @@ export default function CityWarehousesPage() { ), }, - { title: '地址', dataIndex: 'address', ellipsis: true }, + { title: '仓库', dataIndex: 'name', width: 140, ellipsis: true }, + { title: '地址', dataIndex: 'address', width: 180, ellipsis: true }, { title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '电话', dataIndex: 'contactPhone', width: 120 }, { - title: '管仓类型', - dataIndex: 'managerType', - width: 100, - render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v, + title: '管仓', + width: 90, + render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType, }, { - title: '管仓合伙人', + title: '合伙人', dataIndex: 'partnerCompanyName', width: 120, ellipsis: true, @@ -226,12 +251,14 @@ export default function CityWarehousesPage() { ), }, { - title: '履约', - width: 120, + title: '自动发货', + width: 130, render: (_, row) => - row.fulfillmentMode === 'API_AUTO' - ? row.fulfillmentProviderName || 'API' - : WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL], + row.fulfillmentMode === 'API_AUTO' ? ( + {row.fulfillmentProviderName || '自动'} + ) : ( + {WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]} + ), }, { title: '状态', @@ -274,21 +301,23 @@ export default function CityWarehousesPage() { return (
- - 仓库管理 - + 仓库 @@ -296,7 +325,7 @@ export default function CityWarehousesPage() { type="info" showIcon style={{ marginBottom: 16 }} - message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。" + message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。" />
- + + + +
`共 ${t} 条`, onChange: (p, ps) => { setPage(p); setPageSize(ps); @@ -366,7 +381,7 @@ export default function CityWarehousesPage() { /> setCreateOpen(false)} @@ -425,13 +440,11 @@ export default function CityWarehousesPage() { setCreateFulfillmentMode(v)} - /> - - + @@ -490,13 +503,11 @@ export default function CityWarehousesPage() { setEditFulfillmentMode(v)} - /> - - + diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 35f1f81..96d0b61 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -194,8 +194,8 @@ export const FULFILLMENT_PROVIDER_STATUS_LABELS: Record = { - [WarehouseFulfillmentMode.API_AUTO]: 'API 自动推单', - [WarehouseFulfillmentMode.MANUAL]: '自管手工填单', + [WarehouseFulfillmentMode.API_AUTO]: '自动发货', + [WarehouseFulfillmentMode.MANUAL]: '关闭(手工填单)', }; export enum AccountStatus { diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index e88b2eb..026dd37 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -177,7 +177,7 @@ async function main() { - const xfxProvider = await prisma.fulfillmentProvider.create({ + await prisma.fulfillmentProvider.create({ data: { code: 'XFX', name: '小飞侠', @@ -218,9 +218,9 @@ async function main() { status: 'ACTIVE', - fulfillmentMode: 'API_AUTO', + fulfillmentMode: 'MANUAL', - fulfillmentProviderId: xfxProvider.id, + fulfillmentProviderId: null, lng: 113.665, From e9537ce052540038fc1b00e57b8f80e0a449a131 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 17 Jul 2026 08:57:19 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E9=85=8D=E9=80=81=E9=80=BB=E8=BE=91=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/pages/OrdersPage.tsx | 112 ++++++++++++------ .../src/modules/ops/admin-orders.service.ts | 4 +- 2 files changed, 81 insertions(+), 35 deletions(-) diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 505c314..e4958e3 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -45,6 +45,8 @@ type CityOption = { id: string; name: string; code: string }; type WarehouseOption = { id: string; name: string; + cityId?: string; + cityName?: string; status?: string; contactName: string; contactPhone: string; @@ -192,16 +194,13 @@ export default function OrdersPage() { }); } - async function loadWarehousesForOrder(order: OrderDetail) { - const cityId = order.city?.id || order.cityId; - if (!cityId) { - setWarehouses([]); - return [] as WarehouseOption[]; - } - const rows = await request(`/admin/cities/${cityId}/warehouses`).catch(() => []); - const active = rows.filter((w) => !w.status || w.status === 'ACTIVE'); - setWarehouses(active); - return active; + async function loadWarehouses() { + const res = await request>( + `/admin/city-warehouses?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&status=ACTIVE`, + ).catch(() => null); + const rows = (res?.items ?? []).filter((w) => !w.status || w.status === 'ACTIVE'); + setWarehouses(rows); + return rows; } async function openDetail(id: string) { @@ -214,16 +213,25 @@ export default function OrdersPage() { const res = await request(`/admin/orders/${id}`); setShipTarget(res); setDetail(res); - logisticsForm.resetFields(); - const rows = await loadWarehousesForOrder(res); - const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id || rows[0]?.id; - const preferred = rows.find((w) => w.id === preferredWarehouseId) || rows[0]; + logisticsForm.setFieldsValue({ + logisticsCompany: '', + trackingNo: '', + manualQueryUrl: '', + }); + const rows = await loadWarehouses(); + const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id; + const preferred = + rows.find((w) => w.id === preferredWarehouseId) || + rows.find((w) => w.cityId === (res.city?.id || res.cityId)) || + rows[0]; const defaults = preferred ? warehouseToDefaults(preferred, shipDefaults) : (shipDefaults ?? await request('/admin/orders/ship-defaults').catch(() => null)); if (defaults) { setShipDefaults(defaults); applyShipDefaults(defaults, preferred?.id); + } else { + shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' }); } setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS'); setShipModalOpen(true); @@ -242,9 +250,21 @@ export default function OrdersPage() { try { const res = await request(`/admin/orders/${shipTarget.id}/ship`, { method: 'POST', - body: JSON.stringify(values), + body: JSON.stringify({ + provider: values.provider || 'XFX', + warehouseId: values.warehouseId, + weight: values.weight, + payMode: values.payMode, + remark: values.remark, + fromName: values.fromName, + fromMobile: values.fromMobile, + fromAddress: values.fromAddress, + fromAddressDetail: values.fromAddressDetail, + fromLng: values.fromLng, + fromLat: values.fromLat, + }), }); - message.success('仓配发货成功'); + message.success('选仓发货成功'); setShipTarget(res); setDetail(res); setShipModalOpen(false); @@ -258,12 +278,22 @@ export default function OrdersPage() { async function submitLogisticsShip() { if (!shipTarget) return; - const values = await logisticsForm.validateFields(); + const values = await logisticsForm.validateFields(['logisticsCompany', 'trackingNo', 'manualQueryUrl']); + const logisticsCompany = String(values.logisticsCompany ?? '').trim(); + const trackingNo = String(values.trackingNo ?? '').trim(); + if (!logisticsCompany || !trackingNo) { + message.error('请填写快递公司与运单号'); + return; + } setLogisticsShipping(true); try { const res = await request(`/admin/orders/${shipTarget.id}/logistics-ship`, { method: 'POST', - body: JSON.stringify(values), + body: JSON.stringify({ + logisticsCompany, + trackingNo, + manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined, + }), }); message.success('快递单已录入'); setShipTarget(res); @@ -593,13 +623,14 @@ export default function OrdersPage() { open={shipModalOpen} onCancel={() => setShipModalOpen(false)} width={560} - destroyOnClose + destroyOnClose={false} + forceRender footer={ {shipMode === 'WAREHOUSE' ? ( ) : (