2026-09-14 16:31:08 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<el-card>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<!-- 附件中心:集中查阅 + 下载/预览 + 逻辑删除 + 归档清理。
|
|
|
|
|
|
物理落盘统一为 <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) }})
|
2026-09-19 17:04:58 +08:00
|
|
|
|
<el-button v-if="disk.level === 'critical' && can('sys.attachment:manage')" link type="danger"
|
|
|
|
|
|
style="margin-left:8px" @click="doArchive">立即归档清理</el-button>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
|
2026-09-14 16:31:08 +08:00
|
|
|
|
<el-form inline style="margin-bottom:12px">
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<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>
|
2026-09-14 16:31:08 +08:00
|
|
|
|
<el-form-item label="业务类型">
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<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" />
|
2026-09-14 16:31:08 +08:00
|
|
|
|
</el-select>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item label="业务ID">
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<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>
|
2026-09-19 17:04:58 +08:00
|
|
|
|
<el-button v-if="can('sys.attachment:manage')" type="warning" plain :loading="archiving"
|
|
|
|
|
|
@click="doArchive">归档清理</el-button>
|
2026-09-14 16:31:08 +08:00
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-form>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
<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 label="相对路径" min-width="260" show-overflow-tooltip>
|
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
|
<el-link type="primary" :underline="false" @click="preview(row)">{{ row.filePath || '-' }}</el-link>
|
2026-09-18 18:54:54 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-table-column>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<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>
|
2026-09-19 17:04:58 +08:00
|
|
|
|
<el-button v-if="!row.deleted && can('sys.attachment:manage')" link type="danger" size="small"
|
|
|
|
|
|
@click="remove(row)">删除</el-button>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
</template>
|
2026-09-14 16:31:08 +08:00
|
|
|
|
</el-table-column>
|
|
|
|
|
|
</el-table>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-09-14 16:31:08 +08:00
|
|
|
|
</el-card>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<PageHelp :data="helpAttachment" page="Attachment" />
|
2026-09-14 16:31:08 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
2026-09-14 16:31:08 +08:00
|
|
|
|
import request from '../utils/request'
|
|
|
|
|
|
import { getToken } from '../utils/auth'
|
2026-09-19 17:04:58 +08:00
|
|
|
|
import { can } from '../utils/perm'
|
2026-09-19 10:54:15 +08:00
|
|
|
|
import { fmtTime } from '../utils/format'
|
|
|
|
|
|
import PageHelp from '../components/PageHelp.vue'
|
|
|
|
|
|
import { helpAttachment } from '../help'
|
2026-09-14 16:31:08 +08:00
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
// 文件类型(大写枚举)中文名
|
|
|
|
|
|
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([])
|
2026-09-14 16:31:08 +08:00
|
|
|
|
const list = ref([])
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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 })
|
2026-09-14 16:31:08 +08:00
|
|
|
|
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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 || '-' }
|
2026-09-14 16:31:08 +08:00
|
|
|
|
function fmtSize(n) {
|
|
|
|
|
|
n = n || 0
|
|
|
|
|
|
if (n < 1024) return n + ' B'
|
|
|
|
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
2026-09-19 10:54:15 +08:00
|
|
|
|
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')
|
2026-09-14 16:31:08 +08:00
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
// 下载:带鉴权走 id 路由,服务端给出中文文件名
|
2026-09-14 16:31:08 +08:00
|
|
|
|
async function download(row) {
|
|
|
|
|
|
const token = getToken()
|
2026-09-19 10:54:15 +08:00
|
|
|
|
const resp = await fetch('/api/v1/attachment/file/' + row.id + '?dl=1', { headers: { Authorization: 'Bearer ' + token } })
|
2026-09-14 16:31:08 +08:00
|
|
|
|
if (!resp.ok) { ElMessage.error('下载失败'); return }
|
|
|
|
|
|
const blob = await resp.blob()
|
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
|
const a = document.createElement('a')
|
|
|
|
|
|
a.href = url
|
|
|
|
|
|
a.download = row.fileName || 'file'
|
|
|
|
|
|
a.click()
|
|
|
|
|
|
URL.revokeObjectURL(url)
|
|
|
|
|
|
}
|
2026-09-19 10:54:15 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-14 16:31:08 +08:00
|
|
|
|
onMounted(load)
|
|
|
|
|
|
</script>
|
2026-09-19 10:54:15 +08:00
|
|
|
|
<style scoped>
|
|
|
|
|
|
.pager { margin-top: 12px; display: flex; justify-content: flex-end; }
|
|
|
|
|
|
</style>
|