feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新

1.  新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警
2.  重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑
3.  对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库
4.  实现入库单作废功能与区域库位的多级父子结构管理
5.  删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径
6.  新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
This commit is contained in:
SunYF
2026-09-19 10:54:15 +08:00
parent 9002d9a642
commit 62e45b7379
95 changed files with 10467 additions and 2677 deletions
+24 -3
View File
@@ -7,16 +7,17 @@ export const helpProductType = {
overview:
'物料档案 = 本页是 WMS「基础数据(物料主数据)」中"成品"品类在本系统的视图,与 WMS 指向同一份主数据(以 WMS 为准,实时拉取、不双录)。本页承载的是"要生产哪些成品",是整条产线的源头基础数据。\n\n品类来源(与 WMS 一致):原材料=进货来的(外购);半成品、成品=自己生产的;其他=辅料/工装/试验设备等。成品还可以作为零部件,放进另一个成品的物料清单(BOM)里被再次加工成新的成品。\n\n主数据链条:物料档案(成品) → 工单(选成品+物料清单) → 物料清单(给该产品编号配料) → 日排产 → 备料单 → 库房出库 → 产线生产 → 完工成品自动回流 WMS 入库。\n\n关系说明(几对几):\n· 成品 ↔ 工单:1 对 N。一个成品可以建多个工单(同一型号今天做一批、明天做一批,工单号不同、产品编号相同)。\n· 成品 ↔ 物料清单:1 对多(选用制)。同一成品可并存多份物料清单(不同厂家供货/含半成品结构),用「物料清单名称」区分,工单建单时选定一份,备料/装机都按工单锁定的那份算。\n· 成品 ↔ 备料单:间接。备料单由「日排产 × 工单锁定的物料清单」算出,与成品无直接约束。\n· 强约束:工单/物料清单都挂在产品编号下,成品编码变了,下游关联会断,建议建好后不要改。\n· 与 WMS 的关系:两套系统解耦。物料/图号主数据以 WMS 为单一来源,MES 实时拉取(WMS 不可达时降级为本地只读缓存);成品完工后由系统自动生成 SN 回流 WMS 入库,无需人工对账。',
fields: [
{ name: '名称', source: '手工填写。用户自定义,无上游来源。', purpose: '成品的中文名,用于识别与下拉展示', fill: '如:智能电表、断路器、配电柜' },
{ name: '名称', source: '手工填写(必填)。用户自定义,无上游来源。', purpose: '成品的中文名,用于识别与下拉展示', fill: '如:智能电表、断路器、配电柜' },
{
name: '编码',
source: '系统自动生成 + 可手动修改。新建时会自动带出(格式 PROD-YYYYMMDD-序号),你可直接使用或改为自定义编码;编辑时保留已存值。一个产品编号可对应多个工单(同一型号今天做一批、明天做一批,工单号不同,产品编号相同)。',
purpose: '产品唯一编码。它是 MES 内部识别该产品的标识,决定该产品的物料清单内容,也是工单、备料、拧紧、追溯的挂靠键。',
fill: '建议用有意义的编码,如 METER-01。要点:①编码主数据以 WMS(库房) 为单一来源,本系统实时从 WMS 拉取物料/成品档案;WMS 不可达时降级为本地只读缓存;②成品完工后系统自动生成 SN 并回流 WMS 入库,无需人工对账;③如产品编号就是成品编码,则直接沿用该编码即可;④建好后不要随意改,否则下游工单/追溯的关联会断。'
},
{ name: '规格', source: '手工填写(必填)。与 WMS 物料档案必填口径一致。', purpose: '成品的规格型号,用于识别与打印', fill: '如:500×300×200' },
{ name: '单位', source: '手工填写(必填)。与 WMS 物料档案必填口径一致。', purpose: '计量单位', fill: '如:台 / 套' },
{ name: '类别', source: '手工填写。可选,无上游来源。', purpose: '产品所属大类,便于分类统计', fill: '如:计量表 / 开关柜;可空' },
{ name: '启用', source: '开关选择。默认开启。', purpose: '启用后才允许在新建工单/备料的下拉里被选到', fill: '默认开启;暂时停产的可关闭(不删除,保留档案)' },
{ name: '备注', source: '手工填写。可选。', purpose: '补充说明', fill: '可不填' }
{ name: '启用', source: '开关选择。默认开启。', purpose: '启用后才允许在新建工单/备料的下拉里被选到', fill: '默认开启;暂时停产的可关闭(不删除,保留档案)' }
]
}
@@ -449,3 +450,23 @@ export const helpAlertCenter = {
{ name: '标记已读', source: '人工在本页操作。', purpose: '处置完成后闭环该预警', fill: '核对处置后点「标记已读」' }
]
}
export const helpAttachment = {
title: '附件中心(统一存储与查阅)',
overview:
'附件中心集中登记并查阅全系统附件(图纸、检验照片、检验报告、工艺流程、流程卡、包装照片、Excel 导入等),新增在各业务页面内完成,本页负责查阅、下载、删除与归档清理。\n\n统一存储规则:所有附件按「附件根目录/年/月/日/文件类型/文件」分层存放,文件类型目录全部大写(PROCESS_PDF、INSPECTION_PHOTO、INSPECTION_PDF、DRAWING、PROCESS_CARD、PACKAGE_PHOTO、EXCEL_IMPORT、OTHER);文件名统一用 UUID,扩展名小写,不含中文与特殊字符;数据库只存相对路径(如 2026/09/19/INSPECTION_PHOTO/xxx.jpg),不存绝对路径。附件根目录由配置文件 Upload.Dir 指定,不硬编码。\n\n上传为流式写入(边写边算 MD5),同业务对象同 MD5 自动秒传不重复占空间;单文件大小按文件类型配置上限(默认 20MB)。\n\n删除为逻辑删除:库内标记「已删除」并记录删除人/时间,物理文件保留,勾选「含已删除」可追溯。\n\n归档清理:本地只保留最近「配置的年数」(默认 1 年),更早的附件可一键归档到外部备份目录并标记「已归档」,释放本地磁盘。\n\n磁盘空间监控:系统每天早上 7 点自动检测磁盘剩余空间,剩余低于 20% 告警、低于 10% 提示执行备份归档清理(本页顶部同步横幅提示,critical 时可直接点「立即归档清理」)。',
fields: [
{ name: '文件类型', source: '上传时由业务场景指定或按扩展名推断(大写枚举)。', purpose: '决定存储目录分层,也是查阅时的主要筛选维度', fill: '下拉单选八类之一,留空=全部' },
{ name: '业务类型', source: '上传时随附件记录写入(工单/物料/工艺流程/检验/入库 等)。', purpose: '区分附件挂在哪种业务单据上', fill: '下拉单选,留空=全部' },
{ name: '业务ID', source: '上传时写入的业务键(工单号/图号/SN)。', purpose: '定位某个具体单据的全部附件', fill: '支持模糊匹配,如输入工单号' },
{ name: '文件名', source: '上传时的原始文件名。', purpose: '按名称模糊查找', fill: '支持模糊匹配' },
{ name: '上传时间', source: '系统记录上传时刻。', purpose: '按时间区间缩小范围', fill: '可选起止日期,留空=不限' },
{ name: '含已删除', source: '勾选后包含逻辑删除的记录。', purpose: '追溯已删除附件', fill: '默认不勾选' },
{ name: '相对路径', source: '系统按统一规则生成(年/月/日/类型/UUID.ext)。', purpose: '直观看到物理存放位置;点击即在线预览', fill: '只读' },
{ name: '大小', source: '系统统计上传达字节数。', purpose: '评估占用空间', fill: '只读' },
{ name: '状态', source: '系统维护(有效/已归档/已删除)。', purpose: '区分当前可用的与已归档/已删除的附件', fill: '只读' },
{ name: '预览 / 下载', source: '预览走免鉴权直链(PDF/图片浏览器内联打开);下载走鉴权路由并带回中文文件名。', purpose: '查看或另存附件', fill: '点击对应按钮' },
{ name: '删除', source: '人工在本页操作(逻辑删除,需二次确认)。', purpose: '移除无效附件(保留可追溯索引)', fill: '点「删除」并确认' },
{ name: '归档清理', source: '人工在本页操作(先预览数量再确认执行)。', purpose: '把早于本地保留年数的附件移到外部备份目录并标记已归档,释放磁盘', fill: '点「归档清理」→确认' }
]
}
+209 -33
View File
@@ -1,63 +1,202 @@
<template>
<el-card>
<!-- 附件中心仅作集中查阅只读新增/删除在各业务页面内完成如检验页工序步骤行
本页展示文件路径点击路径可直接下载查看 -->
<el-alert type="info" :closable="false" show-icon style="margin-bottom:12px"
title="附件中心为只读查阅页:新增/删除附件请在各自业务页面(如质量检验、工序步骤)内操作" />
<!-- 附件中心集中查阅 + 下载/预览 + 逻辑删除 + 归档清理
物理落盘统一为 <Upload.Dir>////<文件类型>/<uuid>.<ext>库内只存相对路径 -->
<el-alert v-if="disk.level !== 'ok'" :type="disk.level === 'critical' ? 'error' : 'warning'"
:closable="false" show-icon style="margin-bottom:12px"
:title="disk.message || '磁盘空间告警'">
<template #default>
<div style="font-size:12px;line-height:1.7">
附件根目录{{ disk.rootDir }}剩余 {{ freePercent }}%剩余 {{ fmtSize(disk.freeBytes) }} / {{ fmtSize(disk.totalBytes) }}
<el-button v-if="disk.level === 'critical'" link type="danger" style="margin-left:8px"
@click="doArchive">立即归档清理</el-button>
</div>
</template>
</el-alert>
<el-form inline style="margin-bottom:12px">
<el-form-item label="文件类型">
<el-select v-model="form.fileType" clearable placeholder="全部" style="width:150px">
<el-option v-for="(label, val) in FILE_TYPES" :key="val" :label="label" :value="val" />
</el-select>
</el-form-item>
<el-form-item label="业务类型">
<el-select v-model="form.bizType" clearable style="width:160px" placeholder="全部">
<el-option label="工单" value="work_order" />
<el-option label="物料" value="material" />
<el-option label="工艺流程" value="process_flow" />
<el-option label="检验" value="inspection" />
<el-option label="质量" value="quality" />
<el-select v-model="form.bizType" clearable placeholder="全部" style="width:150px">
<el-option v-for="(label, val) in BIZ_TYPES" :key="val" :label="label" :value="val" />
</el-select>
</el-form-item>
<el-form-item label="业务ID">
<el-input v-model="form.bizId" placeholder="如 工单号 / 图号" style="width:220px" @keyup.enter="load" />
<el-input v-model="form.bizId" placeholder="如 工单号 / 图号 / SN" clearable style="width:190px"
@keyup.enter="search" />
</el-form-item>
<el-form-item label="文件名">
<el-input v-model="form.fileName" placeholder="模糊匹配" clearable style="width:160px" @keyup.enter="search" />
</el-form-item>
<el-form-item label="上传时间">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
range-separator="" start-placeholder="开始日期" end-placeholder="结束日期" style="width:240px" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.includeDeleted">含已删除</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button type="warning" plain :loading="archiving" @click="doArchive">归档清理</el-button>
</el-form-item>
<el-form-item><el-button type="primary" @click="load">查询</el-button></el-form-item>
</el-form>
<el-table :data="list" border size="small">
<el-table-column prop="fileName" label="文件名" min-width="180" />
<el-table-column label="文件路径" min-width="260">
<template #default="{row}">
<el-link type="primary" :underline="false" @click="download(row)">{{ row.filePath || '-' }}</el-link>
<el-table :data="list" border size="small" v-loading="loading" empty-text="暂无附件">
<el-table-column type="index" label="序号" width="60" align="center"
:index="(i) => (page.current - 1) * page.size + i + 1" />
<el-table-column prop="fileName" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="文件类型" width="120" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ fileTypeText(row.fileType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="fileSize" label="大小" width="110">
<template #default="{row}">{{ fmtSize(row.fileSize) }}</template>
<el-table-column label="相对路径" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="preview(row)">{{ row.filePath || '-' }}</el-link>
</template>
</el-table-column>
<el-table-column label="大小" width="100" align="right">
<template #default="{ row }">{{ fmtSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column label="业务类型" width="110" align="center">
<template #default="{ row }">{{ bizTypeText(row.bizType) }}</template>
</el-table-column>
<el-table-column prop="bizId" label="业务ID" width="150" show-overflow-tooltip />
<el-table-column prop="uploader" label="上传人" width="100" align="center">
<template #default="{ row }">{{ row.uploader || '-' }}</template>
</el-table-column>
<el-table-column label="上传时间" width="155" align="center">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.deleted" size="small" type="danger">已删除</el-tag>
<el-tag v-else-if="row.archived" size="small" type="warning">已归档</el-tag>
<el-tag v-else size="small" type="success">有效</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="preview(row)">预览</el-button>
<el-button link type="success" size="small" @click="download(row)">下载</el-button>
<el-button v-if="!row.deleted" link type="danger" size="small" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
<el-table-column prop="bizType" label="业务类型" width="120" />
<el-table-column prop="bizId" label="业务ID" width="140" />
<el-table-column prop="uploader" label="上传人" width="100" />
<el-table-column prop="createdAt" label="上传时间" min-width="170" />
</el-table>
<div class="pager">
<el-pagination background layout="total, sizes, prev, pager, next" :total="total"
:current-page="page.current" :page-size="page.size" :page-sizes="[20, 50, 100]"
@current-change="(p) => { page.current = p; load() }"
@size-change="(s) => { page.size = s; page.current = 1; load() }" />
</div>
</el-card>
<PageHelp :data="helpAttachment" page="Attachment" />
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
import { getToken } from '../utils/auth'
import { fmtTime } from '../utils/format'
import PageHelp from '../components/PageHelp.vue'
import { helpAttachment } from '../help'
const form = reactive({ bizType: '', bizId: '' })
const list = ref([])
async function load() {
list.value = (await request.get('/attachments', { params: { bizType: form.bizType, bizId: form.bizId } })) || []
// 文件类型(大写枚举)中文名
const FILE_TYPES = {
PROCESS_PDF: '工艺流程',
INSPECTION_PHOTO: '检验照片',
INSPECTION_PDF: '检验报告',
DRAWING: '图纸',
PROCESS_CARD: '流程卡',
PACKAGE_PHOTO: '包装照片',
EXCEL_IMPORT: 'Excel导入',
OTHER: '其他'
}
// 业务类型中文名
const BIZ_TYPES = {
work_order: '工单',
material: '物料',
process_flow: '工艺流程',
inspection: '检验',
quality: '质量',
inbound: '入库',
semi: '半成品',
outbound: '出库',
attachment: '系统'
}
const form = reactive({ fileType: '', bizType: '', bizId: '', fileName: '', includeDeleted: false })
const dateRange = ref([])
const list = ref([])
const total = ref(0)
const page = reactive({ current: 1, size: 20 })
const loading = ref(false)
const archiving = ref(false)
const disk = reactive({ level: 'ok', message: '', rootDir: '', usedPercent: 0, freeBytes: 0, totalBytes: 0 })
const freePercent = computed(() => Math.max(0, 100 - (disk.usedPercent || 0)))
function fileTypeText(v) { return FILE_TYPES[v] || v || '-' }
function bizTypeText(v) { return BIZ_TYPES[v] || v || '-' }
function fmtSize(n) {
n = n || 0
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(1) + ' MB'
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
async function load() {
loading.value = true
try {
const data = await request.get('/attachment/all', {
params: {
fileType: form.fileType,
bizType: form.bizType,
bizId: form.bizId.trim(),
fileName: form.fileName.trim(),
startDate: dateRange.value?.[0] || '',
endDate: dateRange.value?.[1] || '',
includeDeleted: form.includeDeleted ? 'true' : 'false',
page: page.current,
pageSize: page.size
}
})
list.value = data?.list || []
total.value = data?.total || 0
Object.assign(disk, data?.disk || {})
} finally {
loading.value = false
}
}
function search() { page.current = 1; load() }
function resetFilters() {
form.fileType = ''
form.bizType = ''
form.bizId = ''
form.fileName = ''
form.includeDeleted = false
dateRange.value = []
search()
}
// 预览:走免鉴权直链(UUID 文件名不可枚举),PDF/图片浏览器内联打开
function preview(row) {
if (!row.previewUrl) return ElMessage.warning('无预览地址')
window.open(row.previewUrl, '_blank')
}
// 下载:带鉴权走 id 路由,服务端给出中文文件名
async function download(row) {
if (!row.filePath) return
const token = getToken()
const resp = await fetch('/api/v1/attachment/download?name=' + encodeURIComponent(row.filePath), { headers: { Authorization: 'Bearer ' + token } })
const resp = await fetch('/api/v1/attachment/file/' + row.id + '?dl=1', { headers: { Authorization: 'Bearer ' + token } })
if (!resp.ok) { ElMessage.error('下载失败'); return }
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
@@ -67,5 +206,42 @@ async function download(row) {
a.click()
URL.revokeObjectURL(url)
}
async function remove(row) {
try {
await ElMessageBox.confirm(`确认删除附件「${row.fileName}」吗?删除后可在「含已删除」中追溯。`, '操作确认', {
confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning'
})
} catch { return }
await request.post('/attachment/delete', { id: row.id })
ElMessage.success('删除成功')
await load()
}
// 归档:先 dryRun 预览数量,确认后执行(早于本地保留年数的附件移动到外部备份目录并标记已归档)
async function doArchive() {
const pre = await request.post('/attachment/archive', { dryRun: true })
if (!pre?.marked) return ElMessage.info('没有需要归档的附件(早于 ' + pre?.cutYear + ' 年的记录)')
const tip = pre.archiveDir
? `将把 ${pre.marked} 个早于 ${pre.cutYear} 年的附件移动到备份目录:\n${pre.archiveDir}`
: `将把 ${pre.marked} 个早于 ${pre.cutYear} 年的附件标记为已归档(未配置备份目录,不移动物理文件)`
try {
await ElMessageBox.confirm(tip + '\n\n确认执行归档吗?', '归档确认', {
confirmButtonText: '确认归档', cancelButtonText: '取消', type: 'warning'
})
} catch { return }
archiving.value = true
try {
const res = await request.post('/attachment/archive', { dryRun: false })
ElMessage.success(`归档完成:移动 ${res?.moved || 0},标记 ${res?.marked || 0},失败 ${res?.failed || 0}`)
await load()
} finally {
archiving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pager { margin-top: 12px; display: flex; justify-content: flex-end; }
</style>
@@ -30,8 +30,11 @@
<el-table-column prop="isActive" label="启用" width="80">
<template #default="{row}"><el-tag :type="row.isActive?'success':'info'">{{ row.isActive?'是':'否' }}</el-tag></template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140">
<template #default="{row}">{{ row.remark || '-' }}</template>
<el-table-column prop="spec" label="规格" min-width="120">
<template #default="{row}">{{ row.spec || '-' }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="80" align="center">
<template #default="{row}">{{ row.unit || '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{row}">{{ row.createdAt ? fmtTime(row.createdAt) : '-' }}</template>
@@ -53,9 +56,10 @@
<el-form-item label="编码">
<el-input v-model="dialog.code" :disabled="!!dialog.id" placeholder="留空由系统生成 PROD-日期-流水(创建后不可改)" />
</el-form-item>
<el-form-item label="规格"><el-input v-model="dialog.spec" placeholder="如 500×300×200" /></el-form-item>
<el-form-item label="单位"><el-input v-model="dialog.unit" placeholder="如 台/套" /></el-form-item>
<el-form-item label="类别"><el-input v-model="dialog.category" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="dialog.isActive" /></el-form-item>
<el-form-item label="备注"><el-input v-model="dialog.remark" type="textarea" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.show=false">取消</el-button>
@@ -83,7 +87,7 @@ const page = ref(1)
const pageSize = ref(20)
const wmsOnline = ref(true)
const filters = reactive({ code: '', name: '', category: '', isActive: '', startDate: '', endDate: '' })
const dialog = reactive({ show: false, id: 0, name: '', code: '', category: '', remark: '', isActive: true })
const dialog = reactive({ show: false, id: 0, name: '', code: '', spec: '', unit: '', category: '', isActive: true })
function defaultRange() {
const d = new Date()
@@ -117,13 +121,17 @@ async function load() {
function search() { page.value = 1; load() }
async function openAdd() {
Object.assign(dialog, { show: true, id: 0, name: '', code: '', category: '', remark: '', isActive: true })
Object.assign(dialog, { show: true, id: 0, name: '', code: '', spec: '', unit: '', category: '', isActive: true })
}
function openEdit(row) { Object.assign(dialog, { show: true, id: row.id, name: row.name, code: row.code, category: row.category, remark: row.remark, isActive: row.isActive }) }
function openEdit(row) { Object.assign(dialog, { show: true, id: row.id, name: row.name, code: row.code, spec: row.spec || '', unit: row.unit || '', category: row.category, isActive: row.isActive }) }
async function save() {
if (!dialog.name) return ElMessage.warning('名称必填')
if (dialog.id) await request.put('/product-types', { id: dialog.id, name: dialog.name, category: dialog.category, remark: dialog.remark, isActive: dialog.isActive })
else await request.post('/product-types', { code: dialog.code, name: dialog.name, category: dialog.category, remark: dialog.remark, isActive: dialog.isActive })
// 必填口径与 WMS 物料档案一致(2026-09-19):除类别外均必填
if (!trim(dialog.name)) return ElMessage.warning('名称为必填项')
if (!trim(dialog.spec)) return ElMessage.warning('规格型号为必填项')
if (!trim(dialog.unit)) return ElMessage.warning('单位为必填项')
const body = { name: trim(dialog.name), spec: trim(dialog.spec), unit: trim(dialog.unit), category: dialog.category, isActive: dialog.isActive }
if (dialog.id) await request.put('/product-types', { id: dialog.id, ...body })
else await request.post('/product-types', { code: dialog.code, ...body })
ElMessage.success('保存成功')
dialog.show = false
load()
@@ -326,7 +326,8 @@ async function viewAttachments(row) {
}
async function downloadAttachment(row) {
const token = getToken()
const resp = await fetch('/api/v1/attachment/download?name=' + encodeURIComponent(row.filePath), {
// 统一存储后按附件 id 下载(服务端回中文文件名 + Content-Disposition
const resp = await fetch('/api/v1/attachment/file/' + row.id + '?dl=1', {
headers: { Authorization: 'Bearer ' + token }
})
if (!resp.ok) { ElMessage.error('下载失败'); return }