diff --git a/frontend/src/api/materials.js b/frontend/src/api/materials.js index c3d9ebf6..762efa5e 100644 --- a/frontend/src/api/materials.js +++ b/frontend/src/api/materials.js @@ -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() + }) } } diff --git a/frontend/src/utils/exportCsv.js b/frontend/src/utils/exportCsv.js new file mode 100644 index 00000000..83226d3a --- /dev/null +++ b/frontend/src/utils/exportCsv.js @@ -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) +} diff --git a/frontend/src/utils/exportCsv.test.js b/frontend/src/utils/exportCsv.test.js new file mode 100644 index 00000000..88549db3 --- /dev/null +++ b/frontend/src/utils/exportCsv.test.js @@ -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') + }) +})