feat(worker-platform): 接单模板列表支持一键导出到本地 CSV

后台接单平台「接单模板列表」右上角新增「导出」按钮:

- 按当前筛选(搜索关键词 + 一级/二级分类)导出全部模板,服务端分页的
  情况按每页 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 转义、文件名、空列表与非浏览器环境。
This commit is contained in:
yml2213
2026-10-08 13:50:19 +08:00
parent 98dc675b88
commit 5466783017
3 changed files with 506 additions and 0 deletions
@@ -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<WorkProductRule, 'categoryId' | 'categoryName'>,
categoryMap?: Map<number, WorkCategory>,
) {
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<number, WorkProductRuleMapping[]>,
categoryMap?: Map<number, WorkCategory>,
): 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<unknown>) {
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',
)
}