From 13869d345f04ef2f65378d01466aa1af1d9e80d9 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 2 Nov 2026 14:25:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(worker-platform):=20=E5=95=86=E5=93=81?= =?UTF-8?q?=E5=90=8D=E6=8C=89=E6=9D=A5=E6=BA=90=E8=BF=BD=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=90=8E=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增来源后缀配置(来源下拉、启停、备注,同一时间仅启用一个) - 管理端列表/详情与打手端列表/详情/大厅展示「商品名(后缀)」 - 打手端 DTO 透出 sourceProvider,仅展示,不改写商品名 - 新增来源选项接口与前后端单测 --- apps/backend/src/config/app-config-keys.ts | 2 + .../worker-platform/work-order-query-repo.ts | 18 ++ .../routes/admin/worker-platform/config.ts | 41 +++ apps/backend/src/routes/worker/orders.ts | 12 + .../admin-worker-config-service.ts | 12 + .../src/services/worker-platform/index.ts | 1 + .../work-order-display-mappers.ts | 11 + ...worker-product-name-suffix-service.test.ts | 143 +++++++++++ .../worker-product-name-suffix-service.ts | 145 +++++++++++ .../pages/admin/panels/AnnouncementPanel.tsx | 239 +++++++++++++++++- .../admin/panels/WorkOrderTableCells.tsx | 8 +- .../pages/admin/panels/WorkOrdersPanel.tsx | 25 +- .../src/pages/worker/WorkerHallPage.tsx | 63 ++++- .../pages/worker/WorkerOrderDetailDrawer.tsx | 16 +- .../pages/worker/WorkerOrderMobileList.tsx | 14 +- .../pages/worker/WorkerOrderTableColumns.tsx | 82 +++--- .../src/pages/worker/WorkerOrdersPage.tsx | 18 +- .../src/services/admin/worker-platform.ts | 17 ++ apps/frontend/src/services/worker.ts | 7 + apps/frontend/src/types/worker-platform.ts | 22 ++ .../src/utils/product-name-source-suffix.ts | 69 +++++ .../tests/product-name-source-suffix.test.ts | 92 +++++++ 22 files changed, 1008 insertions(+), 49 deletions(-) create mode 100644 apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts create mode 100644 apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts create mode 100644 apps/frontend/src/utils/product-name-source-suffix.ts create mode 100644 apps/frontend/tests/product-name-source-suffix.test.ts diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 9be4b937..27dc02eb 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -7,6 +7,8 @@ export const APP_CONFIG_KEYS = { workerProductMatch: 'worker_product_match', workerAnnouncement: 'worker_announcement', workerHall: 'worker_hall', + /** 商品名来源后缀:按 material.source.provider 在商品名后追加(文字),仅展示用 */ + workerProductNameSuffix: 'worker_product_name_suffix', workerRiskControl: 'worker_risk_control', workerMockShops: 'worker_mock_shops', kuaishouIndustrySource: 'kuaishou_industry_source', diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 177da82c..afc592eb 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -1689,6 +1689,24 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh ` } +/** + * 后台「商品名来源后缀」下拉选项:取工单资料里的来源标识。 + * 去重、去空、限制数量,避免脏数据把下拉撑爆。 + */ +export async function listWorkOrderSourceProviders(limit = 200): Promise { + const result = await query<{ provider: string }>( + ` + SELECT DISTINCT NULLIF(BTRIM(material_json->'source'->>'provider'), '') AS provider + FROM work_orders + WHERE NULLIF(BTRIM(material_json->'source'->>'provider'), '') IS NOT NULL + ORDER BY provider ASC + LIMIT $1 + `, + [limit], + ) + return result.rows.map((row) => row.provider).filter(Boolean) +} + /** * 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 * 候选集与列表接口同源(FIFO 窗口 + 全部置顶单),所以置顶单即使排在窗口外也能正常抢单/拼单。 diff --git a/apps/backend/src/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index fdacc2c2..0020091f 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -13,6 +13,7 @@ import { getAdminWorkerPlatformNotificationConfig, getAdminWorkerPlatformSummary, getAdminWorkerProductMatchConfig, + getAdminWorkerProductNameSuffixConfig, listAdminKuaishouMatchSources, listAdminWorkCategories, listAdminWorkProductMatchLogs, @@ -20,6 +21,7 @@ import { listAdminWorkProductRuleMappings, listAdminWorkProductRules, listAdminWorkerLevels, + listWorkerProductNameSuffixSourceOptions, saveAdminWorkCategory, saveAdminWorkProductRule, saveAdminWorkProductRuleMapping, @@ -29,6 +31,7 @@ import { saveAdminWorkerLevel, saveAdminWorkerPlatformNotificationConfig, saveAdminWorkerProductMatchConfig, + saveAdminWorkerProductNameSuffixConfig, syncAdminWorkProductRulePrice, testAdminKuaishouProductMatch, testAdminProductMatchByOrderItem, @@ -309,6 +312,44 @@ router.post( }), ) +router.get( + '/worker-platform/product-name-suffix', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => getAdminWorkerProductNameSuffixConfig(), { + successMessage: 'ok', + errorMessage: '读取商品名来源后缀配置失败', + scope: '[admin/worker-platform/product-name-suffix]', + }), +) + +// 下拉选项来自工单资料里的来源标识,低频配置,给浏览器 5 分钟缓存。 +router.get( + '/worker-platform/product-name-suffix/sources', + requireAdminRoles(['admin', 'operator', 'support']), + setHttpCache(300), + createJsonHandler(() => listWorkerProductNameSuffixSourceOptions(), { + successMessage: 'ok', + errorMessage: '读取工单来源选项失败', + scope: '[admin/worker-platform/product-name-suffix/sources]', + }), +) + +router.post( + '/worker-platform/product-name-suffix', + requireAdminRoles(['admin']), + createJsonHandler((req) => saveAdminWorkerProductNameSuffixConfig(req.body || {}), { + successMessage: '商品名来源后缀已保存', + errorMessage: '保存商品名来源后缀失败', + scope: '[admin/worker-platform/product-name-suffix]', + audit: (_req, data) => ({ + action: 'worker_product_name_suffix_saved', + targetType: 'worker_product_name_suffix', + targetId: 'worker_product_name_suffix', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + router.post( '/worker-platform/product-match-config', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/routes/worker/orders.ts b/apps/backend/src/routes/worker/orders.ts index 7a46de55..cc1db343 100644 --- a/apps/backend/src/routes/worker/orders.ts +++ b/apps/backend/src/routes/worker/orders.ts @@ -8,6 +8,7 @@ import { getWorkerMyOrderEvents, getWorkerMyOrderOverview, getWorkerOrderFeedbackSummary, + getWorkerProductNameSuffixDisplayConfig, listWorkerMyOrders, markWorkerOrderRead, remindWorkerOrderAcceptance, @@ -90,6 +91,17 @@ router.get( }), ) +// 商品名来源后缀映射:只下发已启用配置,打手端列表/详情/大厅统一按来源追加(文字),只读。 +router.get( + '/product-name-suffix', + requireActiveWorker, + createRouteHandler(() => getWorkerProductNameSuffixDisplayConfig(), { + successMessage: 'ok', + errorMessage: '读取商品名来源后缀配置失败', + scope: '[worker/product-name-suffix]', + }), +) + router.get( '/orders/feedbacks', requireActiveWorker, diff --git a/apps/backend/src/services/worker-platform/admin-worker-config-service.ts b/apps/backend/src/services/worker-platform/admin-worker-config-service.ts index df870d1d..3cf996f8 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-config-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-config-service.ts @@ -12,6 +12,10 @@ import { getWorkerProductMatchConfig, saveWorkerProductMatchConfig, } from './worker-product-match-config-service.js' +import { + getWorkerProductNameSuffixConfig, + saveWorkerProductNameSuffixConfig, +} from './worker-product-name-suffix-service.js' export async function getAdminWorkerPlatformNotificationConfig() { return getWorkerPlatformNotificationConfig() @@ -37,6 +41,14 @@ export async function saveAdminWorkerProductMatchConfig(payload: JsonObject = {} return saveWorkerProductMatchConfig(payload) } +export async function getAdminWorkerProductNameSuffixConfig() { + return getWorkerProductNameSuffixConfig() +} + +export async function saveAdminWorkerProductNameSuffixConfig(payload: JsonObject = {}) { + return saveWorkerProductNameSuffixConfig(payload) +} + export async function pruneAdminWorkProductMatchLogs(payload: JsonObject = {}) { const config = getWorkerProductMatchConfig() const maxRows = payload.maxRows !== undefined ? Number(payload.maxRows) : config.maxMatchLogs diff --git a/apps/backend/src/services/worker-platform/index.ts b/apps/backend/src/services/worker-platform/index.ts index ca46aaa8..60b9a547 100644 --- a/apps/backend/src/services/worker-platform/index.ts +++ b/apps/backend/src/services/worker-platform/index.ts @@ -61,6 +61,7 @@ export { export * from './worker-product-match-config-service.js' export * from './worker-announcement-config-service.js' export * from './worker-hall-config-service.js' +export * from './worker-product-name-suffix-service.js' export * from './worker-acceptance-config-service.js' export * from './sync-worker-orders-service.js' export * from './consignment-contact-service.js' diff --git a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts index 26e3fbec..f32a1b16 100644 --- a/apps/backend/src/services/worker-platform/work-order-display-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-order-display-mappers.ts @@ -525,6 +525,14 @@ export function summarizeWorkerShares( } } +/** 来源平台标识(如 91kaquan):管理端直接读 material,打手端单独透出,避免大厅裁剪 material 后丢失。 */ +function resolveSourceProviderFromMaterial(material: unknown): string { + if (!material || typeof material !== 'object' || Array.isArray(material)) return '' + const source = (material as Record).source + if (!source || typeof source !== 'object' || Array.isArray(source)) return '' + return String((source as Record).provider || '').trim() +} + export function mapWorkOrderForWorker( workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }, @@ -571,6 +579,8 @@ export function mapWorkOrderForWorker( productName: hasActiveShares ? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity) : visibleOrder.productName, + /** 来源平台标识;前端按后台配置决定是否在商品名后追加(文字) */ + sourceProvider: resolveSourceProviderFromMaterial(visibleOrder.material), status: personalStatus, /** 行上展示的问题备注:母单问题单用母单备注,否则用被标记份额的备注 */ problemNote: @@ -664,6 +674,7 @@ export function mapWorkOrderForHall( workOrderNo: mapped.workOrderNo, platformOrderId: mapped.platformOrderId, productName: mapped.productName, + sourceProvider: mapped.sourceProvider, categoryId: mapped.categoryId, categoryName: mapped.categoryName, parentCategoryId: workOrder.parent_category_id ? Number(workOrder.parent_category_id) : null, diff --git a/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts new file mode 100644 index 00000000..036a6511 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildWorkerProductNameSuffixDisplayConfig, + normalizeProductNameSuffixText, + normalizeWorkerProductNameSuffixConfig, +} from './worker-product-name-suffix-service.js' + +function item( + source: string, + suffix: string, + extra: Partial<{ enabled: boolean; note: string }> = {}, +) { + return { source, suffix, enabled: false, note: '', ...extra } +} + +test('来源后缀配置:默认空配置', () => { + assert.deepEqual(normalizeWorkerProductNameSuffixConfig(undefined), { items: [] }) + assert.deepEqual(normalizeWorkerProductNameSuffixConfig({}), { items: [] }) + assert.deepEqual(normalizeWorkerProductNameSuffixConfig({ items: 'bad' }), { items: [] }) +}) + +test('来源后缀配置:来源去空格转小写,后缀剥离外层括号', () => { + assert.deepEqual( + normalizeWorkerProductNameSuffixConfig({ + items: [ + { source: ' 91KAQUAN ', suffix: '(91卡券)' }, + { source: 'douyin', suffix: '(抖音)' }, + ], + }), + { + items: [item('91kaquan', '91卡券'), item('douyin', '抖音')], + }, + ) + assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券') + assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券') +}) + +test('来源后缀配置:空来源或空后缀被丢弃', () => { + assert.deepEqual( + normalizeWorkerProductNameSuffixConfig({ + items: [ + { source: '', suffix: '空来源' }, + { source: 'empty-suffix', suffix: ' ' }, + { source: 'ok', suffix: '保留' }, + ], + }), + { items: [item('ok', '保留')] }, + ) +}) + +test('来源后缀配置:同一来源不区分大小写去重,保留第一条', () => { + assert.deepEqual( + normalizeWorkerProductNameSuffixConfig({ + items: [ + { source: '91kaquan', suffix: '第一条' }, + { source: '91Kaquan', suffix: '第二条' }, + ], + }), + { items: [item('91kaquan', '第一条')] }, + ) +}) + +test('来源后缀配置:最多 50 条', () => { + const items = Array.from({ length: 60 }, (_, index) => ({ + source: `source-${index}`, + suffix: `后缀${index}`, + })) + assert.equal(normalizeWorkerProductNameSuffixConfig({ items }).items.length, 50) +}) + +test('来源后缀配置:来源最长 64、后缀最长 32', () => { + const config = normalizeWorkerProductNameSuffixConfig({ + items: [{ source: 'a'.repeat(80), suffix: 's'.repeat(40) }], + }) + assert.equal(config.items[0]?.source.length, 64) + assert.equal(config.items[0]?.suffix.length, 32) +}) + +test('来源后缀配置:非对象条目被忽略', () => { + assert.deepEqual( + normalizeWorkerProductNameSuffixConfig({ + items: ['bad', null, 42, { source: 'ok', suffix: '保留' }], + }), + { items: [item('ok', '保留')] }, + ) +}) + +test('来源后缀配置:启停与备注原样归一化,旧配置默认停用', () => { + assert.deepEqual( + normalizeWorkerProductNameSuffixConfig({ + items: [ + { source: 'a', suffix: '甲', enabled: true, note: ' 第一条备注 ' }, + { source: 'b', suffix: '乙', note: '只有备注' }, + ], + }), + { + items: [ + item('a', '甲', { enabled: true, note: '第一条备注' }), + item('b', '乙', { note: '只有备注' }), + ], + }, + ) +}) + +test('来源后缀配置:同一时间只保留第一条启用项', () => { + const config = normalizeWorkerProductNameSuffixConfig({ + items: [ + { source: 'a', suffix: '甲', enabled: true }, + { source: 'b', suffix: '乙', enabled: true }, + { source: 'c', suffix: '丙', enabled: true }, + ], + }) + assert.deepEqual( + config.items.map((row) => [row.source, row.enabled]), + [ + ['a', true], + ['b', false], + ['c', false], + ], + ) +}) + +test('来源后缀配置:备注最长 200', () => { + const config = normalizeWorkerProductNameSuffixConfig({ + items: [{ source: 'ok', suffix: '后缀', note: 'n'.repeat(240) }], + }) + assert.equal(config.items[0]?.note.length, 200) +}) + +test('打手端展示配置:只下发启用项且不带备注', () => { + assert.deepEqual( + buildWorkerProductNameSuffixDisplayConfig({ + items: [ + { source: 'a', suffix: '甲', enabled: false, note: '停用' }, + { source: 'b', suffix: '乙', enabled: true, note: '启用' }, + ], + }), + { items: [{ source: 'b', suffix: '乙' }] }, + ) + assert.deepEqual(buildWorkerProductNameSuffixDisplayConfig({ items: [] }), { items: [] }) +}) diff --git a/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts new file mode 100644 index 00000000..6d519726 --- /dev/null +++ b/apps/backend/src/services/worker-platform/worker-product-name-suffix-service.ts @@ -0,0 +1,145 @@ +import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' +import { listWorkOrderSourceProviders } from '../../repositories/worker-platform/work-order-query-repo.js' +import type { JsonObject } from '../../types/json.js' +import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' + +/** + * 商品名来源后缀配置。 + * + * 只影响展示:工单表里的 `product_name` 永远保持原值。 + * `source` 与工单资料里的 `material.source.provider` 精确匹配(不区分大小写), + * `suffix` 只存括号内的文字,前端统一按「商品名(后缀)」渲染。 + * 同一时间只允许启用一条配置,只有启用项会追加后缀。 + */ +export type WorkerProductNameSuffixItem = { + source: string + suffix: string + /** 是否启用;保存时只保留第一条启用项 */ + enabled: boolean + /** 后台备注(仅后台配置页可见) */ + note: string +} + +export type WorkerProductNameSuffixConfig = { + items: WorkerProductNameSuffixItem[] +} + +/** 打手端只需要启用的来源 -> 后缀映射,不暴露备注等信息。 */ +export type WorkerProductNameSuffixDisplayItem = { + source: string + suffix: string +} + +export type WorkerProductNameSuffixDisplayConfig = { + items: WorkerProductNameSuffixDisplayItem[] +} + +const MAX_ITEMS = 50 +const MAX_SOURCE_LENGTH = 64 +const MAX_SUFFIX_LENGTH = 32 +const MAX_NOTE_LENGTH = 200 + +export function getWorkerProductNameSuffixConfig(): WorkerProductNameSuffixConfig { + return readAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerProductNameSuffix, + fallback: createDefaultWorkerProductNameSuffixConfig, + normalize: normalizeWorkerProductNameSuffixConfig, + }) +} + +export async function saveWorkerProductNameSuffixConfig( + rawValue: unknown, +): Promise { + return saveAppConfigEntry({ + configKey: APP_CONFIG_KEYS.workerProductNameSuffix, + value: rawValue, + normalize: normalizeWorkerProductNameSuffixConfig, + }) +} + +/** 默认不配置任何来源,保存前所有商品名都保持原样。 */ +export function createDefaultWorkerProductNameSuffixConfig(): WorkerProductNameSuffixConfig { + return { items: [] } +} + +export function normalizeWorkerProductNameSuffixConfig( + rawValue: unknown, +): WorkerProductNameSuffixConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const rawItems = Array.isArray(source.items) ? source.items : [] + const seenSources = new Set() + const items: WorkerProductNameSuffixItem[] = [] + let hasEnabledItem = false + + for (const rawItem of rawItems) { + if (items.length >= MAX_ITEMS) break + if (!isPlainObject(rawItem)) continue + const sourceText = String(rawItem.source ?? '') + .trim() + .slice(0, MAX_SOURCE_LENGTH) + const suffixText = normalizeProductNameSuffixText(rawItem.suffix) + if (!sourceText || !suffixText) continue + // 匹配时大小写不敏感,统一存小写,避免 91kaquan / 91Kaquan 配置出两条。 + const normalizedSource = sourceText.toLowerCase() + if (seenSources.has(normalizedSource)) continue + seenSources.add(normalizedSource) + // 同时只能启用一个来源:按保存顺序保留第一条启用项,其余强制停用。 + const enabled = rawItem.enabled === true && !hasEnabledItem + if (enabled) hasEnabledItem = true + items.push({ + source: normalizedSource, + suffix: suffixText, + enabled, + note: String(rawItem.note ?? '') + .trim() + .slice(0, MAX_NOTE_LENGTH), + }) + } + + return { items } +} + +/** 打手端展示配置:只下发启用项,未启用时返回空列表(商品名保持原样)。 */ +export function buildWorkerProductNameSuffixDisplayConfig( + config: WorkerProductNameSuffixConfig, +): WorkerProductNameSuffixDisplayConfig { + return { + items: config.items + .filter((item) => item.enabled) + .map((item) => ({ source: item.source, suffix: item.suffix })), + } +} + +export function getWorkerProductNameSuffixDisplayConfig(): WorkerProductNameSuffixDisplayConfig { + return buildWorkerProductNameSuffixDisplayConfig(getWorkerProductNameSuffixConfig()) +} + +/** 后台来源下拉选项:工单资料里出现过的来源 + 已配置但暂时没有工单的来源。 */ +export async function listWorkerProductNameSuffixSourceOptions(): Promise<{ items: string[] }> { + const providers = await listWorkOrderSourceProviders() + const config = getWorkerProductNameSuffixConfig() + const seen = new Map() + for (const value of [...providers, ...config.items.map((item) => item.source)]) { + const text = String(value ?? '').trim() + if (!text) continue + const key = text.toLowerCase() + if (!seen.has(key)) seen.set(key, text) + } + return { items: [...seen.values()].sort((a, b) => a.localeCompare(b)) } +} + +/** 后缀只保留括号内的文字:用户填了外层全角/半角括号时自动剥离。 */ +export function normalizeProductNameSuffixText(value: unknown): string { + let text = String(value ?? '').trim() + if (!text) return '' + const wrappedFullWidth = text.startsWith('(') && text.endsWith(')') + const wrappedHalfWidth = text.startsWith('(') && text.endsWith(')') + if ((wrappedFullWidth || wrappedHalfWidth) && text.length >= 2) { + text = text.slice(1, -1).trim() + } + return text.slice(0, MAX_SUFFIX_LENGTH) +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx index 9cb5a206..93b76890 100644 --- a/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { ReloadOutlined, SaveOutlined } from '@ant-design/icons' +import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons' import { Alert, App, @@ -9,12 +9,13 @@ import { Input, InputNumber, Popconfirm, + Select, Space, Switch, Tag, Typography, } from 'antd' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { addAdminMockCustomShop, @@ -22,14 +23,18 @@ import { fetchAdminMockCustomShops, fetchAdminScheduledJobsConfig, fetchAdminWorkerAnnouncement, + fetchAdminWorkerProductNameSuffix, + fetchAdminWorkerProductNameSuffixSources, renameAdminMockCustomShop, runAdminScheduledJob, saveAdminWorkerAnnouncement, + saveAdminWorkerProductNameSuffix, saveAdminScheduledJobsConfig, } from '@/services/admin' import type { AdminScheduledJobItem, AdminScheduledJobRuntimeState } from '@/types/admin' -import type { WorkerAnnouncementConfig } from '@/types/worker-platform' +import type { WorkerAnnouncementConfig, WorkerProductNameSuffixItem } from '@/types/worker-platform' import { formatAdminDateTime } from '@/utils/admin-time' +import { normalizeProductNameSuffixText } from '@/utils/product-name-source-suffix' import HallConfigPanel from './HallConfigPanel' @@ -41,6 +46,7 @@ export default function AnnouncementPanel() { return ( + @@ -241,6 +247,233 @@ function AnnouncementCard() { ) } +type ProductNameSuffixDraft = WorkerProductNameSuffixItem & { rowKey: string } + +let productNameSuffixRowSeed = 0 + +function createProductNameSuffixRow( + source = '', + suffix = '', + enabled = false, + note = '', +): ProductNameSuffixDraft { + productNameSuffixRowSeed += 1 + return { + rowKey: `product-name-suffix-${productNameSuffixRowSeed}`, + source, + suffix, + enabled, + note, + } +} + +/** 商品名来源后缀:按工单来源在商品名后追加「(文字)」,只影响展示,不改库里的商品名。 */ +function ProductNameSourceSuffixCard() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [saving, setSaving] = useState(false) + const [items, setItems] = useState([]) + const suffixQuery = useQuery({ + queryKey: ['admin-worker-product-name-suffix'], + queryFn: fetchAdminWorkerProductNameSuffix, + staleTime: 5 * 60 * 1000, + }) + const sourcesQuery = useQuery({ + queryKey: ['admin-worker-product-name-suffix-sources'], + queryFn: fetchAdminWorkerProductNameSuffixSources, + staleTime: 5 * 60 * 1000, + }) + + useEffect(() => { + const remoteItems = suffixQuery.data?.data.items || [] + setItems( + remoteItems.map((item) => + createProductNameSuffixRow(item.source, item.suffix, item.enabled, item.note), + ), + ) + }, [suffixQuery.data]) + + // 下拉选项来自订单来源字段;已配置但暂时没有工单的来源也保留,避免编辑时丢值。 + const sourceOptions = useMemo(() => { + const values = new Map() + for (const item of items) { + const text = String(item.source || '').trim() + if (!text) continue + const key = text.toLowerCase() + if (!values.has(key)) values.set(key, text) + } + for (const value of sourcesQuery.data?.data.items || []) { + const text = String(value || '').trim() + if (!text) continue + const key = text.toLowerCase() + if (!values.has(key)) values.set(key, text) + } + return [...values.values()] + .sort((a, b) => a.localeCompare(b)) + .map((value) => ({ value, label: value })) + }, [items, sourcesQuery.data]) + + function patchRow(rowKey: string, patch: Partial) { + setItems((prev) => prev.map((item) => (item.rowKey === rowKey ? { ...item, ...patch } : item))) + } + + function toggleRow(rowKey: string, enabled: boolean) { + setItems((prev) => + prev.map((item) => { + if (item.rowKey === rowKey) return { ...item, enabled } + // 同一时间只能启用一个来源:启用当前行时自动停用其他行。 + return enabled ? { ...item, enabled: false } : item + }), + ) + } + + function removeRow(rowKey: string) { + setItems((prev) => prev.filter((item) => item.rowKey !== rowKey)) + } + + async function save() { + const normalized: WorkerProductNameSuffixItem[] = [] + const seenSources = new Set() + let hasEnabledItem = false + for (const [index, item] of items.entries()) { + const source = String(item.source || '').trim() + const suffix = normalizeProductNameSuffixText(item.suffix) + if (!source && !suffix) continue + if (!source || !suffix) { + message.error(`第 ${index + 1} 行:来源和后缀都要填写`) + return + } + const normalizedSource = source.toLowerCase() + if (seenSources.has(normalizedSource)) { + message.error(`来源「${source}」重复,一行只能配置一个来源`) + return + } + seenSources.add(normalizedSource) + const enabled = item.enabled === true && !hasEnabledItem + if (enabled) hasEnabledItem = true + normalized.push({ + source, + suffix, + enabled, + note: String(item.note || '').trim(), + }) + } + if (normalized.length > 50) { + message.error('最多配置 50 条来源后缀') + return + } + + setSaving(true) + try { + await saveAdminWorkerProductNameSuffix({ items: normalized }) + await queryClient.invalidateQueries({ queryKey: ['admin-worker-product-name-suffix'] }) + message.success('商品名来源后缀已保存') + } catch (error) { + message.error(error instanceof Error ? error.message : '保存商品名来源后缀失败') + } finally { + setSaving(false) + } + } + + return ( + } loading={saving} onClick={() => void save()}> + 保存 + + } + > + + 来源下拉数据取自工单的「来源」字段;选中来源后填写后缀,启用后展示为「商品名(后缀)」。 + 同一时间只能启用一个来源,启用新来源会自动停用其他行;未启用或没有匹配配置的来源不追加。 + 备注仅后台可见。只影响列表/详情的展示,不会改写工单里的商品名。 + + {sourcesQuery.isError ? ( + + 读取工单来源选项失败,已配置的来源仍可编辑;请稍后刷新重试。 + + ) : null} + {items.length === 0 ? ( + + 还没有配置,所有商品名保持原样;点击「添加一行」开始配置。 + + ) : null} + + {items.map((item, index) => { + const suffix = normalizeProductNameSuffixText(item.suffix) + return ( + + toggleRow(item.rowKey, checked)} + /> + patchRow(item.rowKey, { suffix: event.target.value })} + /> + patchRow(item.rowKey, { note: event.target.value })} + /> + + {suffix + ? item.enabled + ? `示例:商品名(${suffix})` + : '未启用,不追加' + : '未填写后缀时不追加'} + + + + ) + })} + + + + ) +} + function MockCustomShopsCard() { const { message, modal } = App.useApp() const queryClient = useQueryClient() diff --git a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx index 7cf892d8..64ebbd28 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrderTableCells.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from 'react' import { updateAdminWorkOrder, updateAdminWorkOrderPaymentAmount } from '@/services/admin' import type { WorkOrder } from '@/types/worker-platform' +import { appendProductNameSourceSuffix } from '@/utils/product-name-source-suffix' import { formatMoney } from './shared' import { @@ -15,10 +16,13 @@ import { /** 商品名:单击看详情,双击原地修改(仅未接走工单) */ export function ProductNameEditableCell({ order, + sourceSuffix = '', onOpenDetail, onSaved, }: { order: WorkOrder + /** 按来源匹配到的后缀(不含括号):只用于展示,编辑态仍显示原始商品名 */ + sourceSuffix?: string onOpenDetail: () => void onSaved: () => Promise | void }) { @@ -69,13 +73,15 @@ export function ProductNameEditableCell({ } if (!editing) { + const displayName = + appendProductNameSourceSuffix(order.productName, sourceSuffix) || '未命名商品' return ( - {order.productName || '未命名商品'} + {displayName} ) } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index c41b8855..06bb7470 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -68,6 +68,7 @@ import { fetchAdminWorkOrderSharing, fetchAdminWorkOrderShopOptions, fetchAdminWorkerLevels, + fetchAdminWorkerProductNameSuffix, fetchAdminWorkerUsers, fetchAdminWorkOrders, markAdminWorkOrderProblem, @@ -107,6 +108,10 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' import { buildCollectMaterialLinkUrl } from '@/utils/collect-link' +import { + applyProductNameSourceSuffix, + resolveProductNameSourceSuffix, +} from '@/utils/product-name-source-suffix' import { isExplicitOrderNoSearch, normalizeSearchText } from '@/utils/admin-realtime-work-orders' import { DeadlineCountdown, @@ -285,6 +290,13 @@ export default function WorkOrdersPanel() { queryFn: fetchAdminSession, retry: false, }) + // 商品名来源后缀(杂项配置维护):只影响列表/详情展示,编辑与复制仍用原始商品名。 + const productNameSuffixQuery = useQuery({ + queryKey: ['admin-worker-product-name-suffix'], + queryFn: fetchAdminWorkerProductNameSuffix, + staleTime: 5 * 60 * 1000, + }) + const productNameSuffixItems = productNameSuffixQuery.data?.data.items || [] const sessionPermissions = adminSessionQuery.data?.data.user.permissions const canReturnToHall = sessionPermissions ? sessionPermissions.includes('worker_order.return_to_hall') @@ -436,6 +448,13 @@ export default function WorkOrdersPanel() { // 详情抽屉里的寄售履约信息(有寄售履约才展示寄卖单号) const detailConsignment = detailOrder ? getConsignmentFulfillment(detailOrder) : null + const detailProductName = detailOrder + ? applyProductNameSourceSuffix( + detailOrder.productName, + asRecord(detailOrder.material?.source).provider, + productNameSuffixItems, + ) + : '' // 仅单独筛选「待验收」时提供批量验收入口(多选状态下不启用勾选,避免误操作) const isPendingAcceptanceOnly = statuses.length === 1 && statuses[0] === 'pending_acceptance' @@ -1676,11 +1695,13 @@ export default function WorkOrdersPanel() { const source = asRecord(row.material?.source) const shopName = String(source.shopName || '').trim() const provider = String(source.provider || '').trim() + const sourceSuffix = resolveProductNameSourceSuffix(productNameSuffixItems, provider) const consignment = getConsignmentFulfillment(row) return (
openDetailDrawer(row)} onSaved={refreshAll} /> @@ -2360,7 +2381,7 @@ export default function WorkOrdersPanel() { className="worker-order-detail-drawer" title={ detailOrder - ? `${detailOrder.productName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}` + ? `${detailProductName || '工单详情'} · ${getDisplayOrderNo(detailOrder)}` : '工单详情' } width={860} @@ -2408,7 +2429,7 @@ export default function WorkOrdersPanel() { {detailOrder.orderItemId || '-'} - {detailOrder.productName || '-'} + {detailProductName || '-'} {detailOrder.categoryName || '-'} diff --git a/apps/frontend/src/pages/worker/WorkerHallPage.tsx b/apps/frontend/src/pages/worker/WorkerHallPage.tsx index ab4e9a7d..1cea384d 100644 --- a/apps/frontend/src/pages/worker/WorkerHallPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerHallPage.tsx @@ -33,7 +33,15 @@ import { Tooltip, Typography, } from 'antd' -import { memo, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react' import { Link } from 'react-router' import { WorkerContactModal } from '@/components/worker/WorkerContactModal' @@ -44,6 +52,7 @@ import { fetchWorkerHallLeaderboard, fetchWorkerMyOrders, fetchWorkerMyOrderOverview, + fetchWorkerProductNameSuffix, fetchWorkerProfile, grabWorkerOrder, joinWorkerSharingOrder, @@ -67,6 +76,10 @@ import { resolveHallRemainingQuantity, resolveHallRewardAmount, } from '@/utils/worker-product-name' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import { formatDateTime, formatPublishedElapsedTime } from '@/utils/date-time' import { createRequestId } from '@/utils/request-id' @@ -161,6 +174,28 @@ export default function WorkerHallPage() { const orders = ordersQuery.data?.pages.flatMap((page) => page.data.items) || [] const firstPage = ordersQuery.data?.pages[0]?.data + // 商品名来源后缀(后台「杂项配置」维护):只影响展示,不改商品名本身。 + const productNameSuffixQuery = useQuery({ + queryKey: ['worker-product-name-suffix'], + queryFn: fetchWorkerProductNameSuffix, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: false, + }) + const productNameSuffixItems = useMemo( + () => productNameSuffixQuery.data?.data.items || [], + [productNameSuffixQuery.data], + ) + const resolveDisplayProductName = useCallback( + (order: WorkOrder) => + applyProductNameSourceSuffix( + resolveHallProductName(order), + order.sourceProvider, + productNameSuffixItems, + ) || '未命名商品', + [productNameSuffixItems], + ) + const [categoryMeta, setCategoryMeta] = useState<{ categories: WorkCategory[] showSubcategories: boolean @@ -337,7 +372,7 @@ export default function WorkerHallPage() { modal.confirm({ title: '确认抢单', icon: , - content: `确认抢下「${resolveHallProductName(order)}」?奖励 ${formatMoney( + content: `确认抢下「${resolveDisplayProductName(order)}」?奖励 ${formatMoney( resolveHallRewardAmount(order), )}`, okText: '确认抢单', @@ -345,7 +380,7 @@ export default function WorkerHallPage() { onOk: () => submitGrab(order), }) }, - [grabConfirmEnabled, modal, submitGrab], + [grabConfirmEnabled, modal, submitGrab, resolveDisplayProductName], ) const openSharingModal = useCallback((order: WorkOrder) => { @@ -679,6 +714,7 @@ export default function WorkerHallPage() { remainingSlots={remainingSlots} isInternalWorker={isInternalWorker} grabbing={grabbingId === order.workOrderId} + productNameSuffixItems={productNameSuffixItems} onGrab={requestGrab} onOpenSharing={openSharingModal} /> @@ -1017,6 +1053,7 @@ export default function WorkerHallPage() { remainingSlots={remainingSlots} isInternalWorker={isInternalWorker} grabbing={grabbingId === order.workOrderId} + productNameSuffixItems={productNameSuffixItems} onGrab={requestGrab} onOpenSharing={openSharingModal} /> @@ -1031,7 +1068,7 @@ export default function WorkerHallPage() { )} void onOpenSharing: (order: WorkOrder) => void } @@ -1202,11 +1241,17 @@ const HallDesktopCard = memo(function HallDesktopCard({ remainingSlots, isInternalWorker, grabbing, + productNameSuffixItems, onGrab, onOpenSharing, }: HallCardProps) { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) - const productName = resolveHallProductName(order) || '未命名商品' + const productName = + applyProductNameSourceSuffix( + resolveHallProductName(order), + order.sourceProvider, + productNameSuffixItems, + ) || '未命名商品' const sharingRemaining = resolveHallRemainingQuantity(order) const depositAmount = resolveHallDepositAmount(order) const rewardAmount = resolveHallRewardAmount(order) @@ -1326,11 +1371,17 @@ const HallMobileCard = memo(function HallMobileCard({ remainingSlots, isInternalWorker, grabbing, + productNameSuffixItems, onGrab, onOpenSharing, }: HallCardProps) { const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots) - const productName = resolveHallProductName(order) || '未命名商品' + const productName = + applyProductNameSourceSuffix( + resolveHallProductName(order), + order.sourceProvider, + productNameSuffixItems, + ) || '未命名商品' const sharingRemaining = resolveHallRemainingQuantity(order) const myShareSummary = resolveHallMyShareSummary(order) diff --git a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx index 074d350d..33de9c2a 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderDetailDrawer.tsx @@ -7,6 +7,10 @@ import VideoPreview, { formatAcceptanceVideoDuration } from '@/components/files/ import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' import { resolveOrderConsignmentScreenshotUrls } from '@/utils/consignment-display' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import { canEditAcceptanceImages, @@ -35,6 +39,7 @@ export function WorkerOrderDetailDrawer({ isMobile, order, orderTimelineVisible, + productNameSuffixItems, onClose, onOpenAcceptance, onOpenGiftMaterial, @@ -44,6 +49,8 @@ export function WorkerOrderDetailDrawer({ isMobile: boolean order: WorkOrder | null orderTimelineVisible: boolean + /** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */ + productNameSuffixItems: ProductNameSourceSuffixItem[] onClose: () => void onOpenAcceptance: (order: WorkOrder) => void onOpenGiftMaterial: (order: WorkOrder) => void @@ -51,6 +58,13 @@ export function WorkerOrderDetailDrawer({ onOpenFeedback: (order: WorkOrder) => void }) { const isTimedOut = Boolean(order && (order.status === 'open' || order.timeoutNotice?.isTimedOut)) + const displayProductName = order + ? applyProductNameSourceSuffix( + order.productName, + order.sourceProvider, + productNameSuffixItems, + ) || '-' + : '-' return ( - {order.productName || '-'} + {displayProductName} {order.categoryName || '-'} {getDisplayOrderNo(order)} diff --git a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx index 139da7fd..ce4263f3 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderMobileList.tsx @@ -20,6 +20,10 @@ import type { WorkOrder, } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import type { WorkerOrderStatusOption } from './WorkerOrderDesktopList' import { @@ -56,6 +60,7 @@ export function WorkerOrderMobileList({ pagination, page, pageSize, + productNameSuffixItems, renderShareTable, loading, searching, @@ -85,6 +90,8 @@ export function WorkerOrderMobileList({ pagination: PaginationData | undefined page: number pageSize: number + /** 后台「杂项配置」维护的来源后缀;仅展示追加,不影响复制内容 */ + productNameSuffixItems: ProductNameSourceSuffixItem[] renderShareTable?: (order: WorkOrder) => ReactNode loading: boolean searching: boolean @@ -178,6 +185,7 @@ export function WorkerOrderMobileList({ @@ -308,7 +320,7 @@ function WorkerOrderMobileCard({
-
{order.productName || '未命名商品'}
+
{displayProductName}
{order.status === 'open' || order.timeoutNotice?.isTimedOut ? (
diff --git a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx index 461e602c..2b2d4eb3 100644 --- a/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrderTableColumns.tsx @@ -12,6 +12,10 @@ import { DeadlineCountdown } from '@/components/DeadlineCountdown' import ImagePreviewList from '@/components/files/ImagePreviewList' import type { WorkOrder } from '@/types/worker-platform' import { formatDateTime } from '@/utils/date-time' +import { + applyProductNameSourceSuffix, + type ProductNameSourceSuffixItem, +} from '@/utils/product-name-source-suffix' import { canEditAcceptanceImages, @@ -40,6 +44,7 @@ import { type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default' export function buildWorkerOrderTableColumns({ + productNameSuffixItems, onOpenDetail, onOpenAcceptance, onOpenGiftMaterial, @@ -50,6 +55,7 @@ export function buildWorkerOrderTableColumns({ onCopyOrderInfo, onCopyMaterialImages, }: { + productNameSuffixItems: ProductNameSourceSuffixItem[] onOpenDetail: (order: WorkOrder) => void onOpenAcceptance: (order: WorkOrder) => void onOpenGiftMaterial: (order: WorkOrder) => void @@ -64,41 +70,49 @@ export function buildWorkerOrderTableColumns({ { title: '订单信息', width: 270, - render: (_, row) => ( -
- - {row.isUnread ? ( - - 新 - - ) : null} - onOpenDetail(row)} - title={row.productName || '未命名商品'} - ellipsis + render: (_, row) => { + const displayProductName = + applyProductNameSourceSuffix( + row.productName, + row.sourceProvider, + productNameSuffixItems, + ) || '未命名商品' + return ( +
+ + {row.isUnread ? ( + + 新 + + ) : null} + onOpenDetail(row)} + title={displayProductName} + ellipsis + > + {displayProductName} + + + + {row.categoryName ? {row.categoryName} : null} + {row.myShares?.length || row.myShare ? ( + 拼单 {row.myShares?.length || 1} 次 + ) : null} + + - {row.productName || '未命名商品'} - - - - {row.categoryName ? {row.categoryName} : null} - {row.myShares?.length || row.myShare ? ( - 拼单 {row.myShares?.length || 1} 次 - ) : null} - - - {getDisplayOrderNo(row)} - - - 接单:{formatDateTime(row.assignedAt)} - -
- ), + {getDisplayOrderNo(row)} + + + 接单:{formatDateTime(row.assignedAt)} + +
+ ) + }, }, { title: '状态', diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 4f1b1094..ff216238 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,7 +1,7 @@ import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useNavigate } from 'react-router' import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { fetchWorkerAfterSalesCases, @@ -9,6 +9,7 @@ import { fetchWorkerOrderFeedbacks, fetchWorkerMyOrderOverview, fetchWorkerMyOrders, + fetchWorkerProductNameSuffix, fetchWorkerProfile, markWorkerOrderRead, remindWorkerAcceptance, @@ -206,6 +207,18 @@ export default function WorkerOrdersPage() { const pagination = ordersQuery.data?.data.pagination const orders = ordersQuery.data?.data.items || [] + // 商品名来源后缀(后台「杂项配置」维护):仅展示追加,复制资料仍用原始商品名。 + const productNameSuffixQuery = useQuery({ + queryKey: ['worker-product-name-suffix'], + queryFn: fetchWorkerProductNameSuffix, + staleTime: 5 * 60 * 1000, + retry: false, + }) + const productNameSuffixItems = useMemo( + () => productNameSuffixQuery.data?.data.items || [], + [productNameSuffixQuery.data], + ) + // 流转记录对打手是否可见(后台配置开关,默认关闭) const profileQuery = useQuery({ queryKey: ['worker-profile'], @@ -626,6 +639,7 @@ export default function WorkerOrdersPage() { } const columns = buildWorkerOrderTableColumns({ + productNameSuffixItems, onOpenDetail: openOrderDetail, onOpenAcceptance: openAcceptanceModal, onOpenGiftMaterial: openGiftMaterialModal, @@ -686,6 +700,7 @@ export default function WorkerOrdersPage() { pagination={pagination} page={page} pageSize={pageSize} + productNameSuffixItems={productNameSuffixItems} renderShareTable={renderShareTable} loading={ordersQuery.isLoading} searching={ordersQuery.isFetching} @@ -767,6 +782,7 @@ export default function WorkerOrdersPage() { isMobile={isMobile} order={detailOrder} orderTimelineVisible={orderTimelineVisible} + productNameSuffixItems={productNameSuffixItems} onClose={() => setDetailOrder(null)} onOpenAcceptance={openAcceptanceModal} onOpenGiftMaterial={openGiftMaterialModal} diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index cd867027..ab9cc411 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -29,6 +29,7 @@ import type { WorkerPlatformNotificationConfig, WorkerProductMatchConfig, WorkerAnnouncementConfig, + WorkerProductNameSuffixConfig, WorkerLevel, WorkerListResponse, WorkerUserListResponse, @@ -109,6 +110,22 @@ export function saveAdminWorkerAnnouncement(payload: WorkerAnnouncementConfig) { return apiPost('/api/v1/admin/worker-platform/announcement', payload) } +export function fetchAdminWorkerProductNameSuffix() { + return apiGet('/api/v1/admin/worker-platform/product-name-suffix') +} + +/** 来源下拉选项:来自工单资料里的 source.provider */ +export function fetchAdminWorkerProductNameSuffixSources() { + return apiGet<{ items: string[] }>('/api/v1/admin/worker-platform/product-name-suffix/sources') +} + +export function saveAdminWorkerProductNameSuffix(payload: WorkerProductNameSuffixConfig) { + return apiPost( + '/api/v1/admin/worker-platform/product-name-suffix', + payload, + ) +} + export function fetchAdminWorkerCancelRequests( status: 'pending' | 'approved' | 'rejected' = 'pending', ) { diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index c6130c8f..6ef1b4ad 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -11,6 +11,8 @@ import type { WorkerCancelRequest, WorkerCancelRequestRisk, WorkerAnnouncementConfig, + WorkerProductNameSuffixConfig, + WorkerProductNameSuffixDisplayConfig, WorkerAcceptanceConfig, WorkerAcceptanceVideo, WorkerOrderFeedback, @@ -152,6 +154,11 @@ export function fetchWorkerAnnouncement() { return apiGet('/api/v1/worker/announcement') } +/** 商品名来源后缀映射(后台「杂项配置」维护,只返回已启用项),打手端列表/详情/大厅展示用。 */ +export function fetchWorkerProductNameSuffix() { + return apiGet('/api/v1/worker/product-name-suffix') +} + /** 验收视频配置(后台可改):时长上限、目标高度、码率等,压缩前读取。 */ export function fetchWorkerAcceptanceConfig() { return apiGet('/api/v1/worker/acceptance/config') diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index f7f8b30b..542fb12d 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -325,6 +325,26 @@ export type WorkerAnnouncementConfig = { content: string } +export type WorkerProductNameSuffixItem = { + /** 来源标识(精确匹配、不区分大小写),如 91kaquan */ + source: string + /** 括号内的后缀文字,如 91卡券 */ + suffix: string + /** 是否启用;同一时间只允许启用一条 */ + enabled: boolean + /** 后台备注(仅后台配置页可见) */ + note: string +} + +export type WorkerProductNameSuffixConfig = { + items: WorkerProductNameSuffixItem[] +} + +/** 打手端只拿启用的来源 -> 后缀映射 */ +export type WorkerProductNameSuffixDisplayConfig = { + items: Array> +} + export type WorkerHallConfig = { maxVisibleOrders: number showSubcategories?: boolean @@ -622,6 +642,8 @@ export type WorkOrder = { workOrderNo: string platformOrderId: string productName: string + /** 来源平台标识(如 91kaquan),仅打手端返回;用于按后台配置追加展示后缀 */ + sourceProvider?: string categoryId?: number | null categoryName: string parentCategoryId?: number | null diff --git a/apps/frontend/src/utils/product-name-source-suffix.ts b/apps/frontend/src/utils/product-name-source-suffix.ts new file mode 100644 index 00000000..b074ef75 --- /dev/null +++ b/apps/frontend/src/utils/product-name-source-suffix.ts @@ -0,0 +1,69 @@ +export type ProductNameSourceSuffixItem = { + /** 来源标识,如 91kaquan */ + source: string + /** 括号内的后缀文字,如 91卡券 */ + suffix: string + /** 未启用(false)的配置不参与展示;缺省按启用处理,兼容打手端精简数据 */ + enabled?: boolean +} + +/** 后缀只保留括号内的文字:填了外层全角/半角括号时自动剥离。 */ +export function normalizeProductNameSuffixText(value: unknown): string { + let text = String(value ?? '').trim() + if (!text) return '' + const wrappedFullWidth = text.startsWith('(') && text.endsWith(')') + const wrappedHalfWidth = text.startsWith('(') && text.endsWith(')') + if ((wrappedFullWidth || wrappedHalfWidth) && text.length >= 2) { + text = text.slice(1, -1).trim() + } + return text +} + +/** + * 按来源取后缀:来源精确匹配、不区分大小写,空来源或空后缀不命中。 + * 同一来源配置多条时以第一条有效配置为准。 + */ +export function resolveProductNameSourceSuffix( + items: ProductNameSourceSuffixItem[] | null | undefined, + provider: unknown, +): string { + const normalizedProvider = String(provider ?? '') + .trim() + .toLowerCase() + if (!normalizedProvider) return '' + + for (const item of items ?? []) { + if (item?.enabled === false) continue + const source = String(item?.source ?? '') + .trim() + .toLowerCase() + if (!source || source !== normalizedProvider) continue + const suffix = normalizeProductNameSuffixText(item?.suffix) + if (suffix) return suffix + } + return '' +} + +/** + * 在商品名后追加「(后缀)」;没有后缀时原样返回。 + * 商品名已以相同后缀结尾(全角/半角括号均可)时不重复追加。 + */ +export function appendProductNameSourceSuffix(productName: unknown, suffix: unknown): string { + const name = String(productName ?? '').trim() + const normalizedSuffix = normalizeProductNameSuffixText(suffix) + if (!name || !normalizedSuffix) return name + + if (name.endsWith(`(${normalizedSuffix})`) || name.endsWith(`(${normalizedSuffix})`)) { + return name + } + return `${name}(${normalizedSuffix})` +} + +/** 展示用:按来源匹配后一次性完成后缀追加;无匹配时返回原商品名。 */ +export function applyProductNameSourceSuffix( + productName: unknown, + provider: unknown, + items: ProductNameSourceSuffixItem[] | null | undefined, +): string { + return appendProductNameSourceSuffix(productName, resolveProductNameSourceSuffix(items, provider)) +} diff --git a/apps/frontend/tests/product-name-source-suffix.test.ts b/apps/frontend/tests/product-name-source-suffix.test.ts new file mode 100644 index 00000000..524ebaa3 --- /dev/null +++ b/apps/frontend/tests/product-name-source-suffix.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + appendProductNameSourceSuffix, + applyProductNameSourceSuffix, + normalizeProductNameSuffixText, + resolveProductNameSourceSuffix, +} from '../src/utils/product-name-source-suffix.ts' + +const items = [ + { source: '91kaquan', suffix: '91卡券' }, + { source: 'douyin', suffix: '抖音' }, +] + +test('来源后缀:空配置不追加', () => { + assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', []), '苏尔南手枪') + assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', null), '苏尔南手枪') +}) + +test('来源后缀:精确匹配后追加全角括号后缀', () => { + assert.equal( + applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', items), + '苏尔南手枪(91卡券)', + ) +}) + +test('来源后缀:来源匹配不区分大小写并去除首尾空格', () => { + assert.equal(resolveProductNameSourceSuffix(items, ' 91KAQUAN '), '91卡券') + assert.equal( + applyProductNameSourceSuffix('苏尔南手枪', ' 91Kaquan ', items), + '苏尔南手枪(91卡券)', + ) +}) + +test('来源后缀:没有匹配来源时不追加', () => { + assert.equal(applyProductNameSourceSuffix('苏尔南手枪', 'kuaishou', items), '苏尔南手枪') + assert.equal(applyProductNameSourceSuffix('苏尔南手枪', '', items), '苏尔南手枪') +}) + +test('来源后缀:商品名已带相同后缀时不重复追加', () => { + assert.equal( + appendProductNameSourceSuffix('苏尔南手枪(91卡券)', '91卡券'), + '苏尔南手枪(91卡券)', + ) + assert.equal(appendProductNameSourceSuffix('苏尔南手枪(91卡券)', '91卡券'), '苏尔南手枪(91卡券)') +}) + +test('来源后缀:后缀为空或商品名为空时不做处理', () => { + assert.equal(appendProductNameSourceSuffix('苏尔南手枪', ''), '苏尔南手枪') + assert.equal(appendProductNameSourceSuffix('', '91卡券'), '') + assert.equal(applyProductNameSourceSuffix('', '91kaquan', items), '') +}) + +test('来源后缀:配置里的外层括号会被剥离后再拼接', () => { + assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券') + assert.equal(normalizeProductNameSuffixText('(91卡券)'), '91卡券') + assert.equal( + applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [ + { source: '91kaquan', suffix: '(91卡券)' }, + ]), + '苏尔南手枪(91卡券)', + ) +}) + +test('来源后缀:停用的配置不参与展示,启用后才追加', () => { + assert.equal( + applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [ + { source: '91kaquan', suffix: '91卡券', enabled: false }, + ]), + '苏尔南手枪', + ) + assert.equal( + applyProductNameSourceSuffix('苏尔南手枪', '91kaquan', [ + { source: '91kaquan', suffix: '91卡券', enabled: true }, + ]), + '苏尔南手枪(91卡券)', + ) +}) + +test('来源后缀:同一来源多条配置时取第一条有效后缀', () => { + assert.equal( + resolveProductNameSourceSuffix( + [ + { source: '91kaquan', suffix: '第一' }, + { source: '91kaquan', suffix: '第二' }, + ], + '91kaquan', + ), + '第一', + ) +})