From 5466783017bbdcad433464b9803c6aa7041ec6ed Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 8 Oct 2026 13:50:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(worker-platform):=20=E6=8E=A5=E5=8D=95?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=AF=BC=E5=87=BA=E5=88=B0=E6=9C=AC=E5=9C=B0=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台接单平台「接单模板列表」右上角新增「导出」按钮: - 按当前筛选(搜索关键词 + 一级/二级分类)导出全部模板,服务端分页的 情况按每页 100 条循环拉全量(页数上限兜底),不会只导出当前页; - 导出 UTF-8 带 BOM 的 CSV,Excel/WPS 双击直接打开,文件名带时间戳 (接单模板_YYYYMMDD_HHmm.csv);逗号/引号/换行按 CSV 规范转义; - 22 列:模板名称/标识/分类/系统/平台/商品SKU/接单金额/按件价/拼单配置/ 验收模式/自动建单/状态/超时策略/资料字段/已绑定商品与绑定明细/时间等; - 空结果、超出上限、浏览器不支持下载分别给出明确提示。 导出逻辑集中在 apps/frontend/src/utils/product-rules-export.ts(纯函数, 无 @/ 别名与运行时相对导入,便于 node --experimental-strip-types 单测), 新增 tests/product-rules-export.test.ts 覆盖分类回退、金额与拼单转换、 绑定明细、CSV 转义、文件名、空列表与非浏览器环境。 --- .../pages/admin/panels/ProductRulesPanel.tsx | 73 +++++ .../src/utils/product-rules-export.ts | 278 ++++++++++++++++++ .../tests/product-rules-export.test.ts | 155 ++++++++++ 3 files changed, 506 insertions(+) create mode 100644 apps/frontend/src/utils/product-rules-export.ts create mode 100644 apps/frontend/tests/product-rules-export.test.ts diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index a0758475..8b7c5b76 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -1,5 +1,6 @@ import { DeleteOutlined, + DownloadOutlined, EditOutlined, FolderOpenOutlined, FormOutlined, @@ -50,9 +51,18 @@ import type { } from '@/types/worker-platform' import type { AdminKuaishouIndustryShopOption } from '@/types/admin' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { + buildProductRuleExportRows, + buildProductRulesExportFilename, + downloadProductRulesCsv, +} from '@/utils/product-rules-export' import { formatMoney } from './shared' import { WorkProductBindingForm, type SourcePrefill } from './WorkProductBindingForm' +/** 导出是一次性拉全量,单页取后端上限 100;页数上限兜底,防止异常数据下无限翻页。 */ +const PRODUCT_RULES_EXPORT_PAGE_SIZE = 100 +const PRODUCT_RULES_EXPORT_MAX_PAGES = 200 + export default function ProductRulesPanel() { const { message } = App.useApp() const queryClient = useQueryClient() @@ -63,6 +73,7 @@ export default function ProductRulesPanel() { const [editingRule, setEditingRule] = useState(null) const [syncingPrice, setSyncingPrice] = useState(false) + const [exportingRules, setExportingRules] = useState(false) const [activeMappingId, setActiveMappingId] = useState(null) const [sourcePrefill, setSourcePrefill] = useState(null) @@ -526,6 +537,59 @@ export default function ProductRulesPanel() { } } + /** + * 导出当前筛选(关键词 + 分类)下的全部接单模板到本地 CSV。 + * 列表是服务端分页的,所以这里按页循环拉全量,避免「只导出当前页」。 + */ + async function exportProductRules() { + setExportingRules(true) + try { + const collected: WorkProductRule[] = [] + let total = 0 + let truncated = false + for (let nextPage = 1; nextPage <= PRODUCT_RULES_EXPORT_MAX_PAGES; nextPage += 1) { + const response = await fetchAdminWorkProductRules({ + keyword, + categoryId, + page: nextPage, + pageSize: PRODUCT_RULES_EXPORT_PAGE_SIZE, + }) + const items = response.data?.items || [] + total = response.data?.pagination?.total ?? total + collected.push(...items) + if (items.length < PRODUCT_RULES_EXPORT_PAGE_SIZE || collected.length >= total) { + break + } + if (nextPage === PRODUCT_RULES_EXPORT_MAX_PAGES) { + truncated = true + } + } + + if (collected.length === 0) { + message.warning('当前筛选条件下没有可导出的接单模板') + return + } + + const rows = buildProductRuleExportRows(collected, mappingsByRuleId, categoryMap) + const filename = buildProductRulesExportFilename() + if (!downloadProductRulesCsv(rows, filename)) { + message.error('当前浏览器环境不支持本地文件下载') + return + } + if (truncated) { + message.warning( + `共 ${total} 条接单模板,本次已导出前 ${rows.length} 条,请缩小筛选范围后再导出剩余部分`, + ) + return + } + message.success(`已导出 ${rows.length} 条接单模板到本地:${filename}`) + } catch (error) { + message.error(error instanceof Error ? error.message : '导出接单模板失败') + } finally { + setExportingRules(false) + } + } + function syncSharingRuleForm(next: { sharingTotalQuantity?: number sharingUnitReward?: number @@ -1190,6 +1254,15 @@ export default function ProductRulesPanel() { + diff --git a/apps/frontend/src/utils/product-rules-export.ts b/apps/frontend/src/utils/product-rules-export.ts new file mode 100644 index 00000000..551b848f --- /dev/null +++ b/apps/frontend/src/utils/product-rules-export.ts @@ -0,0 +1,278 @@ +/** + * 「接单模板列表」导出到本地的纯函数(CSV)。 + * + * 后台运营需要把接单模板(含分类、价格、拼单、验收模式、资料字段、已绑定商品) + * 导出成表格存档 / 对账 / 批量核对,所以这里集中放「模板 → 导出行 → CSV → 下载」的逻辑。 + * + * 刻意写成纯函数 + 无 `@/` 别名 + 无运行时相对导入(仅 `import type`), + * 方便 `node --experimental-strip-types --test` 直接跑单测; + * 只有 `downloadTextFile` 触摸 DOM,且在非浏览器环境会安全返回 false。 + */ +import type { + CollectField, + WorkCategory, + WorkProductRule, + WorkProductRuleMapping, +} from '../types/worker-platform' + +/** Excel 识别 UTF-8 中文需要 BOM,否则用 Excel 双击打开会乱码。 */ +export const CSV_BOM = '\uFEFF' + +const CSV_HEADERS = [ + '模板名称', + '模板标识', + '分类', + '系统', + '平台', + '商品/SKU', + '接单金额(元)', + '按件价(元)', + '拼单', + '拼单总份数', + '每份报酬(元)', + '验收模式', + '自动建单', + '状态', + '超时(分钟)', + '超时策略', + '资料字段', + '已绑定商品数', + '绑定明细', + '排序', + '创建时间', + '更新时间', +] as const + +export type ProductRuleExportRow = { + productName: string + ruleKey: string + categoryPath: string + provider: string + platform: string + skuCode: string + rewardAmountYuan: string + unitPriceYuan: string + sharingEnabled: boolean + sharingTotalQuantity: number + sharingUnitRewardYuan: string + acceptanceMode: string + autoCreate: boolean + enabled: boolean + timeoutMinutes: number + timeoutPolicy: string + collectFields: string + mappingCount: number + mappingSummary: string + sortOrder: number + createdAt: string + updatedAt: string +} + +/** 与 DeadlineCountdown.formatTimeoutPolicyLabel 保持同一口径(此处不引 tsx 组件,便于单测)。 */ +function formatTimeoutPolicyLabel(policy?: string) { + if (policy === 'cancel_release') return '超时取消退押金' + if (policy === 'cancel_deduct') return '超时取消扣押金' + return '超时退回大厅' +} + +function formatAcceptanceModeLabel(mode?: string) { + return mode === 'friend_gift' ? '好友赠送' : '标准验收' +} + +function formatMappingTypeLabel(mappingType: string) { + if (mappingType === 'sku_exact') return 'SKU精确' + if (mappingType === 'sku_series') return 'SKU数量系列' + return '商品默认' +} + +function formatYuan(fen: number | undefined) { + return ((Number(fen) || 0) / 100).toFixed(2) +} + +/** 分类展示口径与列表一致:二级分类显示「一级 / 二级」。 */ +export function resolveRuleCategoryPath( + rule: Pick, + categoryMap?: Map, +) { + const category = rule.categoryId ? categoryMap?.get(rule.categoryId) : null + const parentCategory = + category?.parentId && categoryMap ? categoryMap.get(category.parentId) : null + if (category) { + return parentCategory ? `${parentCategory.name} / ${category.name}` : category.name + } + return String(rule.categoryName || '').trim() || '未分类' +} + +/** 资料字段:沿用模板编辑框的 `key:名称#选项1,选项2` 口径,多字段用「;」压成一行。 */ +export function formatRuleCollectFields(fields?: CollectField[]) { + const list = Array.isArray(fields) ? fields : [] + return list + .map((field) => { + const key = String(field?.key || '').trim() + if (!key) return '' + const options = + Array.isArray(field.options) && field.options.length > 0 + ? `#${field.options.join(',')}` + : '' + return `${key}:${String(field.label || key).trim()}${options}` + }) + .filter(Boolean) + .join(';') +} + +/** 单条商品绑定的一句话描述,导出后可直接在表格里筛选店铺 / 商品 / SKU。 */ +export function describeRuleMapping(mapping: WorkProductRuleMapping) { + const typeLabel = formatMappingTypeLabel(mapping.mappingType) + const shops = (mapping.sellerIds || []).filter(Boolean).join('/') + const product = String(mapping.itemTitle || mapping.relItemId || '').trim() || '全部商品' + const sku = + mapping.mappingType === 'product_default' + ? '' + : ` SKU[${String(mapping.skuNick || mapping.relSkuId || '-').trim()}]` + const mode = + mapping.mappingType === 'sku_series' + ? `·${mapping.skuMatchMode === 'exact' ? '精确' : '模糊'}` + : '' + return `${typeLabel}${mode}·店铺[${shops || '-'}]·商品[${product}]${sku}·${ + mapping.enabled ? '启用' : '停用' + }` +} + +/** 本地时间(导出文件名与时间列都用它,避免 UTC 造成的时区错觉)。 */ +function formatExportDateTime(value: string | null | undefined) { + const normalized = String(value || '').trim() + if (!normalized) return '' + const date = new Date(normalized) + if (Number.isNaN(date.getTime())) return normalized + const pad = (input: number) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} + +export function buildProductRuleExportRows( + rules: WorkProductRule[], + mappingsByRuleId: Map, + categoryMap?: Map, +): ProductRuleExportRow[] { + return (Array.isArray(rules) ? rules : []).map((rule) => { + const ruleMappings = mappingsByRuleId.get(rule.ruleId) || [] + const autoFromOrder = rule.sharing?.autoFromOrder === true + return { + productName: String(rule.productName || rule.ruleKey || '').trim(), + ruleKey: String(rule.ruleKey || '').trim(), + categoryPath: resolveRuleCategoryPath(rule, categoryMap), + provider: String(rule.provider || '').trim(), + platform: String(rule.platform || '').trim(), + skuCode: String(rule.skuCode || '').trim(), + // 随订单计算的模板没有固定到手价,导出时明确标注,避免被当成 0 元。 + rewardAmountYuan: autoFromOrder ? '随订单计算' : formatYuan(rule.rewardAmount), + unitPriceYuan: Number(rule.unitPriceFen || 0) > 0 ? formatYuan(rule.unitPriceFen) : '', + sharingEnabled: rule.sharing?.enabled === true, + sharingTotalQuantity: Number(rule.sharing?.totalQuantity || 0), + sharingUnitRewardYuan: + Number(rule.sharing?.unitReward || 0) > 0 ? formatYuan(rule.sharing?.unitReward) : '', + acceptanceMode: formatAcceptanceModeLabel(rule.acceptanceMode), + autoCreate: rule.autoCreate === true, + enabled: rule.enabled === true, + timeoutMinutes: Number(rule.timeoutMinutes || 0), + timeoutPolicy: formatTimeoutPolicyLabel(rule.timeoutPolicy), + collectFields: formatRuleCollectFields(rule.requirement?.fields), + mappingCount: ruleMappings.length, + mappingSummary: ruleMappings.map(describeRuleMapping).join(';'), + sortOrder: Number(rule.sortOrder || 0), + createdAt: formatExportDateTime(rule.createdAt), + updatedAt: formatExportDateTime(rule.updatedAt), + } + }) +} + +function escapeCsvValue(value: unknown) { + const text = value === null || value === undefined ? '' : String(value) + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text +} + +function toCsvLine(values: ReadonlyArray) { + return values.map(escapeCsvValue).join(',') +} + +export function buildProductRulesCsv(rows: ProductRuleExportRow[]) { + const lines = [toCsvLine(CSV_HEADERS)] + for (const row of Array.isArray(rows) ? rows : []) { + lines.push( + toCsvLine([ + row.productName, + row.ruleKey, + row.categoryPath, + row.provider, + row.platform, + row.skuCode, + row.rewardAmountYuan, + row.unitPriceYuan, + row.sharingEnabled ? '启用' : '关闭', + row.sharingEnabled ? row.sharingTotalQuantity : '', + row.sharingEnabled ? row.sharingUnitRewardYuan : '', + row.acceptanceMode, + row.autoCreate ? '是' : '否', + row.enabled ? '启用' : '停用', + row.timeoutMinutes, + row.timeoutPolicy, + row.collectFields, + row.mappingCount, + row.mappingSummary, + row.sortOrder, + row.createdAt, + row.updatedAt, + ]), + ) + } + return lines.join('\r\n') +} + +/** 形如 接单模板_20260221_1530.csv */ +export function buildProductRulesExportFilename(now: Date = new Date()) { + const pad = (input: number) => String(input).padStart(2, '0') + const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad( + now.getHours(), + )}${pad(now.getMinutes())}` + return `接单模板_${stamp}.csv` +} + +/** 触发浏览器下载;非浏览器环境(如单测)返回 false,不抛错。 */ +export function downloadTextFile( + filename: string, + content: string, + mimeType = 'text/plain;charset=utf-8', +) { + if ( + typeof document === 'undefined' || + typeof URL === 'undefined' || + typeof URL.createObjectURL !== 'function' + ) { + return false + } + const blob = new Blob([content], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + // 立刻 revoke 在部分浏览器会打断下载,稍后回收即可。 + setTimeout(() => URL.revokeObjectURL(url), 1000) + return true +} + +/** 导出接单模板为本地 CSV(带 BOM,Excel/WPS 直接双击可读)。 */ +export function downloadProductRulesCsv( + rows: ProductRuleExportRow[], + filename: string = buildProductRulesExportFilename(), +) { + return downloadTextFile( + filename, + `${CSV_BOM}${buildProductRulesCsv(rows)}`, + 'text/csv;charset=utf-8', + ) +} diff --git a/apps/frontend/tests/product-rules-export.test.ts b/apps/frontend/tests/product-rules-export.test.ts new file mode 100644 index 00000000..3ad26385 --- /dev/null +++ b/apps/frontend/tests/product-rules-export.test.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + CSV_BOM, + buildProductRuleExportRows, + buildProductRulesCsv, + buildProductRulesExportFilename, + describeRuleMapping, + downloadProductRulesCsv, + downloadTextFile, + formatRuleCollectFields, + resolveRuleCategoryPath, +} from '../src/utils/product-rules-export.ts' + +function createRule(overrides: Record = {}) { + return { + ruleId: 1, + ruleKey: 'android-暗潮-1钻', + provider: '安卓', + platform: '快手', + shopId: '', + skuCode: 'SKU-001', + productName: '暗潮1钻', + matchType: 'contains', + match: { + sellerId: '', + itemId: '', + relItemId: '', + skuId: '', + relSkuId: '', + itemTitle: '', + skuNick: '', + itemTitleMatchType: 'contains', + skuNickMatchType: 'contains', + }, + categoryId: 20, + categoryName: '暗潮', + enabled: true, + autoCreate: true, + rewardAmount: 12000, + unitPriceFen: 0, + sharing: { enabled: false, totalQuantity: 1, unitReward: 0, autoFromOrder: false }, + requirement: { fields: [] }, + timeoutMinutes: 60, + timeoutPolicy: 'reopen', + acceptanceMode: 'standard', + sortOrder: 1, + createdAt: '2026-02-21T10:00:00.000Z', + updatedAt: '2026-02-21T11:00:00.000Z', + ...overrides, + } as never +} + +test('接单模板导出:分类展示成「一级 / 二级」', () => { + const categoryMap = new Map([ + [10, { categoryId: 10, name: '三角洲行动', parentId: null, sortOrder: 1 }], + [20, { categoryId: 20, name: '暗潮', parentId: 10, sortOrder: 1 }], + ]) as never + assert.equal(resolveRuleCategoryPath(createRule(), categoryMap), '三角洲行动 / 暗潮') +}) + +test('接单模板导出:分类缺失时回退未分类,不再显示空列', () => { + assert.equal( + resolveRuleCategoryPath(createRule({ categoryId: null, categoryName: '' })), + '未分类', + ) +}) + +test('接单模板导出:金额按元输出,随订单计算的模板明确标注', () => { + const [fixed] = buildProductRuleExportRows( + [createRule({ unitPriceFen: 5000 })], + new Map(), + new Map(), + ) + assert.equal(fixed.rewardAmountYuan, '120.00') + assert.equal(fixed.unitPriceYuan, '50.00') + + const [autoFromOrder] = buildProductRuleExportRows( + [ + createRule({ + rewardAmount: 0, + sharing: { enabled: true, autoFromOrder: true, totalQuantity: 5, unitReward: 2000 }, + }), + ], + new Map(), + new Map(), + ) + assert.equal(autoFromOrder.rewardAmountYuan, '随订单计算') + assert.equal(autoFromOrder.sharingEnabled, true) + assert.equal(autoFromOrder.sharingTotalQuantity, 5) + assert.equal(autoFromOrder.sharingUnitRewardYuan, '20.00') +}) + +test('接单模板导出:商品绑定汇总带店铺、商品与启用状态', () => { + const row = { + mappingId: 1, + ruleId: 1, + ruleKey: 'android-暗潮-1钻', + productName: '暗潮1钻', + sellerIds: ['11300001'], + sellerId: '11300001', + relItemId: '', + itemTitle: '暗潮 1钻 代练', + relSkuId: '', + skuNick: '', + skuMatchMode: 'fuzzy', + mappingType: 'product_default', + enabled: true, + createdAt: '', + updatedAt: '', + } as never + assert.equal(describeRuleMapping(row), '商品默认·店铺[11300001]·商品[暗潮 1钻 代练]·启用') +}) + +test('接单模板导出:CSV 转义逗号、引号与换行', () => { + const rule = createRule({ + productName: '暗潮,1钻 "特惠"', + requirement: { fields: [{ key: 'gameId', label: '游戏\n编号', options: [] }] }, + }) + const rows = buildProductRuleExportRows([rule], new Map(), new Map()) + const csv = buildProductRulesCsv(rows) + + assert.ok(csv.startsWith('模板名称,模板标识,分类,')) + assert.ok(csv.includes('"暗潮,1钻 ""特惠"""')) + // 换行被引号包裹后仍是同一行记录 + assert.equal(csv.split('\r\n').length, 2) +}) + +test('接单模板导出:资料字段沿用 key:名称#选项 口径', () => { + assert.equal( + formatRuleCollectFields([ + { key: 'gameId', label: '游戏编号', options: [] }, + { key: 'system', label: '系统', options: ['安卓', '苹果'] }, + ] as never), + 'gameId:游戏编号;system:系统#安卓,苹果', + ) +}) + +test('接单模板导出:文件名带时间戳且为 .csv', () => { + const filename = buildProductRulesExportFilename(new Date(2026, 1, 21, 15, 30, 5)) + assert.equal(filename, '接单模板_20260221_1530.csv') + assert.equal(CSV_BOM, '\uFEFF') +}) + +test('接单模板导出:空列表只输出表头', () => { + const csv = buildProductRulesCsv([]) + assert.equal(csv.split('\r\n').length, 1) + assert.ok(csv.includes('绑定明细')) +}) + +test('接单模板导出:非浏览器环境不抛错,返回 false 由调用方提示', () => { + assert.equal(downloadTextFile('x.csv', 'a,b'), false) + assert.equal(downloadProductRulesCsv(buildProductRuleExportRows([], new Map(), new Map())), false) +})