Files
moc/src/views/docs/query/index.vue
T

333 lines
20 KiB
Vue
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.
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAppStore } from '@/store/modules/app';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import { getColorWithOpacity } from "@/utils/common";
const appStore = useAppStore();
const themeStore = useThemeStore();
const gap = computed(() => (appStore.isMobile ? 0 : 16));
const aq = ref<string>('');
const uploadFiles = () => {
window.$message?.success('演示:上传归档补充材料(限变更主管部门专责;材料单独存入「7-补充材料」,不改动原归档包,哈希留痕)')
}
interface ArchivePkg {
id: string; name: string; closedAt: string; size: string; files: number;
hash: string; revoked?: boolean; supplement?: boolean;
}
interface ArchiveUnit { unit: string; years: { year: string; pkgs: ArchivePkg[] }[] }
const ARCHIVE_TREE: ArchiveUnit[] = [
{
unit: "01-常减压装置",
years: [
{ year: "2024", pkgs: [
{ id: "MOC-2024-R01-0003", name: "减一线换热器 E-108 芯子更换", closedAt: "2024-04-18", size: "12.6MB", files: 19, hash: "3a91d7c4f0e2…5b17" },
{ id: "MOC-2024-R01-0007", name: "常压塔顶回流泵 P-105B 叶轮更换", closedAt: "2024-09-02", size: "9.8MB", files: 16, hash: "8e02b1a6d3c9…42fa" },
] },
{ year: "2025", pkgs: [
{ id: "MOC-2025-R01-0011", name: "初馏塔进料温度联锁值调整(已撤销)", closedAt: "2025-03-11", size: "7.4MB", files: 14, hash: "c5d8f2a1b7e4…9c03", revoked: true },
{ id: "MOC-2025-R01-0019", name: "电脱盐系统注水泵型号替换", closedAt: "2025-07-25", size: "15.2MB", files: 22, hash: "1f4a9c7e2b6d…d831", supplement: true },
{ id: "MOC-2025-R01-0023", name: "常压炉燃料气切断阀改造", closedAt: "2025-11-14", size: "21.0MB", files: 27, hash: "6b3e0d8a5f1c…77e2" },
] },
{ year: "2026", pkgs: [
{ id: "MOC-2026-R01-0028", name: "减顶真空泵 P-107A 电机更换", closedAt: "2026-06-30", size: "18.4MB", files: 24, hash: "b8e3a1c5d2f7…4c60" },
{ id: "MOC-2026-R01-0031", name: "常顶油气冷却器管束更新", closedAt: "2026-07-12", size: "16.9MB", files: 21, hash: "d2a7c4f88b1e…5a96" },
{ id: "MOC-2026-R01-0039", name: "进料泵 P-101A 更换为高效节能泵", closedAt: "2026-08-01", size: "18.4MB", files: 24, hash: "7c4f9be2a1d3…e804" },
] },
],
},
{
unit: "02-催化裂化装置",
years: [
{ year: "2025", pkgs: [
{ id: "MOC-2025-R02-0014", name: "再生器旋风分离器更换", closedAt: "2025-05-20", size: "26.3MB", files: 31, hash: "9c1e5b3d7a2f…b450" },
{ id: "MOC-2025-R02-0021", name: "气压机出口压力联锁逻辑修改", closedAt: "2025-10-08", size: "11.7MB", files: 18, hash: "4d8f0a2c6e9b…31cd" },
] },
{ year: "2026", pkgs: [
{ id: "MOC-2026-R02-0025", name: "分馏塔顶循回流管线扩径", closedAt: "2026-06-30", size: "14.5MB", files: 20, hash: "a7b2d9e4c1f8…6e05" },
] },
],
},
{
unit: "03-聚合装置",
years: [
{ year: "2026", pkgs: [
{ id: "MOC-2026-R03-0008", name: "聚合釜搅拌器密封形式变更", closedAt: "2026-05-19", size: "13.2MB", files: 17, hash: "e6f1a8c3b5d0…92d7" },
] },
],
},
];
const ARCHIVE_FOLDERS: { dir: string; files: { name: string; size: string }[] }[] = [
{ dir: "1-申请与审批", files: [
{ name: "变更申请表(终版).pdf", size: "486KB" },
{ name: "审批记录(含会签意见与留痕).pdf", size: "312KB" },
{ name: "等级判定表.pdf", size: "154KB" },
{ name: "原始附件(方案/会议纪要/数据表).zip", size: "2.8MB" },
] },
{ dir: "2-风险分析", files: [
{ name: "AI 风险预分析报告.pdf", size: "268KB" },
{ name: "JSA 作业安全分析表.pdf", size: "196KB" },
{ name: "HAZOP 分析记录.pdf", size: "742KB" },
{ name: "安全检查表(SCL.pdf", size: "188KB" },
] },
{ dir: "3-培训与考核", files: [
{ name: "变更培训材料(终版).pdf", size: "1.1MB" },
{ name: "培训签到表(照片).jpg", size: "2.4MB" },
{ name: "考核成绩单.xlsx", size: "32KB" },
] },
{ dir: "4-PSSR检查", files: [
{ name: "PSSR 检查清单(终版).pdf", size: "356KB" },
{ name: "现场确认照片.zip", size: "6.7MB" },
{ name: "电子签名确认记录.pdf", size: "142KB" },
] },
{ dir: "5-验收记录", files: [
{ name: "变更验收单.pdf", size: "204KB" },
{ name: "整改记录与复验结论.pdf", size: "168KB" },
] },
{ dir: "6-受控文件副本", files: [
{ name: "受控文件更新清单(新旧对照).pdf", size: "96KB" },
{ name: "PID-101_Rev.4_20260805.pdf", size: "3.2MB" },
{ name: "PID-101_Rev.3_20250101.pdf", size: "3.1MB" },
] },
];
const SUPPLEMENT_FOLDER = { dir: "7-补充材料(归档后补充)", files: [
{ name: "补充说明(泵轴振动复测数据).pdf", size: "214KB" },
{ name: "补充审批记录.pdf", size: "88KB" },
] };
const ROLE_SCOPE: Record<any, { text: string; filter: (u: any, p: any) => boolean }> = {
applicant: { text: "本人发起或参与的变更", filter: (u, p) => u.unit.startsWith("01") && ["MOC-2026-R01-0039", "MOC-2026-R01-0028", "MOC-2025-R01-0019"].includes(p.id) },
dept: { text: "本车间(装置)的全部变更", filter: (u) => u.unit.startsWith("01") },
process: { text: "本人参与审批或检查的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
process2: { text: "本人参与审批或检查的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
safety: { text: "全厂变更(安全监管需要)", filter: () => true },
vp: { text: "全厂变更", filter: () => true },
admin: { text: "全厂变更(系统管理:归档内容只读锁定,不可修改删除)", filter: () => true },
quality: { text: "本人参与会签或检验确认的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
viewer: { text: "全厂变更(信息查看角色:只读查阅)", filter: () => true },
};
const tF = ref("全部时间");
const oF = ref("全部组织");
const pF = ref("全部专业");
const FILTER_GROUPS = computed(() => [
{ label: "时间", opts: ["全部时间", "近一年", "近半年", "近三个月"], val: tF.value, set: (v: string) => { tF.value = v; } },
{ label: "组织", opts: ["全部组织", "一车间", "二车间", "三车间"], val: oF.value, set: (v: string) => { oF.value = v; } },
{ label: "专业", opts: ["全部专业", "工艺", "设备", "仪表", "管理"], val: pF.value, set: (v: string) => { pF.value = v; } },
]);
const scope = computed(() => ROLE_SCOPE['applicant'] ?? ROLE_SCOPE.vp);
const searching = computed(() => aq.value.trim().length > 0);
const filtering = computed(() => tF.value !== "全部时间" || oF.value !== "全部组织" || pF.value !== "全部专业");
const UNIT_ORG: Record<string, string> = { "01-常减压装置": "一车间", "02-催化裂化装置": "二车间", "03-聚合装置": "三车间" };
const profOf = (name: string) => /联锁|报警/.test(name) ? "仪表" : /规程|制度|管理/.test(name) ? "管理" : /泵|阀|电机|换热器|搅拌|密封|分离器|管束|冷却器|气压机|叶轮|芯子/.test(name) ? "设备" : "工艺";
const REF = new Date("2026-08-03");
const selId = ref("MOC-2026-R01-0039");
const passF = (p: (typeof allPkgs.value)[number]) => {
const daysOf = tF.value === "近一年" ? 365 : tF.value === "近半年" ? 183 : tF.value === "近三个月" ? 92 : 0;
return (daysOf === 0 || (REF.getTime() - new Date(p.closedAt).getTime()) / 86400000 <= daysOf) &&
(oF.value === "全部组织" || UNIT_ORG[p.unit] === oF.value) &&
(pF.value === "全部专业" || profOf(p.name) === pF.value);
};
const tree = computed(() => ARCHIVE_TREE
.map((u:any) => ({ unit: u.unit, years: u.years.map((y) => ({ year: y.year, pkgs: y.pkgs.filter((p:any) => scope.value.filter(u, p)) })).filter((y:any) => y.pkgs.length > 0) }))
.filter((u:any) => u.years.length > 0));
const allPkgs = computed(() => tree.value.flatMap((u:any) => u.years.flatMap((y:any) => y.pkgs.map((p:any) => ({ ...p, unit: u.unit, year: y.year })))));
const results = computed(() => (searching.value || filtering.value)
? allPkgs.value.filter((p:any) => passF(p) && (!searching.value || p.id.toLowerCase().includes(aq.value.trim().toLowerCase()) || p.name.includes(aq.value.trim())))
: []);
const sel = computed(() => allPkgs.value.find((p:any) => p.id === selId.value) ?? allPkgs.value[0]);
const selFolders = computed(() => (sel.value?.supplement ? [...ARCHIVE_FOLDERS, SUPPLEMENT_FOLDER] : ARCHIVE_FOLDERS));
const openUnits = ref<Record<string, boolean>>({ "01-常减压装置": true });
const openFolders = ref<Record<string, boolean>>({ "1-申请与审批": true });
const downloadZip = (sel:any) => {
window.$message?.success(`演示:下载 ${sel.id} 归档包 ZIP(含归档索引.txt)`)
}
const exportEvidenceChain = () => {
window.$message?.success('演示:导出证据链(哈希清单 + 操作日志)')
}
const applySupplement = () => {
window.$message?.info('演示:发起归档补充申请(补充材料单独存入 7-补充材料,不改动原归档)')
}
const viewIndex = () => {
window.$message?.info('演示:查看归档索引(文件清单 + 逐项 SHA-256')
}
const previewFile = (file:any) => {
window.$message?.info(`演示:在线预览 ${file.name}`)
}
const downloadFile = (file:any) => {
window.$message?.success(`演示:下载 ${file.name}`)
}
</script>
<template>
<NSpace vertical :size="16">
<!-- 我的草稿箱 -->
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
<NGi span="24 s:24 m:16">
<div class="h-100% flex items-center gap-2">
<Icon icon="mage:book" class="size-20px" :style="{ color: themeStore.themeColor }" />
<p class="text-base font-semibold">变更归档一次变更一个归档包 · 关闭即自动归档 · 只读锁定</p>
</div>
</NGi>
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
<div class="flex items-center gap-2">
<NButton size="small" type="primary" class="text-xs" @click="uploadFiles">
<Icon icon="material-symbols:upload" class="size-16px" /> 上传归档补充
</NButton>
</div>
</NGi>
</NGrid>
<div
class="mt-4 flex items-center gap-2 rounded-lg bg-[var(--bg-color)] px-3 py-2 text-xs text-[var(--theme-color)]"
:style="{
'--theme-color': themeStore.themeColor,
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
}"
>
<Icon icon="uil:lock" class="size-16px" />
当前角色调阅范围{{ scope.text }} · 归档包关闭时自动生成内容只读锁定并记录 SHA-256 哈希任何修改留痕可追责
</div>
<NInput v-model:value="aq" type="text" class="w-40 text-xs mt-3" placeholder="按变更单号精确查找,或按名称关键词检索(如 MOC-2026-R01-0039">
<template #suffix>
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
</template>
</NInput>
<!-- 筛选时间 / 组织 / 专业 -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 mt-3">
<div v-for="g in FILTER_GROUPS" :key="g.label" class="flex flex-wrap items-center gap-1">
<span class="text-[11px] text-slate-400">{{ g.label }}</span>
<NButton size="tiny" round v-for="o in g.opts" :key="o" @click="g.set(o)" class="text-[11px]" :type="g.val === o ? 'primary' : 'default'">{{ o }}</NButton>
</div>
</div>
<div class="grid gap-3 grid-cols-3 mt-3">
<!-- 三层目录树 / 筛选检索结果 -->
<div class="rounded-lg border p-2">
<div class="px-2 pb-1 text-xs font-medium text-slate-500">{{ (searching || filtering) ? `筛选 / 检索结果(${results.length}` : '归档区目录(装置 / 年份 / 变更单号)' }}</div>
<template v-if="searching || filtering">
<div v-if="results.length === 0" class="px-2 py-4 text-center text-xs text-slate-400">未找到匹配的归档包</div>
<button v-for="p in results" :key="p.id" @click="selId = p.id"
class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-[#DFE9F6]/60"
:class="sel?.id === p.id ? 'bg-[#DFE9F6] font-medium text-[#1D4E9C]' : ''">
<FolderArchive class="h-3.5 w-3.5 shrink-0 text-[#6E93C6]" />
<span class="truncate">{{ p.id }}</span>
<Tag v-if="p.revoked" class="border-red-200 bg-red-100 px-1 py-0 text-[10px] text-red-600">已撤销</Tag>
<Tag v-if="p.supplement" class="border-amber-200 bg-amber-100 px-1 py-0 text-[10px] text-amber-700">补充</Tag>
</button>
</template>
<template v-else>
<div v-for="u in tree" :key="u.unit">
<button @click="openUnits[u.unit] = !openUnits[u.unit]"
class="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
<Icon icon="formkit:right" class="size-14px transition-transform" :class="openUnits[u.unit] ? 'rotate-90' : ''" />
<Icon v-if="openUnits[u.unit]" icon="quill:folder-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
{{ u.unit }}
</button>
<template v-if="openUnits[u.unit]">
<div v-for="y in u.years" :key="y.year">
<div class="pl-7 pt-1 text-[11px] font-medium text-slate-400">{{ y.year }} {{ y.pkgs.length }}</div>
<button v-for="p in y.pkgs" :key="p.id" @click="selId = p.id"
class="flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-2 text-left text-xs hover:bg-[#DFE9F6]/60"
:class="sel?.id === p.id ? 'bg-[var(--bg-color)] font-medium text-[var(--theme-color)]' : 'bg-transparent'"
:style="{
'--theme-color': themeStore.themeColor,
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
}"
>
<Icon icon="lucide:folder-archive" class="size-14px" :style="{ color: themeStore.themeColor }" />
<span class="truncate">{{ p.id }}</span>
<NTag v-if="p.revoked" size="small" type="error" class="text-[10px]">已撤销</NTag>
<NTag v-if="p.supplement" size="small" type="warning" class="text-[10px]">补充</NTag>
</button>
</div>
</template>
</div>
</template>
</div>
<!-- 归档包详情 -->
<div v-if="sel" class="rounded-lg border col-span-2">
<div class="border-b p-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">{{ sel.id }}</span>
<NTag v-if="sel.revoked" size="small" type="error">已撤销保留归档备查</NTag>
<NTag v-else size="small" type="info">
<template #icon>
<Icon icon="uil:lock" class="size-14px" />
</template>
只读锁定
</NTag>
<NTag v-if="sel.supplement" size="small" type="warning">含归档补充</NTag>
</div>
<div class="mt-1 text-xs text-slate-600">{{ sel.name }}</div>
<div class="mt-2 flex flex-wrap justify-between gap-x-4 gap-y-1 text-[11px] text-slate-500">
<span>归档路径归档区/{{ sel.unit }}/{{ sel.year }}/{{ sel.id }}/</span>
<span>关闭时间:{{ sel.closedAt }}</span>
<span> {{ sel.files }} 个文件 · {{ sel.size }}</span>
<span class="flex items-center gap-1 font-mono">
<Icon icon="lucide:hash" class="size-12px" />
{{ sel.hash }}
</span>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<NButton size="small" class="text-xs" @click="downloadZip(sel)">
<Icon icon="material-symbols:download" class="size-14px" />
下载归档包 ZIP
</NButton>
<NButton size="small" class="text-xs" @click="exportEvidenceChain">导出证据链</NButton>
<NButton v-if="!sel.revoked" size="small" class="text-xs" @click="applySupplement">发起归档补充申请</NButton>
</div>
</div>
<div class="divide-y scroll-right">
<div v-for="f in selFolders" :key="f.dir">
<button @click="openFolders[f.dir] = !openFolders[f.dir]"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
<Icon icon="formkit:right" class="size-14px text-slate-400 transition-transform" :class="openFolders[f.dir] ? 'rotate-90' : ''" />
<Icon icon="quill:folder-open" v-if="openFolders[f.dir]" class="size-16px text-[#6E93C6]" />
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
{{ f.dir }}
<span class="ml-auto text-[11px] font-normal text-slate-400">{{ f.files.length }} 个文件</span>
</button>
<template v-if="openFolders[f.dir]">
<div v-for="file in f.files" :key="file.name" class="flex items-center gap-2 py-1.5 pl-10 pr-3 text-xs hover:bg-slate-50">
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="text-[11px] text-slate-400">{{ file.size }}</span>
<NButton text type="primary" @click="previewFile(file)">
<Icon icon="lucide:eye" class="size-14px" />
</NButton>
<NButton text type="primary" @click="downloadFile(file)">
<Icon icon="material-symbols:download" class="size-16px" />
</NButton>
</div>
</template>
</div>
<div class="flex items-center gap-2 px-3 py-2 text-xs hover:bg-slate-50">
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
<span class="flex-1 font-mono">归档索引.txt</span>
<span class="text-[11px] text-slate-400">清单与哈希</span>
<NButton text type="primary" @click="viewIndex">
<Icon icon="lucide:eye" class="size-14px" />
</NButton>
</div>
</div>
</div>
</div>
</NCard>
</NSpace>
</template>
<style scoped>
</style>
<style scoped lang="scss">
.scroll-right {
height: calc(100vh - 460px);
overflow-y: auto;
}
</style>