Files
goauto/web/src/utils/list-export.js
T
QiuSWandClaude Sonnet 5.5 e351a69912 feat(web): add shop column to SYB product export (#372)
Insert a text "店铺" column (row.shopName) right after SYB订单 and move
the 0.00 number format to the shifted price column. Sync the wiki mirror.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 11:50:18 +08:00

149 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #372: export every page of an admin list (current filters) to xlsx.
// Pure helpers with injected dependencies so they can be unit tested without a
// browser; the pages inject the API calls and the Export2Excel saver.
export const EXPORT_MAX_ROWS = 5000
const pad = (value, width = 2) => String(value).padStart(width, '0')
// YYYY-MM-DD HH:mm:ss in the browser's local time zone; empty for blank/invalid.
export function formatExportTime(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
// YYYYMMDD-HHmmss for file names.
export function formatExportStamp(date = new Date()) {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
}
// Cents -> yuan text with two decimals; null/undefined/'' stay empty.
export function centToYuan(cent) {
if (cent === null || cent === undefined || cent === '') return ''
const value = Number(cent)
if (!Number.isFinite(value)) return ''
const sign = value < 0 ? '-' : ''
const abs = Math.abs(Math.round(value))
return `${sign}${Math.floor(abs / 100)}.${pad(abs % 100)}`
}
// Cents -> yuan number (1230 -> 12.3) for numeric Excel cells; null/undefined/''/NaN stay empty, 0 stays 0.
export function centToYuanNumber(cent) {
if (cent === null || cent === undefined || cent === '') return ''
const value = Number(cent)
if (!Number.isFinite(value)) return ''
return Math.round(value) / 100
}
// yeeke only has one variation_name ("color,size"); split at the first English
// or Chinese comma. Without a comma the whole text is the color.
export function splitYeekeVariation(name) {
const text = String(name ?? '').trim()
if (!text) return { color: '', size: '' }
const index = text.search(/[,,]/)
if (index < 0) return { color: text, size: '' }
return { color: text.slice(0, index).trim(), size: text.slice(index + 1).trim() }
}
const blank = value => (value === null || value === undefined ? '' : value)
// Column indexes written as numbers; the saver shows them as 0.00.
export const PURCHASE_EXPORT_NUMBER_COLS = [8]
export const SYB_PRODUCT_EXPORT_NUMBER_COLS = [3]
export const PURCHASE_EXPORT_HEADER = ['SYB订单号', '虾皮商品ID', '颜色', '尺码', '数量', '状态', 'PDD单号', '下单时间', '价格(元)']
// statusLabel: the page's own status -> Chinese label function.
export function purchaseExportRow(task, statusLabel = value => value) {
return [
blank(task.shopeeOrderNoSnapshot), blank(task.shopeeItemIdSnapshot), blank(task.targetColorSnapshot), blank(task.targetSizeSnapshot),
blank(task.quantity), task.status ? statusLabel(task.status) : '', blank(task.pddOrderNo),
formatExportTime(task.orderSubmittedAt), centToYuanNumber(task.pddOrderAmountCent)
]
}
export const SYB_PRODUCT_EXPORT_HEADER = ['SYB订单', '店铺', '虾皮商品ID', 'SYB售价(TWD)', 'SYB颜色', 'SYB尺码', 'SYB数量', 'yeeke颜色', 'yeeke尺码', 'yeeke数量', '匹配时间']
export const isActiveReturnMatch = match => Boolean(match) && (match.status === 'matched' || match.status === 'confirmed')
// match: the active (matched/confirmed) return match of this product, or null.
export function sybProductExportRow(product, match) {
const active = isActiveReturnMatch(match) ? match : null
const yeeke = active ? splitYeekeVariation(active.variationName) : { color: '', size: '' }
return [
blank(product.orderCode), blank(product.shopName), blank(product.shopeeItemId), centToYuanNumber(product.unitPriceCent), blank(product.targetColor), blank(product.targetSize), blank(product.quantity),
yeeke.color, yeeke.size, active ? blank(active.yeekeQuantity) : '', active ? formatExportTime(active.matchedAt) : ''
]
}
export function exportFailureMessage(result, maxRows = EXPORT_MAX_ROWS) {
switch (result.reason) {
case 'empty': return '当前筛选没有数据,未生成文件'
case 'too_many': return `共 ${result.total} 条,超过 ${maxRows} 条上限,请缩小筛选范围`
case 'page_failed': return `导出失败:第 ${result.page} 页读取失败,未生成文件`
case 'enrich_failed': return '导出失败:匹配信息读取失败,未生成文件'
default: return '导出失败,未生成文件'
}
}
function chunk(items, size) {
const result = []
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size))
return result
}
// Reads page 1 for the total, refuses above maxRows, then reads the remaining
// pages. Any failure returns { ok: false } and nothing is saved.
// fetchPage(page, pageSize) -> { items, total }
// enrich(items) -> decorated items of the same length (optional, per batch)
// mapRow(item) -> array of cells
// save({ header, data, filename }) -> writes the file (only after success)
export async function runListExport({
fetchPage, pageSize, maxRows = EXPORT_MAX_ROWS, enrich = null, enrichBatchSize = 200,
header, mapRow, filename, save, numberFormatCols = [], onProgress = () => {}
}) {
let first
try {
first = await fetchPage(1, pageSize)
} catch (error) {
return { ok: false, reason: 'page_failed', page: 1, error }
}
const total = Number(first?.total) || 0
if (total === 0) return { ok: false, reason: 'empty', total: 0 }
if (total > maxRows) return { ok: false, reason: 'too_many', total }
const pages = Math.ceil(total / pageSize)
const enrichSteps = enrich ? Math.ceil(total / enrichBatchSize) : 0
const steps = pages + enrichSteps
let done = 1
onProgress(done, steps)
let items = [...(first.items || [])]
for (let page = 2; page <= pages; page++) {
let response
try {
response = await fetchPage(page, pageSize)
} catch (error) {
return { ok: false, reason: 'page_failed', page, error }
}
items = items.concat(response?.items || [])
onProgress(++done, steps)
}
if (enrich) {
const decorated = []
for (const part of chunk(items, enrichBatchSize)) {
try {
decorated.push(...await enrich(part))
} catch (error) {
return { ok: false, reason: 'enrich_failed', error }
}
onProgress(++done, steps)
}
items = decorated
}
if (items.length === 0) return { ok: false, reason: 'empty', total: 0 }
const data = items.map(mapRow)
await save({ header, data, filename: `${filename}_${formatExportStamp()}`, numberFormatCols })
return { ok: true, total, rows: data.length }
}