feat: 物料导入前端接口与 CSV 导出工具

- materialsApi 增加 importFile(silent,校验明细由页面弹窗展示)与
  downloadImportTemplate(走原生 fetch,响应是文件流)
- 下载接口绕开 axios 拦截器,自行携带令牌与当前公司头
- 新增 exportCsv:buildCsv 带 UTF-8 BOM、按 RFC 4180 转义;downloadCsv 触发下载
- BOM 不用内联零宽字符,改用 JS 转义序列写法,源码里可见可改
This commit is contained in:
文睿 张 2026-10-10 16:02:31 +08:00
parent 7b926ff60a
commit 444c2ed1af
3 changed files with 73 additions and 0 deletions

View File

@ -1,4 +1,16 @@
import request from './index'
import { getOrganizationId } from '../utils/organizationContext'
// 下载接口绕开 axios,得自己带令牌与当前公司,否则 401 / 400。
// 令牌沿用拦截器同款读法(localStorage),避免在 API 模块里依赖 Pinia 激活时序。
function downloadHeaders() {
const token = localStorage.getItem('wmms_token')
const organizationId = getOrganizationId()
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {})
}
}
export const materialsApi = {
getAll(params = {}) {
@ -23,5 +35,18 @@ export const materialsApi = {
return request.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
importFile(formData) {
// silent:校验失败的明细要自己弹窗展示,不让拦截器先弹一句笼统提示
return request.post('/material-import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
silent: true
})
},
downloadImportTemplate() {
// 下载走原生 fetch:响应是文件流,axios 拦截器只认 JSON 的 code 字段
return fetch('/api/material-import/template', {
headers: downloadHeaders()
})
}
}

View File

@ -0,0 +1,27 @@
// 生成 CSV 文本。带 UTF-8 BOM(写成转义而不是内联零宽字符,源码里才看得见),
// Excel 打开中文才不会乱码;字段里的逗号、引号、换行按 RFC 4180 用双引号包裹并转义。
export function buildCsv(rows) {
const lines = rows.map(row => row.map(escapeCsvCell).join(','))
return '\uFEFF' + lines.map(line => line + '\r\n').join('')
}
function escapeCsvCell(value) {
const text = value === null || value === undefined ? '' : String(value)
if (/[",\r\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
}
// 触发浏览器下载。用 Blob + a[download],不依赖任何第三方库。
export function downloadCsv(fileName, csv) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,21 @@
import { describe, expect, test } from 'vitest'
import { buildCsv } from './exportCsv'
// BOM 用转义写,不内联那个零宽字符:内联的话看不见也改不动,
// 一旦被编辑器或编码转换吃掉,测试就会以一个极难看懂的方式失败。
const BOM = '\uFEFF'
describe('buildCsv', () => {
test('用逗号分隔并在行尾换行', () => {
const csv = buildCsv([
['行号', '失败原因'],
[3, '物料名称不能为空']
])
expect(csv).toBe(BOM + '行号,失败原因\r\n3,物料名称不能为空\r\n')
})
test('包含逗号、引号、换行的字段用双引号包裹并转义', () => {
const csv = buildCsv([['规格', '备注'], ['M8,30', '含"引号"'], ['两\n行', '']])
expect(csv).toBe(BOM + '规格,备注\r\n"M8,30","含""引号"""\r\n"两\n行",\r\n')
})
})