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:
@@ -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<WorkProductRule | null>(null)
|
||||
const [syncingPrice, setSyncingPrice] = useState(false)
|
||||
const [exportingRules, setExportingRules] = useState(false)
|
||||
const [activeMappingId, setActiveMappingId] = useState<number | 'new' | null>(null)
|
||||
const [sourcePrefill, setSourcePrefill] = useState<SourcePrefill | null>(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() {
|
||||
<Button size="middle" onClick={reprocessKuaishouOrders}>
|
||||
重匹配
|
||||
</Button>
|
||||
<Button
|
||||
size="middle"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={exportingRules}
|
||||
onClick={exportProductRules}
|
||||
title="按当前筛选条件导出全部接单模板到本地 CSV"
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
<Button type="primary" size="middle" icon={<PlusOutlined />} onClick={resetRuleForm}>
|
||||
新建
|
||||
</Button>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user