Files
moc/src/views/my/initiateChange/index.vue
T

1823 lines
115 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 { computed, ref } from 'vue';
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme';
import {
CHANGES, type ChangeLevel, type ChangeOrder, type ChangeStatus, type ChangeType,
} from "@/typings/model";
import { getColorWithOpacity } from "@/utils/common";
import {
AiError,
aiApplication,
aiLevelScore,
aiRecognize,
aiRiskOrganize,
aiRiskPreAnalysis,
aiTypeJudge,
type CompareRow,
} from "@/service/api/ai";
import HighlightText from "./modules/HighlightText.vue";
import Ed from "./modules/Ed.vue";
import Hazop from "./modules/hazop.vue";
import Jsa from "./modules/jsa.vue";
import Scl from "./modules/scl.vue";
import RiskCheck from "./modules/riskCheck.vue";
const themeStore = useThemeStore();
interface ApplyForm {
name: string;
dept: string;
mainChanges: string[];
related: string;
purposes: string[];
effect: string;
type: string;
level: string;
duration: string;
urgent: boolean;
date: any;
restoreDate: any;
}
const form = ref<ApplyForm>({
name: "", dept: "", mainChanges: [""], related: "", purposes: [], effect: "",
type: "", level: "", duration: "", urgent: false, date: null, restoreDate: null,
});
const tab = ref<string>("pre");
const TABS = [
{ id: "pre", name: "变更预识别", icon: 'lucide:sparkles' },
{ id: "form", name: "变更申请表", icon: 'akar-icons:file' },
{ id: "risk", name: "风险分析记录表", icon: 'proicons:alert-triangle' },
{ id: "train", name: "变更培训内容", icon: 'lucide:graduation-cap' },
{ id: "pssr", name: "PSSR 检查内容", icon: 'tabler:clipboard-check' },
{ id: "accept", name: "验收评价", icon: 'material-symbols:check-circle-outline' },
{ id: "close", name: "变更关闭确认表", icon: 'quill:folder-open' },
];
const confirmed = ref<Record<string, boolean>>({});
const blockedTabs = ref<string[]>([]);
const aiEnabled = ref<boolean>(true);
const analyzing = ref<boolean>(false);
// ---------- 变更预识别 ----------
const desc = ref<string>(
"某缩合反应现在控在80℃要反应8小时,车间反馈产能跟不上。我们小试做了几组,90℃下4小时转化率就能到98%,但有个未知杂质会从0.05%涨到0.12%。这个杂质结构还没定,不过不是基因毒性警示结构。想申请把温度提到90℃,同时把反应时间缩到4小时。",
);
const rows = ref<any>(null);
const AI_ROWS = [
{
id: 1, item: "工艺流程",
before: "流程简述(含物料、设备):某缩合反应,原料A与原料B在缩合反应釜中混合,加热至80℃进行保温反应8小时,反应完成后转入后处理工序。\n设计意图:通过缩合反应制得目标产物,保证转化率和杂质水平。",
after: "流程简述(含物料、设备):保温温度提升至90℃,反应时间缩短为4小时,其余流程不变。\n设计意图:在保持转化率98%的前提下缩短反应时间、提升产能。",
hl: ["保温温度提升至90℃,反应时间缩短为4小时", "在保持转化率98%的前提下缩短反应时间、提升产能"],
},
{
id: 2, item: "设备信息",
before: "基本信息(名称、设计参数、材质、用途):缩合反应釜,设计温度100℃,设计压力0.2MPa,材质SS304,用于缩合反应。\n使用信息(安装位置、连接方式、维护策略):安装于反应工段,夹套加热,按预防性维护计划定期检查。",
after: "无变化",
},
{
id: 3, item: "运行条件",
before: "运行参数:反应温度80±2℃,保温时间8小时,压力常压。\n管控措施:DCS温度高报警设定85℃,高高报警90℃并联锁切断加热介质。",
after: "运行参数:反应温度改为90±2℃,保温时间改为4小时,压力不变。\n管控措施:DCS温度高报警需上调至95℃,高高报警及联锁值需上调至100℃。",
hl: ["90±2℃", "4小时", "DCS温度高报警需上调至95℃,高高报警及联锁值需上调至100℃"],
},
{ id: 4, item: "操作方式", before: "DCS自动化控制,远程操作,单人监控。", after: "无变化" },
];
// AI 调用失败统一提示:不阻塞页面、不清空已有数据,保留手动编辑能力(P1)
const aiFail = (e: any) => {
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
else window.$message?.warning("AI 分析失败,可手动填写");
};
// 页面当前对比行 → AI 契约行
const compareRows = (): CompareRow[] => (rows.value ?? []).map((x: any) => ({ item: x.item, before: x.before, after: x.after, hl: x.hl }));
// ---------- AI 变更识别 ----------
const runIdentify = async () => {
if (!desc.value.trim()) { window.$message?.warning("请先输入变更内容描述"); return; }
analyzing.value = true;
rows.value = null;
try {
const res = await aiRecognize(desc.value);
rows.value = (res?.rows ?? []).map((x, i) => ({ id: i + 1, item: x.item, before: x.before, after: x.after, hl: x.hl }));
window.$message?.success(`AI 变更识别完成,共 ${rows.value.length} 项,请逐项人工确认(可直接修改)`);
} catch (e: any) {
rows.value = AI_ROWS.map((x) => ({ ...x }));
aiFail(e);
} finally {
analyzing.value = false;
}
};
const editing = ref<any>(null);
// ---------- 风险预分析 ----------
const preRiskEdit = ref<any>(null);
const preRiskShown = ref<boolean>(false);
const preRiskText = ref({
severity: "本次变更为缩合反应温度由80℃提升至90℃、保温时间由8h缩短至4h。变更前后反应体系与物料不变,不新增化学品,不存在新增燃爆、毒害、腐蚀等危害严重性变化;温度提升后仍在溶剂沸点与物料分解温度的安全裕度之内。综上,本次变更未显著增加潜在事件的危害严重程度,事故后果(人员伤亡、财产损失、环境污染)的严重等级与变更前基本一致;但杂质水平由0.05%升至0.12%,需关注长期运行对产品质量与设备结垢的累积影响。",
probability: "变更引入了新的偏差场景。首先,反应温度更接近溶剂回流温度,温控失效或冷却水中断时,超温溜温概率上升,可能引发冲料;其次,保温时间缩短使反应终点判断窗口变窄,人为误判导致转化率不足或副反应增加的概率上升;再者,未知杂质含量升高(0.05%→0.12%),其热稳定性尚未定性,长期累积可能加速釜壁结垢与搅拌负荷异常;此外,DCS报警/联锁值上调后原有报警裕度改变,误报、漏报概率需重新评估。",
protection: "变更主要影响与温度控制相关的保护层。原DCS温度高报警85℃、高高报警及联锁90℃已不适用于新工艺窗口,需同步上调至95℃/100℃并重新整定验证;安全阀起跳压力与泄放能力不受本次温度调整直接影响,仍然有效;操作规程与应急预案须修订后,方可继续作为有效的人为保护层。建议投用前完成联锁测试与报警验证,并将杂质含量纳入日常分析计划。",
});
const runRiskAnalysis = async () => {
analyzing.value = true;
try {
const res = await aiRiskPreAnalysis(desc.value, compareRows());
preRiskText.value = {
severity: res?.severity ?? preRiskText.value.severity,
probability: res?.probability ?? preRiskText.value.probability,
protection: res?.protection ?? preRiskText.value.protection,
};
preRiskShown.value = true;
window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
} catch (e: any) {
preRiskShown.value = true;
aiFail(e);
} finally {
analyzing.value = false;
}
};
const saveDraft = () => window.$message?.success("已保存为草稿,可在左侧「我的草稿」中继续编辑");
// ---------- 生成变更申请单 ----------
const formGen = ref<string>("idle");
const materialsText = ref("");
const updateDocs = ref<string[]>([]);
const disciplines = ref<string[]>([]);
const tools = ref<string[]>(["HAZOP","JSA", "SCL", "RISK_CHECK"]);
const UPDATE_DOC_OPTIONS = ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案'];
const DISCIPLINE_OPTIONS = ['技术', '仪表', '电气', '环保', '质量'];
const genForm = async () => {
if (formGen.value === "generating") return;
formGen.value = "generating";
window.$message?.info("AI 正在生成变更申请单…(「变更申请表」标签已显示生成中标识)");
try {
const res = await aiApplication(desc.value, compareRows());
const pick = (v: string | null | undefined, opts: string[], cur: string) => (v && opts.includes(v) ? v : cur);
const main = (res?.main_changes ?? []).filter((x) => x && x.trim());
const purposes = (res?.purposes ?? []).filter((x) => PURPOSE_OPTIONS.includes(x));
const docs = (res?.update_docs ?? []).filter((x) => UPDATE_DOC_OPTIONS.includes(x));
const discs = (res?.disciplines ?? []).filter((x) => DISCIPLINE_OPTIONS.includes(x));
const aiTools = (res?.risk_tools ?? [])
.map((x) => RISK_TOOLS_FORM.find((t) => t.key === x || t.label === x)?.key)
.filter((x): x is string => !!x);
form.value = {
...form.value,
name: res?.name || form.value.name,
mainChanges: main.length ? main : form.value.mainChanges,
related: (res?.related_changes ?? []).filter(Boolean).join("、") || form.value.related,
purposes: purposes.length ? purposes : form.value.purposes,
effect: res?.effect || form.value.effect,
type: pick(res?.change_type, ['工艺', '设备', '管理'], form.value.type),
duration: pick(res?.duration_suggestion, ['永久', '临时'], form.value.duration),
};
if (res?.materials_text) materialsText.value = res.materials_text;
if (docs.length) updateDocs.value = docs;
if (discs.length) {
disciplines.value = discs;
toggleDiscipline(discs);
}
if (res?.signers && Object.keys(res.signers).length) signers.value = { ...res.signers };
if (aiTools.length) tools.value = aiTools;
currentPlugin.value = "";
formGen.value = "done";
tab.value = "form";
window.$message?.success("变更申请单已生成:AI 自动总结名称、预填内容,均可手动修改");
} catch (e: any) {
formGen.value = "idle";
aiFail(e);
}
};
// ---------- 审批流程设置 ----------
const approvers = ref<Record<string, string>>({
部门审核: "李主任(一车间主任)",
最终批准: "李主任(部门负责人 · 固定)",
});
const signers = ref<Record<string, string>>({});
const sheet = ref<"type" | "level" | "approver" | null>(null);
let demoSeq = 43;
function nextChangeId() {
return `MOC-2026-R01-${String(demoSeq++).padStart(4, "0")}`;
}
const today = () => new Date().toISOString().slice(0, 10);
interface LedgerRow {
id: string;
changeId?: string; // 关联 CHANGES,可打开完整详情
title: string;
type: ChangeType;
level: ChangeLevel;
duration: "永久" | "临时";
urgent?: boolean;
applyTime: string; // 申请时间
dept: string; // 申请部门
applicant: string; // 申请人
planUse: string; // 计划投用时间
status: ChangeStatus;
statusDetail: string; // 当前环节说明
statusTime: string; // 状态更新时间
overdueDays?: number;
approveTime?: string; // 审批通过时间
useTime?: string; // 实际投用时间
handlers: { name: string; done: boolean }[]; // 接班人(当前待办/已办)
}
const LEDGER_ROWS: LedgerRow[] = [
{ id: "MOC-2026-R01-0035", changeId: "MOC-2026-R01-0035", title: "R-201 反应釜搅拌器电机更换(55kW→75kW 防爆电机)", type: "设备", level: "重要", duration: "永久", applyTime: "2026-07-28 09:12", dept: "一车间", applicant: "张工艺", planUse: "2026-08-10", status: "审批中", statusDetail: "专业会签中(周设备、王仪表待签)", statusTime: "2026-07-30 14:20", handlers: [{ name: "王仪表", done: true }, { name: "周设备", done: false }, { name: "王仪表", done: false }] },
{ id: "MOC-2026-R01-0036", changeId: "MOC-2026-R01-0036", title: "聚合反应温度控制上限调整(78℃→82℃)", type: "工艺", level: "重要", duration: "永久", applyTime: "2026-07-25 10:05", dept: "一车间", applicant: "孙丽", planUse: "2026-08-05", status: "审批中", statusDetail: "安全审查(王海燕)", statusTime: "2026-07-29 16:40", handlers: [{ name: "王海燕", done: false }] },
{ id: "MOC-2026-R02-0038", changeId: "MOC-2026-R02-0038", title: "DCS 罐区液位报警值 LIA-301 调整", type: "工艺", level: "一般", duration: "永久", applyTime: "2026-07-20 08:30", dept: "一车间", applicant: "吴强", planUse: "2026-07-31", status: "待PSSR", statusDetail: "培训 / PSSR 资料已传 · 待确认投用", statusTime: "2026-07-27 11:15", approveTime: "2026-07-26 16:40", handlers: [{ name: "吴强", done: false }] },
{ id: "MOC-2026-R01-0039", changeId: "MOC-2026-R01-0039", title: "V-102 安全阀起跳压力临时调整(紧急补办)", type: "设备", level: "一般", duration: "临时", urgent: true, applyTime: "2026-07-15 19:40", dept: "一车间", applicant: "张工艺", planUse: "2026-07-16(已投用)", status: "待关闭", statusDetail: "待关闭确认(资料更新核查中)", statusTime: "2026-07-27 09:02", approveTime: "2026-07-15 21:05", useTime: "2026-07-16 06:30", overdueDays: 4, handlers: [{ name: "李主任", done: false }] },
{ id: "MOC-2026-R03-0040", changeId: "MOC-2026-R03-0040", title: "原料环己烷供应商变更(新增 B 供应商)", type: "管理", level: "重要", duration: "永久", applyTime: "2026-07-18 14:22", dept: "供应部", applicant: "刘芳", planUse: "2026-08-01", status: "实施中", statusDetail: "实施准备中 · 培训 / PSSR 资料待上传", statusTime: "2026-07-26 10:30", handlers: [{ name: "刘芳", done: false }] },
{ id: "MOC-2026-R02-0031", title: "冷冻盐水泵 P-103 叶轮材质变更", type: "设备", level: "一般", duration: "永久", applyTime: "2026-06-28 09:50", dept: "二车间", applicant: "周涛", planUse: "2026-07-15", status: "待验收", statusDetail: "变更验收(72h 运行验证)", statusTime: "2026-07-24 15:44", overdueDays: 7, handlers: [{ name: "李主任", done: false }] },
{ id: "MOC-2026-R01-0029", title: "操作规程修订(夏季工况)", type: "管理", level: "一般", duration: "临时", applyTime: "2026-06-20 11:26", dept: "一车间", applicant: "张工艺", planUse: "2026-07-01", status: "审批中", statusDetail: "部门审核(李主任)", statusTime: "2026-06-21 08:40", handlers: [{ name: "李主任", done: false }] },
{ id: "MOC-2026-R02-0033", title: "包装线贴标机控制程序升级", type: "设备", level: "一般", duration: "永久", applyTime: "2026-07-08 13:35", dept: "二车间", applicant: "吴强", planUse: "2026-08-02", status: "已批准", statusDetail: "待实施(计划 8 月 2 日投用)", statusTime: "2026-07-22 09:12", handlers: [{ name: "周设备", done: false }] },
{ id: "MOC-2026-R03-0027", title: "化验室通风橱整体更换", type: "设备", level: "一般", duration: "永久", applyTime: "2026-06-12 15:10", dept: "质量部", applicant: "陈静", planUse: "2026-06-25", status: "已关闭", statusDetail: "关闭确认完成,已归档", statusTime: "2026-07-02 10:18", handlers: [{ name: "钱峰", done: true }] },
{ id: "MOC-2026-R01-0024", title: "氮气管网压力分级调整", type: "工艺", level: "重要", duration: "永久", applyTime: "2026-05-30 09:00", dept: "一车间", applicant: "孙丽", planUse: "2026-06-20", status: "已关闭", statusDetail: "关闭确认完成,已归档", statusTime: "2026-06-28 16:30", handlers: [{ name: "李主任", done: true }, { name: "刘副总", done: true }] },
];
const ROLES = [
{ id: "applicant", name: "申请人", person: "张工艺", org: "一车间 · 工艺组", desc: "发起变更申请、AI 辅助判定、跟踪进度" },
{ id: "process", name: "专业会签(仪表)", person: "王仪表", org: "技术部 · 仪表科", desc: "专业会签、HAZOP 分析、确认连带变更" },
{ id: "process2", name: "专业会签(设备)", person: "周设备", org: "设备管理部 · 设备科", desc: "专业会签、设备完整性检查、行动项落实" },
{ id: "safety", name: "安全工程师", person: "王海燕", org: "安全环保部", desc: "安全审查、PSSR 检查表确认、风险分析组织" },
{ id: "dept", name: "车间主任", person: "李主任", org: "一车间", desc: "部门审核、现场验收、变更关闭确认" },
{ id: "vp", name: "分管领导", person: "刘副总", org: "公司领导", desc: "重要变更最终批准、移动端审批" },
{ id: "admin", name: "系统管理员", person: "刘敏", org: "信息化部", desc: "流程配置、字典维护、权限分配" },
{ id: "quality", name: "质量工程师", person: "陈质量", org: "质量部", desc: "质量会签、产品合格判定、检验方案确认" },
{ id: "viewer", name: "非审批人员", person: "吴新", org: "相关部门", desc: "以信息查看为主,不参与变更流程" },
];
function nowStr() {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
type DemoListener = () => void;
const demoListeners = new Set<DemoListener>();
function emitDemo() { demoListeners.forEach((f) => f()); }
function submitChange(c: ChangeOrder) {
CHANGES.unshift(c);
const cur = c.flow.find((n) => n.status === "current");
LEDGER_ROWS.unshift({
id: c.id,
changeId: c.id,
title: c.title,
type: c.type,
level: c.level,
duration: c.duration,
urgent: c.urgent || undefined,
applyTime: nowStr(),
dept: c.org.split("·")[0].trim(),
applicant: c.applicant,
planUse: c.date || "待定",
status: c.status,
statusDetail: cur ? `${cur.name}(待处理)` : "审批中",
statusTime: nowStr(),
handlers: (cur?.roles ?? []).map((rid) => ({ name: ROLES.find((r) => r.id === rid)?.person ?? rid, done: false })),
});
emitDemo();
}
// ---------- 提交 ----------
const submitApply = () => {
if (!desc.value.trim()) { window.$message?.warning("请先录入变更描述并完成 AI 识别"); return; }
if (!form.value) { window.$message?.warning("请先在「变更预识别」页点击「生成变更申请单」,生成申请表后再提交"); return; }
const unconfirmed = ["form", "risk", "train", "pssr", "accept", "close"].filter((t) => !confirmed.value[t]);
if (unconfirmed.length) {
blockedTabs.value = unconfirmed;
window.$message?.warning(`全部标签页确认后方可提交,请先完成以下标签页的「内容确认完毕」(标签栏已用红点标出):${unconfirmed.map((t) => TAB_NAME[t]).join("、")}`);
return;
}
blockedTabs.value = [];
const id = nextChangeId();
const level: ChangeLevel = form.value.level === "重要" ? "重要" : "一般";
const flow: any[] = level === "重要"
? [
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "current" },
{ name: "专业会签", kind: "会签", roles: ["process", "process2"], status: "todo", locked: true, members: [{ name: "王仪表(仪表)", done: false }, { name: "周设备(设备)", done: false }] },
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "todo", locked: true },
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
]
: [
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "current" },
{ name: "专业审批", kind: "审批", roles: ["process"], status: "todo", locked: true },
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
];
const order: ChangeOrder = {
id,
title: form.value.name || desc.value.slice(0, 24) || "未命名变更",
type: (form.value.type || "工艺") as ChangeType,
level,
duration: form.value.duration === "临时" ? "临时" : "永久",
urgent: !!form.value.urgent,
status: "审批中",
applicant: ROLES[0].person,
org: form.value.dept || ROLES[0].org,
device: "—",
date: today(),
purpose: form.value.purposes.join("、") || "提高生产效能",
effect: form.value.effect || "—",
before: rows.value?.map((x) => `${x.item}${x.before}`).join("") || desc.value.slice(0, 60),
after: rows.value?.map((x) => `${x.item}${x.after}`).join("") || "—",
related: form.value.related ? form.value.related.split("、") : updateDocs.value,
materials: materialsText.value ? materialsText.value.split("\n").filter(Boolean) : [],
riskTools: tools.value,
flow,
};
submitChange(order);
window.$message?.success(`已提交 ${id},进入「部门审核」节点(台账 / 审批中心 / 角标已联动)`);
// emit("openChange", order);
};
const PURPOSE_OPTIONS = [
"满足市场需求", "提高生产效能", "改善操作条件", "增加安全性能",
"达到环保要求", "法律合规整改", "减少能耗物损", "稳定系统运行",
"完善管理制度", "达到质量改进", "设备可靠性升级",
];
const DEPT_APPROVER_OPTS = ["李主任(一车间主任)", "赵主任(二车间主任)", "陈主任(三车间主任)"].map((x) => ({ label: x, value: x }));
const SIGNER_POOL = ["王仪表(技术部)", "王仪表(技术部 · 仪表)", "周设备(设备科 · 电气)", "王海燕(安全环保部)", "王海燕(质量部)"];
const signerOpts = (cur: string) => [...new Set([cur, ...SIGNER_POOL])].map((x) => ({ label: x, value: x }));
const levelScores = ref<number[]>([2, 2, 3, 1, 2, 3]);
const totalScore = computed(() => levelScores.value.reduce((a, b) => a + b, 0));
const levelResult = computed(() => (totalScore.value >= 20 ? "重要变更" : "一般变更"));
const RISK_TOOLS_FORM = [
{ key: "HAZOP", label: "HAZOP" },
{ key: "JSA", label: "JSA" },
{ key: "SCL", label: "设备SCL" },
{ key: "RISK_CHECK", label: "风险检查表" },
];
const recommendTools = () => {
const lv = form.value?.level === "重要" ? "重要" : "一般";
tools.value = lv === "重要" ? ["HAZOP", "JSA", "检查表法", "通用检查表"] : ["JSA", "检查表法", "通用检查表"];
window.$message?.success(`已按「${lv}变更」规则匹配风险分析工具,可继续手动重选`);
};
const files = ref<string[]>(["小试实验报告.pdf", "杂质初步分析.docx"]);
const previewFile = ref<string | null>(null);
const DISCIPLINE_SIGNER: Record<string, string> = {
技术: "王仪表(技术部)", 仪表: "王仪表(技术部 · 仪表)", 电气: "周设备(设备科 · 电气)", 环保: "王海燕(安全环保部)", 质量: "王海燕(质量部)",
};
const toggleDiscipline = (d:any) => {
const result = disciplines.value.reduce((acc: Record<string, string>, key: string) => {
acc[key] = DISCIPLINE_SIGNER[key]; // 如果键不存在,值为 undefined
return acc;
}, {});
signers.value = {...result};
};
const TAB_NAME: Record<string, string> = {
pre: "变更预识别", form: "变更申请表", risk: "风险分析记录表",
train: "变更培训内容", pssr: "PSSR检查内容", accept: "验收评价", close: "变更关闭确认表",
};
const confirmTab = (t: string) => {
const v = !confirmed.value[t];
confirmed.value = { ...confirmed.value, [t]: v };
if (v) window.$message?.success(`已确认${TAB_NAME[t]}内容完毕(标签页栏已标记 ✓)`);
else window.$message?.info(`已取消${TAB_NAME[t]}的确认标记`);
};
const regenerate = () => window.$message?.success(`演示:已根据变更描述重新生成${TAB_NAME[tab.value]}(本页内容已刷新)`);
const download = () => window.$message?.success(`演示:${TAB_NAME[tab.value]}已下载(Word/PDF`);
const downloadPdf = () => window.$message?.success(`演示:${previewFile.value} 已下载`);
const LEVEL_DIMS = [
{
name: "1. 设备影响", note: "不合适的材质将会导致",
options: [
{ label: "对设备带来一定的损害及腐蚀", score: 1 },
{ label: "对设备带来中等的损害及腐蚀", score: 2 },
{ label: "对设备带来明显的损害及腐蚀", score: 3 },
{ label: "对设备带来重大的损害及腐蚀", score: 4 },
],
},
{
name: "2. 财务影响", note: "工艺设计错误将会导致",
options: [
{ label: "一定损失,a<1万人民币", score: 1 },
{ label: "中等损失,直接损失1万≤a<10万人民币", score: 2 },
{ label: "大量损失,直接损失10万≤a<100万人民币", score: 3 },
{ label: "巨额损失,直接损失100万人民币≤a", score: 4 },
],
},
{
name: "3. 质量影响", note: "",
options: [
{ label: "对质量带来一定的风险(非关键工席、非关键设备设施的变更)", score: 1 },
{ label: "对质量带来中等风险(B类原料替代、生产辅助设备变更)", score: 2 },
{ label: "对质量带来明显的风险(主要工艺参数、中间体生产工艺变更)", score: 3 },
{ label: "对质量有很高风险(主要工艺路线、关键工艺参数变更)", score: 4 },
],
},
{
name: "4. 设计的复杂程度", note: "工艺设计包含了",
options: [
{ label: "少量的简单系统(单个设备或单个部件)", score: 1 },
{ label: "大量的简单系统(多个单类设备或部件)", score: 2 },
{ label: "至少一个复杂系统(涉及多设备/工艺相应调整)", score: 3 },
{ label: "大量的复杂系统(需要装置配套改造调整)", score: 4 },
],
},
{
name: "5. 工艺设计的成熟度", note: "",
options: [
{ label: "被频繁使用,且被证明为专利方设计", score: 1 },
{ label: "被少量使用,或被证明为专利方设计", score: 2 },
{ label: "被相关工业设计方法或参考文献认可(非专利方)", score: 3 },
{ label: "新的,未发表的", score: 4 },
],
},
{
name: "6. 工艺危害", note: "物料性质和工艺条件",
options: [
{ label: "无危害(常压、-10℃≤温度<40℃、无毒、无腐蚀不燃)", score: 1 },
{ label: "可燃/负压/低正压(0~0.3MPa)、40℃≤温度<80℃", score: 2 },
{ label: "易燃/中压(0.3~1.0MPa)、80℃≤温度<150℃", score: 3 },
{ label: "极度危害/高压(>1.0MPa)、温度≥150℃", score: 4 },
],
},
];
const finishApprover = () => {
sheet.value = null;
window.$message?.success('审批流程已更新(底部固定行摘要同步刷新)');
}
// 风险识别分析记录表
const TOOL_CARDS = [
{ key: "HAZOP" as const, name: "HAZOP 分析", desc: "节点-偏离结构化分析:原因 / 后果 / LS 风险矩阵 / 安全措施" },
{ key: "JSA" as const, name: "JSA 分析", desc: "作业步骤分解:危害因素识别与 LS 风险分级管控" },
{ key: "SCL" as const, name: "设备 SCL", desc: "设备 / 管理检查项目对照标准逐项核查" },
{ key: "RISK_CHECK" as const, name: "风险检查表", desc: "变更通用检查项:是 / 否 / 不涉及逐项判定,输出风险描述与管控措施" },
];
const pluginDone = ref<Record<string, number>>({});
const riskRecords = ref<any>([]);
const updRec = (i: number, patch: Partial<any>) => {
riskRecords.value = riskRecords.value.map((x, j) => (j === i ? { ...x, ...patch } : x));
};
const lvlNext = (v: string) => (v === "高" ? "中" : v === "中" ? "低" : "高");
const pssrGroups = ref<{ g: string; items: any[] }[]>([
{ g: "一、风险分析行动项落实", items: [
{ c: "联锁跳车电流设定值校核并留存记录", p: "投用前" },
{ c: "电缆与开关容量校核升级完成", p: "投用前" },
{ c: "操作人员培训完成并考核合格", p: "投用前" },
] },
{ g: "二、现场设备安装检查确认", items: [
{ c: "电机安装就位,联轴器对中合格", p: "投用前" },
{ c: "地脚螺栓紧固,减振垫安装到位", p: "投用前" },
{ c: "电缆接线、防爆格兰头密封良好", p: "投用前" },
] },
{ g: "三、保护措施确认", items: [
{ c: "联锁跳车设定值与新电机额定电流匹配", p: "投用前" },
{ c: "电机外壳接地、防护罩完好", p: "投用前" },
{ c: "紧急停机按钮功能试验正常", p: "投用前" },
] },
{ g: "四、资料归档确认", items: [
{ c: "P&ID(图号 PID-R201-03)已更新并受控", p: "关闭前" },
{ c: "设备台账、备件清单已更新", p: "关闭前" },
{ c: "培训签到表、考核记录已归档", p: "关闭前" },
] },
]);
const toPssr = (i: number) => {
const rec = riskRecords.value[i];
if (!rec || rec.pssr) return;
pssrGroups.value = pssrGroups.value.map((g:any, gi:any) => gi === 0
? { ...g, items: [...g.items, { c: `${rec.suggest}(来源:风险分析记录表 · ${rec.item}`, p: "投用前" }] }
: g);
updRec(i, { pssr: true });
window.$message?.success("建议措施已转为 PSSR 行动项(见「PSSR 检查内容」· 一、风险分析行动项落实)");
};
const toPssrAll = () => {
const rest = riskRecords.value.map((x:any, i:any) => (x.pssr ? -1 : i)).filter((i:any) => i >= 0);
if (!rest.length) { window.$message?.info("所有建议措施均已转为 PSSR 行动项"); return; }
const items = rest.map((i:any) => ({ c: `${riskRecords.value[i].suggest}(来源:风险分析记录表 · ${riskRecords.value[i].item}`, p: "投用前" }));
pssrGroups.value = pssrGroups.value.map((g:any, gi:any) => gi === 0 ? { ...g, items: [...g.items, ...items] } : g);
riskRecords.value = riskRecords.value.map((x:any) => ({ ...x, pssr: true }));
window.$message?.success(`已将 ${rest.length} 条建议措施全部转为 PSSR 行动项(见「PSSR 检查内容」)`);
};
// L/S → 页面风险等级(高/中/低)换算,对齐官方 5×5 矩阵阈值
const lsLevel = (l: number | null, s: number | null) => {
const rr = (l ?? 0) * (s ?? 0);
return rr >= 10 ? "高" : rr >= 5 ? "中" : "低";
};
const aiOrganize = async () => {
analyzing.value = true;
try {
const res = await aiRiskOrganize(desc.value, compareRows());
const gen = (res?.records ?? []).map((r) => ({
item: r.item,
scene: r.scene,
inherent: lsLevel(r.l, r.s),
existing: r.existing,
suggest: r.suggest,
residual: lsLevel(r.residual_l, r.residual_s),
source: "AI 整理",
}));
riskRecords.value = [...riskRecords.value, ...gen];
window.$message?.success(`AI 已根据变更内容自动整理形成风险分析记录表(${gen.length} 项),支持手动修改`);
} catch (e: any) {
aiFail(e);
} finally {
analyzing.value = false;
}
};
// ---------- AI 类型判定 / 等级评分(抽屉打开时调用,静态示例作 fallback ----------
const typeAi = ref<{ type: string; confidence: number; conclusion: string; hit_rules: string[]; exclude_rules: string[]; knowledge_refs: string[] } | null>(null);
const typeAiLoading = ref<boolean>(false);
const openTypeSheet = async () => {
sheet.value = "type";
if (typeAiLoading.value) return;
typeAiLoading.value = true;
try {
const res = await aiTypeJudge(desc.value, compareRows());
typeAi.value = {
type: res?.type ?? "",
confidence: res?.confidence ?? 0,
conclusion: res?.conclusion ?? "",
hit_rules: res?.hit_rules ?? [],
exclude_rules: res?.exclude_rules ?? [],
knowledge_refs: res?.knowledge_refs ?? [],
};
} catch (e: any) {
aiFail(e);
} finally {
typeAiLoading.value = false;
}
};
const levelAiText = ref<string>("");
const levelAiBasis = ref<string[]>([]);
const levelAiLoading = ref<boolean>(false);
const openLevelSheet = async () => {
sheet.value = "level";
if (levelAiLoading.value) return;
levelAiLoading.value = true;
try {
const dims = LEVEL_DIMS.map((d, i) => ({ dim: `DIM_${i + 1}`, name: d.name, max: Math.max(...d.options.map((o) => o.score)) }));
const res = await aiLevelScore(desc.value, compareRows(), dims);
// 返回维度按 name 匹配、匹配不到按顺序对齐回填各维度得分;总分/等级阈值换算沿用页面本地逻辑
const scores = [...levelScores.value];
(res?.dims ?? []).forEach((d, i) => {
const di = LEVEL_DIMS.findIndex((x) => x.name === d.name || x.name.endsWith(d.name));
const idx = di >= 0 ? di : i;
if (idx >= 0 && idx < scores.length && typeof d.score === "number") scores[idx] = d.score;
if (d.basis) levelAiBasis.value[idx] = d.basis;
});
levelScores.value = scores;
levelAiText.value = res?.analysis_text ?? "";
} catch (e: any) {
aiFail(e);
} finally {
levelAiLoading.value = false;
}
};
// ---------- 变更培训 ----------
// 注:培训区块暂无独立「AI 生成」触发按钮,保留静态示例内容;
// 接入时可在此调 aiTraining(desc.value, compareRows(), riskRecords.value)
// 将 training_text 填入 trainText、suggest_posts 展示为建议培训岗位。
const trainText = ref(
`培训对象:R-201 岗位操作人员(4 人)、维修班(2 人)
培训方式:现场讲解 + 操作演示
一、变更内容
M-201A 搅拌电机由 55kW 更换为 75kW 防爆电机(ExdⅡBT4 → ExdⅡCT4),额定电流 102A → 138A;电缆与开关容量同步升级,联锁跳车电流设定值同步校核调整。
二、变化的操作方式
1. 搅拌器启动:变更前直接启动、观察电流 ≤102A;变更后启动后核对电流 ≤138A,确认变频器参数已更新(注意:首次启动空载试运行 30 分钟)
2. 高粘度工况操作:变更前粘度 >3000cP 需降速运行;变更后粘度 ≤6000cP 可全速运行(注意:严禁超过 6000cP 上限)
3. 过载跳闸处置:变更前复位后直接重启;变更后先查明原因并记录,联锁复位需班长确认(注意:跳闸电流设定值已调整)
三、潜在风险与应对措施
1. 风险:电机 / 电缆过热 → 应对:投用首周每班测温并记录,红外点检复核
2. 风险:联锁设定错误导致拒动 / 误动 → 应对:投用前完成跳车试验并留存记录
3. 风险:防爆接合面或格兰头密封失效 → 应对:检查防爆接合面与格兰头,严禁带电开盖`
);
// ---------- PSSR 检查内容 ----------
// 注:PSSR 区块暂无独立「AI 生成」触发按钮,保留静态示例清单;
// 接入时可在此调 aiPssr({ content: desc.value, change_type: form.value.type, risk_records: riskRecords.value })
// 将 groups(g, items: content/phase) 映射为 pssrGroups 的 { g, items: { c, p } } 结构。
const pssrDone = ref<Record<string, { by: string; at: string }>>({});
const pssrNa = ref<Record<string, boolean>>({});
const pssrActive = computed(() => pssrGroups.value.filter((g:any) => !pssrNa.value[g.g]));
const pssrTotal = computed(() => pssrActive.value.reduce((a:any, g:any) => a + g.items.length, 0));
const pssrDoneCount = computed(() => pssrActive.value.reduce((a:any, g:any) => a + g.items.filter((it:any) => pssrDone.value[it.c]).length, 0));
const pssrAllDone = computed(() => pssrDoneCount.value === pssrTotal.value && (pssrTotal.value > 0 || (pssrGroups.value.length > 0 && pssrGroups.value.every((g) => !!pssrNa.value[g.g]))));
const pssrFileRef = ref<any>(null);
const pssrSheet = ref<any>(null);
const closeDocs = ref([
{ name: "PID 图纸(PID-R201-03,受控最新版)", cat: "图纸与规程类", scope: "是", archived: true },
{ name: "操作规程(含超温应急处置卡)", cat: "图纸与规程类", scope: "是", archived: false },
{ name: "应急预案修订", cat: "图纸与规程类", scope: "不涉及", archived: false },
{ name: "总图更新", cat: "图纸与规程类", scope: "否", archived: false },
{ name: "DCS 联锁台账(新报警 / 联锁值)", cat: "台账与制度类", scope: "是", archived: false },
{ name: "设备台账与备件清单", cat: "台账与制度类", scope: "是", archived: true },
{ name: "风险分析报告(HAZOP / 检查表法)", cat: "过程与结果文件类", scope: "是", archived: true },
{ name: "培训签到与考核记录", cat: "过程与结果文件类", scope: "是", archived: true },
{ name: "PSSR 检查记录(含现场照片)", cat: "过程与结果文件类", scope: "是", archived: false },
{ name: "验收报告与化验数据", cat: "过程与结果文件类", scope: "是", archived: false },
]);
const onPssrFile = (e: Event) => {
const input = e.target as HTMLInputElement;
const f = input.files?.[0]; input.value = "";
if (!f) return;
pssrSheet.value = f.name;
const at = today();
const n = { ...pssrDone.value };
pssrGroups.value.forEach((g:any) => { if (!pssrNa.value[g.g]) g.items.forEach((it:any) => { n[it.c] = { by: "纸质确认表上传", at }; }); });
pssrDone.value = n;
const archGroup = pssrGroups.value[pssrGroups.value.length - 1];
if (archGroup && !pssrNa.value[archGroup.g]) {
closeDocs.value = closeDocs.value.map((d:any) => (d.name.includes("PID") || d.name.includes("设备台账") || d.name.includes("培训签到") ? { ...d, archived: true } : d));
}
window.$message?.success(`已上传 PSSR 纸质确认表「${f.name}」:不做内容识别,上传即视为全部检查项完成确认(「不涉及」组除外),原件归档至变更档案`);
};
// ---------- 验收评价 ----------
// 注:验收清单暂无独立「AI 生成」触发按钮,保留静态示例行;
// 接入时可在此调 aiAcceptance({ purpose: form.value.purposes.join('、'), effect: form.value.effect })
// 将 rows(item/basis/standard/method/owner_suggest) 映射为 acceptRows 行结构(id 自行补,result 默认「待验收」)。
const acceptRows = ref([
{ id: 1, item: "反应转化率", basis: "转化率不低于 98%", standard: "转化率 ≥ 98%(连续 3 批化验合格)", method: "化验室逐批取样分析,出具检验报告", owner: "车间 · 张工艺", result: "待验收" },
{ id: 2, item: "单批反应时间 / 产能", basis: "反应时间缩短 50%、产能提升一倍", standard: "保温时间 4h±0.5h,产能提升 ≥ 80%", method: "DCS 批次记录统计,对比变更前 30 天均值", owner: "车间 · 李主任", result: "待验收" },
{ id: 3, item: "未知杂质含量", basis: "杂质水平受控、产品质量合格", standard: "杂质 ≤ 0.15%,产品质量指标全部合格", method: "每批化验跟踪,连续 5 批数据趋势评估", owner: "质量 · 王海燕", result: "待验收" },
{ id: 4, item: "温控与联锁有效性", basis: "温度窗口上调后保护层有效", standard: "温度波动 ≤ ±2℃,高报/联锁动作正确率 100%", method: "DCS 趋势核查 + 联锁测试记录复核", owner: "仪表 · 王仪表", result: "待验收" },
{ id: 5, item: "设备运行状态", basis: "负荷变化后设备长周期稳定", standard: "搅拌电流、釜壁温度无异常趋势,无泄漏", method: "设备巡检 + 振动/温度记录评估", owner: "设备 · 周设备", result: "待验收" },
]);
const acceptNote = ref("");
// ---------- 变更关闭确认表 ----------
const closeFold = ref<Record<string, boolean>>({});
const uploadDoc = ref<string | null>(null);
const uploadSel = ref<string | null>(null);
const UPDATE_DOC_KW: Record<string, string> = { PID图纸: "PID", 操作规程: "操作规程", 总图: "总图", 设备台账: "设备台账", 工艺卡片: "工艺卡片", 联锁台账: "联锁台账", 应急预案: "应急预案" };
const needUpdate = (name: string) => updateDocs.value.some((d:any) => name.includes(UPDATE_DOC_KW[d] ?? d));
const changeScope = (d: any, v: string) => {
closeDocs.value = closeDocs.value.map((x:any) => (x.name === d.name ? { ...x, scope: v } : x));
if (v !== d.scope) window.$message?.info(`演示:「${d.name}」改判为「${v}」,改判理由与操作人已留痕`);
}
const uploadDocInfo = computed(() => {
if (!uploadDoc.value) return null;
const doc = closeDocs.value.find((x) => x.name === uploadDoc.value);
const base = uploadDoc.value.replace(/[(].*$/, "");
return {
storePath: `\\\\PLANT-DMS\\变更管理\\2026\\MOC-2026-R01-0041\\${doc?.cat ?? ""}\\`,
candidates: [`${base}_RevC_受控版.pdf`, `${base}_2026-08 更新版.docx`],
base,
};
});
const openDir = () => {
window.$message?.info('演示:已在文档管理系统中打开该目录')
}
const uploadFiles = () => {
uploadSel.value = `本地文件:${uploadDocInfo.value?.base}_最新版.pdf`;
window.$message?.info('演示:已接收拖拽 / 选择的本地文件');
}
const uploadConfirm = () => {
closeDocs.value = closeDocs.value.map((x) => (x.name === uploadDoc.value ? { ...x, archived: true } : x));
window.$message?.success(`「${uploadDoc.value}」已上传并归档(${uploadSel.value},受控版本,哈希留痕)`);
uploadDoc.value = null;
}
const pluginDrawer = ref<boolean>(false);
const currentPlugin = ref<string>("");
// 插件选择
// 注:风险核查(RISK_CHECK)的「AI 预填检查表」交互在 modules/riskCheck.vue 插件内部,
// 本页 toggleTool 仅负责打开插件抽屉,无独立 AI 预填入口,故不在此接 aiRiskCheck。
const toggleTool = (t: any) => {
if (!tools.value.includes(t.key))
tools.value = tools.value.includes(t.key) ? tools.value.filter((x) => x !== t.key) : [...tools.value, t.key];
currentPlugin.value = t.key;
pluginDrawer.value = true;
};
const exportAll = (items: any[], mode: "manual" | "ai") => {
riskRecords.value = [...riskRecords.value, ...items];
if (mode === "ai") {
pluginDrawer.value = false;
currentPlugin.value = '';
window.$message?.success(`AI 已自动整理形成风险分析记录表(新增 ${items.length} 项),支持手动修改`);
} else {
window.$message?.success(`已选入 ${items.length} 条风险记录,可继续挑选或返回查看记录表`);
}
}
const onBack = () => {
pluginDrawer.value = false;
}
</script>
<template>
<NSpace vertical :size="16">
<!-- 发起变更 -->
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
<!-- 顶部 -->
<div
class="border-b bg-[var(--bg-color)]"
:style="{
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
}"
>
<div
class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b px-4 py-2 border-[var(--border-color)]"
:style="{'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)}"
>
<span class="whitespace-nowrap text-sm font-medium text-slate-600">变更名称 <span class="text-red-500">*</span></span>
<NInput v-model:value="form.name" maxlength="28" show-count placeholder="AI 自动总结(28 字以内),可手动修改" class="!w-420px" />
<span class="ml-auto whitespace-nowrap text-sm text-slate-500">变更编号</span>
<NInput value="MOC-2026-R01-0041" disabled class="!w-44" />
</div>
<div class="flex items-center gap-1 overflow-x-auto px-3 pt-2">
<div v-for="t in TABS" :key="t.id" @click="tab = t.id"
class="flex items-center gap-1.5 whitespace-nowrap px-3 py-2 text-sm transition cursor-pointer border hover:bg-white/60 rounded-t-lg"
:class="tab === t.id ? 'border-b-transparent border-[var(--border-color)] bg-white text-[var(--active-color)]':'border-transparent text-slate-500'"
:style="{
'--border-color': getColorWithOpacity(themeStore.themeColor, 0.3),
'--active-color': themeStore.themeColor
}"
>
<Icon :icon="t.icon" class="size-14px" />
{{ t.name }}
<span v-if="t.id === 'form' && formGen === 'generating'"
class="flex items-center gap-1 rounded-full border border-violet-300 bg-violet-50 px-1.5 py-0.5 text-[10px] text-violet-600">
<Icon icon="ri:loader-4-fill" class="size-14px animate-spin" />
AI 生成中
</span>
<Icon v-if="confirmed[t.id]" icon="material-symbols:check-circle-outline" class="size-14px text-emerald-500" />
<span v-if="!confirmed[t.id] && blockedTabs.includes(t.id)" class="h-1.5 w-1.5 rounded-full bg-red-500" title="本页尚未确认完毕" />
</div>
</div>
</div>
<!-- 中间 -->
<div class="p-4">
<div class="space-y-4" :class="tab === 'pre'?'scroll':'scroll2'">
<!-- ===== 变更预识别 ===== -->
<template v-if="tab === 'pre'">
<!-- 变更内容描述 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:sparkles" class="size-16px text-violet-500" />
<span class="font-semibold text-base ml-2">变更内容描述</span>
</div>
<NButton v-if="aiEnabled" ghost type="primary" @click="runIdentify" :disabled="analyzing">
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-16px animate-spin" />
<Icon v-else icon="fluent:wand-24-regular" class="size-16px" />
变更识别
</NButton>
<NTag type="info" v-else>AI 未启用</NTag>
</div>
<div class="p-4">
<NInput type="textarea" v-model:value="desc" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
<div v-if="!aiEnabled" class="mt-2 flex items-center gap-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
<Icon icon="proicons:alert-triangle" class="size-14px" />
AI 辅助功能未启用可在系统设置 AI 辅助功能开启),识别结果与申请表需手动填写
</div>
</div>
</div>
<!-- 变更识别结果 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
<span class="font-semibold text-base ml-2">{{rows ? `变更识别结果(${rows.length}项)` : '变更识别结果(空表 · 可手动录入,或由 AI 识别生成)'}}</span>
</div>
<span v-if="rows" class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
</div>
<div class="p-4">
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr class="bg-slate-50 text-left text-slate-500">
<th class="w-28 border-b px-3 py-2 font-medium">变更项目</th>
<th class="border-b px-3 py-2 font-medium">变更前</th>
<th class="border-b px-3 py-2 font-medium">变更后</th>
<th class="w-10 border-b px-2 py-2" />
</tr>
</thead>
<tbody>
<tr v-for="row in rows ?? []" :key="row.id" class="align-top">
<td class="border-b px-3 py-3">
<NInput
v-if="editing === `${row.id}-item`"
class="text-left"
type="textarea"
size="small"
autofocus
v-model:value="row.item"
:autosize="{ minRows: 1 }"
placeholder="请输入"
@blur="editing = null"
/>
<p v-else class="font-medium text-slate-700 hover:text-[#17407F] cursor-pointer" @click="editing = `${row.id}-item`" title="点击修改项目名称">
{{ row.item }}
</p>
</td>
<td v-for="field in (['before', 'after'])" :key="field" class="border-b px-3 py-3">
<NInput
v-if="editing === `${row.id}-${field}`"
class="text-left"
type="textarea"
size="small"
autofocus
v-model:value="row[field]"
:autosize="{ minRows: 1 }"
placeholder="请输入"
@blur="editing = null"
/>
<p v-else class="min-h-30px cursor-pointer rounded-lg border bg-slate-50/60 px-2.3 py-0.55 text-left text-sm leading-relaxed text-slate-700 transition hover:border-[#6E93C6] hover:bg-white" @click="editing = `${row.id}-${field}`" title="点击编辑">
<HighlightText v-if="field === 'after'" :text="row[field]" :hl="row.hl" />
<span v-else class="whitespace-pre-line">{{ row[field] }}</span>
</p>
</td>
<td class="border-b px-2 py-3 text-center">
<NButton text @click="rows = rows && rows.filter((x) => x.id !== row.id)" class="text-slate-300 hover:text-red-500" title="删除此行">
<Icon icon="line-md:close-circle" class="size-16px" />
</NButton>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-3">
<NButton ghost
@click="rows = [...(rows ?? []), { id: Date.now(), item: '新项目', before: '', after: '' }]">
<Icon icon="ic:round-plus" class="size-16px" /> 添加行
</NButton>
</div>
</div>
</div>
<!-- 风险预分析报告 -->
<div v-if="preRiskShown" class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center justify-between px-4 py-2 border-b">
<div class="flex items-center">
<Icon icon="lucide:list-checks" class="size-16px text-emerald-500" />
<span class="font-semibold text-base ml-2">风险预分析报告</span>
</div>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
</div>
<div class="p-4">
<div class="space-y-4">
<div v-for="s in ([{ key: 'severity', name: '危害严重性分析' }, { key: 'probability', name: '事件概率分析' }, { key: 'protection', name: '保护措施影响分析' }] as const)" :key="s.key" class="rounded-lg border">
<div class="flex items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
<span class="text-sm font-semibold text-slate-700">{{ s.name }}</span>
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="preRiskEdit = preRiskEdit === s.key ? null : s.key">
{{ preRiskEdit === s.key ? "完成" : "编辑" }}
</NButton>
</div>
<NInput v-if="preRiskEdit === s.key" type="textarea" :bordered="false" v-model:value="preRiskText[s.key]" autofocus rows="6" />
<p v-else class="whitespace-pre-wrap px-3 py-1.5 text-sm leading-relaxed text-slate-700">{{ preRiskText[s.key] }}</p>
</div>
<div class="rounded-lg bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-700">
AI 风险预分析仅供参考用于申请阶段的快速研判正式风险识别请前往风险分析记录表标签页使用 HAZOP / JSA / 检查表法开展并留存记录
</div>
</div>
</div>
</div>
</template>
<!-- ===== 变更申请表 ===== -->
<template v-if="tab === 'form' && form">
<!-- 申请部门 / 申请人 -->
<div class="grid gap-4 grid-cols-2">
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">申请部门 <span class="text-red-500">*</span></div>
<NInput v-model:value="form.dept" placeholder="请输入申请部门" />
</div>
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">申请人</div>
<NInput v-model:value="ROLES[0].person" disabled />
</div>
</div>
<!-- 变更目的 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更目的</div>
<div class="rounded-lg border border-[#ACC6E5] bg-[#EAF0F9]/40 p-3">
<NCheckboxGroup v-model:value="form.purposes">
<div class="flex flex-wrap gap-y-2 gap-x-4">
<NCheckbox v-for="p in PURPOSE_OPTIONS" :key="p" :value="p" :label="p" />
</div>
</NCheckboxGroup>
</div>
</div>
<!-- 预期效果 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">预期效果</div>
<NInput type="textarea" v-model:value="form.effect" rows="2" placeholder="" />
</div>
<!-- 主要变更内容 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">主要变更内容 <span class="text-red-500">*</span></div>
<div class="flex-1 space-y-2">
<div v-for="(m, i) in form.mainChanges" :key="i" class="flex items-center gap-2">
<span class="w-5 shrink-0 text-sm text-slate-400">{{ i + 1 }}.</span>
<NInput v-model:value="m as string" placeholder="" />
<NButton text type="error" @click="form = { ...form, mainChanges: form.mainChanges.filter((_, j) => j !== i) }">
<Icon icon="line-md:close-circle" class="size-16px" />
</NButton>
</div>
<NButton text type="primary" class="text-xs" @click="form = { ...form, mainChanges: [...form.mainChanges, ''] }">
<Icon icon="ic:round-plus" class="size-14px" /> 添加变更点
</NButton>
</div>
</div>
<!-- 连带变更内容 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600">连带变更内容</div>
<div class="flex-1 space-y-2">
<div v-for="(m, i) in (form.related ? form.related.split('、') : [''])" :key="i" class="flex items-center gap-2">
<span class="w-5 shrink-0 text-sm text-slate-400">{{ i + 1 }}.</span>
<NInput v-model:value="m as string" placeholder="连带变更点(如:更新操作规程)" />
<NButton text type="error" @click="form = { ...form, related: (form.related ? form.related.split('、') : ['']).filter((_, j) => j !== i).join('、') }">
<Icon icon="line-md:close-circle" class="size-16px" />
</NButton>
</div>
<NButton text type="primary" class="text-xs" @click="form = { ...form, related: form.related ? `${form.related}、` : '、' }">
<Icon icon="ic:round-plus" class="size-14px" /> 添加连带变更点
</NButton>
</div>
</div>
<!-- 变更类型 / 变更等级 -->
<div class="grid gap-4 grid-cols-2">
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更类型 <span class="text-red-500">*</span></div>
<div class="flex flex-1 items-center gap-4 rounded-lg bg-slate-50 px-3 py-2">
<NRadioGroup v-model:value="form.type">
<div class="flex gap-x-2">
<NRadio v-for="t in ['工艺', '设备', '管理']" :key="t" :value="t">{{t}}</NRadio>
</div>
</NRadioGroup>
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="openTypeSheet">
<Icon icon="lucide:sparkles" class="size-12px" />
AI 判定说明
</NButton>
</div>
</div>
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更等级 <span class="text-red-500">*</span></div>
<div class="flex flex-1 items-center gap-4 rounded-lg bg-slate-50 px-3 py-2">
<NRadioGroup v-model:value="form.level">
<div class="flex gap-x-2">
<NRadio v-for="t in ['一般', '重要']" :key="t" :value="t">{{t}}</NRadio>
</div>
</NRadioGroup>
<NButton text type="primary" class="ml-auto text-xs hover:underline" @click="openLevelSheet">
<Icon icon="lucide:list-checks" class="size-12px" /> 等级判定表 ({{ totalScore }})
</NButton>
</div>
</div>
</div>
<!-- 变更时限 / 风险分析 -->
<div class="grid gap-4 grid-cols-2">
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">变更时限 <span class="text-red-500">*</span></div>
<div class="flex flex-1 items-center gap-4 rounded-lg bg-slate-50 px-3 py-2">
<NRadioGroup v-model:value="form.duration">
<div class="flex gap-x-2">
<NRadio v-for="t in ['永久', '临时']" :key="t" :value="t">{{t}}</NRadio>
</div>
</NRadioGroup>
<NCheckbox v-model:checked="form.urgent">紧急</NCheckbox>
</div>
</div>
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">风险分析</div>
<div class="flex flex-1 flex-wrap items-center gap-2 rounded-lg bg-slate-50 px-3 py-2">
<NButton size="small" round v-for="t in RISK_TOOLS_FORM" :key="t.key" @click="toggleTool(t.key)" title="点击选用 / 取消;AI 辅助分析在各工具插件内使用"
class="text-sm"
:ghost="tools.includes(t.key) ? false : true"
:type="tools.includes(t.key) ? 'primary' : 'default'"
>
{{ t.label }}
</NButton>
<NButton type="primary" ghost dashed size="small" round @click="recommendTools" class="text-xs">
按等级重新推荐
</NButton>
</div>
</div>
</div>
<!-- 计划投用时间 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">计划投用时间</div>
<div class="flex flex-1 flex-wrap items-center gap-2">
<NDatePicker v-model:value="form.date" type="date" class="!max-w-[220px]" />
<template v-if="form.duration === '临时'">
<span class="whitespace-nowrap text-sm text-slate-500">计划恢复时间 <span class="text-red-500">*</span></span>
<NDatePicker v-model:value="form.restoreDate" type="date" class="!max-w-[220px]" />
<span class="text-xs text-amber-600">临时变更到期系统自动提醒恢复原状</span>
</template>
<span v-else class="whitespace-nowrap text-xs text-slate-300">勾选临时后在此并排填写计划恢复时间</span>
</div>
</div>
<div v-if="form.urgent" class="text-xs text-red-500">紧急变更为单独标记走快速通道事后限期补办风险分析与完整审批</div>
<!-- 所需材料 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">所需材料</div>
<div class="flex-1 space-y-2">
<div class="text-[11px] text-slate-400">施工所需材料生成变更申请表时系统自动生成支持手动修改</div>
<NInput type="textarea" v-model:value="materialsText" rows="4" placeholder="点击「生成变更申请单」后由系统按变更内容自动生成,也可直接填写" />
</div>
</div>
<!-- 资料上传 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600 text-right">资料上传</div>
<div class="flex flex-1 flex-wrap items-center gap-2 rounded-lg border border-dashed p-3">
<NButton size="small" class="text-xs" v-for="f in files" :key="f" @click="previewFile = f" title="点击在线预览">
<Icon icon="akar-icons:file" class="size-14px" />
{{ f }}
</NButton>
<NButton @click="files = [...files, `资料_${files.length + 1}.pdf`]">
<Icon icon="material-symbols:upload" class="size-16px" /> 资料上传
</NButton>
<span class="text-xs text-slate-400">点击附件名弹窗预览支持 PDF / Word / 图片在线查看</span>
</div>
</div>
<!-- 需更新的资料 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600">需更新的资料</div>
<div class="flex flex-1 flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-slate-50 px-3 py-2">
<NCheckboxGroup v-model:value="updateDocs">
<div class="flex flex-wrap gap-y-2 gap-x-4">
<NCheckbox v-for="p in ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案']" :key="p" :value="p" :label="p" />
</div>
</NCheckboxGroup>
<span class="self-center text-xs text-slate-400">多选变更关闭前逐项确认上传最新版本</span>
</div>
</div>
<!-- 涉及专业 -->
<div class="flex gap-3">
<div class="w-24 shrink-0 pt-2 text-sm font-medium text-slate-600">涉及专业</div>
<div class="flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-lg bg-slate-50 px-3 py-2">
<NCheckboxGroup v-model:value="disciplines" @update:value="toggleDiscipline">
<div class="flex flex-wrap gap-y-2 gap-x-4">
<NCheckbox v-for="p in ['技术', '仪表', '电气', '环保', '质量']" :key="p" :value="p" :label="p" />
</div>
</NCheckboxGroup>
<span class="self-center text-xs text-slate-400">多选决定审批流中专业会签/审批的路由范围</span>
</div>
<div v-if="Object.keys(signers).length > 0" class="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-slate-500">
<Icon icon="lucide:users" class="size-12px" />已联动专业会签
<NTag size="small" type="info" v-for="(p, d) in signers" :key="d">{{ d }} · {{ p }}</NTag>
<span class="text-slate-400">在底部审批流程设置中可调整人选</span>
</div>
</div>
</div>
</template>
<!-- ===== 风险分析记录表 ===== -->
<template v-if="tab === 'risk'">
<div class="border p-3" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex flex-wrap items-center gap-2">
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
<Icon icon="lucide:shield-alert" class="size-16px" />
风险识别工具按变更等级匹配
</span>
<NTag size="small" :type="form?.level === '重要' ? 'error' : 'default'">
{{ form?.level === '重要' ? '重要变更' : '一般变更' }}
</NTag>
<span class="text-xs text-slate-400">
{{ form?.level === '重要' ? '规则:HAZOP 必做,同时开展 JSA 与检查表法(SCL)' : '规则:JSA + 检查表法(SCL),涉及工艺安全边界的应升级 HAZOP' }}可在申请表风险分析栏手动重选
</span>
</div>
<div class="grid gap-2 grid-cols-4 mt-3">
<div v-for="t in TOOL_CARDS" :key="t.key" :title="t.desc"
class="flex items-center gap-2 rounded-lg border px-3 py-2 transition"
:class="pluginDone[t.key] !== undefined ? 'border-[var(--theme-color)] bg-[var(--theme-color)]' : tools.includes(t.key) ? 'border-[var(--theme-color)] bg-white' : 'border-slate-200 bg-white'"
:style="{'--theme-color': themeStore.themeColor}"
>
<div class="min-w-0 flex-1">
<div
class="flex items-center gap-1.5 text-xs font-semibold"
:class="pluginDone[t.key] !== undefined ? 'text-white' : tools.includes(t.key) ? 'text-[var(--theme-color)]' : 'text-slate-500'"
:style="{'--theme-color': themeStore.themeColor}"
>
<Icon icon="fluent:wand-24-regular" class="size-16px" />
{{ t.name }}
</div>
<div class="mt-0.5 text-[10px]" :class="pluginDone[t.key] !== undefined ? 'text-white/75' : 'text-slate-400'">
{{ pluginDone[t.key] !== undefined ? `已使用并分析了 ${pluginDone[t.key]} 条内容` : '未使用此工具分析' }}
</div>
</div>
<NButton size="small" class="text-xs" :type="pluginDone[t.key] !== undefined ? 'default' : tools.includes(t.key) ? 'primary' : 'default'"
@click="toggleTool(t)">
使用
</NButton>
</div>
</div>
</div>
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex flex-wrap items-center gap-2 bg-slate-100 px-3 py-2">
<span class="flex items-center gap-1.5 text-sm font-medium text-slate-700">
<Icon icon="proicons:alert-triangle" class="size-16px text-amber-500" />
风险分析记录表
</span>
<span class="text-[11px] text-slate-400"> {{ riskRecords.length }} </span>
<span class="ml-auto hidden text-[11px] text-slate-400 lg:block">插件结果入表 AI 自动整理形成单元格点击修改风险等级点击切换</span>
<NButton ghost size="small" color="#7c3aed" class="text-xs" @click="toPssrAll">
<Icon icon="tabler:clipboard-check" class="size-14px" />
全部转 PSSR 行动项
</NButton>
<NButton ghost size="small" type="primary" class="text-xs" @click="aiOrganize">
<Icon icon="lucide:sparkles" class="size-12px" />
AI 自动整理
</NButton>
</div>
<div v-if="riskRecords.length === 0" class="px-3 py-6 text-center text-xs text-slate-400">
尚无风险记录调用上方插件完成分析后逐条入表」,或点击AI 自动整理形成记录表
</div>
<div v-else class="overflow-x-auto p-3">
<table class="w-full min-w-[960px] border-collapse text-xs">
<thead>
<tr>
<th v-for="h in ['风险项', '场景描述', '固有风险', '现有措施', '建议措施', '剩余风险', '来源', '操作']" :key="h"
class="border bg-slate-50 px-2 py-1.5 text-[11px] font-medium text-slate-500">{{ h }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(rec, i) in riskRecords" :key="i">
<td class="border px-1.5 py-1 align-top font-medium text-slate-700">
<Ed :v="rec.item" @update="(x: string) => updRec(i, { item: x })" />
</td>
<td class="border px-1.5 py-1 align-top"><Ed :v="rec.scene" @update="(x: string) => updRec(i, { scene: x })" /></td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag v-if="rec.inherent==='高'" type="error" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { inherent: lvlNext(rec.inherent) as '高' | '中' | '低' })">
{{ rec.inherent }}
</NTag>
<NTag v-if="rec.inherent==='中'" type="warning" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { inherent: lvlNext(rec.inherent) as '高' | '中' | '低' })">
{{ rec.inherent }}
</NTag>
<NTag v-if="rec.inherent==='低'" type="success" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { inherent: lvlNext(rec.inherent) as '高' | '中' | '低' })">
{{ rec.inherent }}
</NTag>
</td>
<td class="border px-1.5 py-1 align-top"><Ed :v="rec.existing" @update="(x: string) => updRec(i, { existing: x })" /></td>
<td class="border px-1.5 py-1 align-top"><Ed :v="rec.suggest" @update="(x: string) => updRec(i, { suggest: x })" /></td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag v-if="rec.residual==='高'" type="error" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { residual: lvlNext(rec.residual) as '高' | '中' | '低' })">
{{ rec.residual }}
</NTag>
<NTag v-if="rec.residual==='中'" type="warning" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { residual: lvlNext(rec.residual) as '高' | '中' | '低' })">
{{ rec.residual }}
</NTag>
<NTag v-if="rec.residual==='低'" type="success" size="small" class="cursor-pointer" title="点击切换等级" @click="updRec(i, { residual: lvlNext(rec.residual) as '高' | '中' | '低' })">
{{ rec.residual }}
</NTag>
</td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag size="small" type="info">{{ rec.source }}</NTag>
</td>
<td class="border px-1.5 py-1 text-center align-top">
<NTag v-if="rec.pssr" size="small" :color="{ color: '#f5f3ff', textColor: '#7c3aed', borderColor: '#f5f3ff' }">已转 PSSR</NTag>
<NButton text size="tiny" color="#7c3aed" v-else class="hover:underline" @click="toPssr(i)"> PSSR</NButton>
<NButton text type="error" size="tiny" class="mt-2" @click="riskRecords = riskRecords.filter((_, j) => j !== i)">删除</NButton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- 变更培训内容 -->
<template v-if="tab === 'train'">
<div class="flex flex-wrap items-center gap-3 mb-3">
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
<Icon icon="lucide:graduation-cap" class="size-16px text-violet-500" />
变更培训内容
</span>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<span class="ml-auto text-xs text-slate-400">AI 生成内容均可点击修改自定义审批通过后相关人员在 PC / 移动端完成学习与签到确认</span>
</div>
<!-- 培训内容 -->
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex flex-wrap items-center gap-2 bg-slate-100 px-3 py-2">
<span class="text-sm font-medium text-slate-700">培训内容</span>
<span class="text-[11px] text-slate-400">含培训对象培训方式变更内容操作方式潜在风险与应对措施一个文本框自动分段换行</span>
<span class="ml-auto text-[11px] text-slate-400">内容支持直接修改</span>
</div>
<NInput v-model:value="trainText" type="textarea" :bordered="false" :autosize="{minRows: 4}" class="font-mono text-sm" placeholder="" />
</div>
</template>
<!-- PSSR 检查内容 -->
<template v-if="tab === 'pssr'">
<div class="flex flex-wrap items-center gap-3">
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
<Icon icon="tabler:clipboard-check" class="size-16px text-violet-500" />
PSSR 投用前安全检查内容
</span>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<NTag size="small" :type="pssrAllDone ? 'success' : 'info'">
已完成 {{ pssrDoneCount }}/{{ pssrTotal }}
</NTag>
<span class="flex items-center gap-2">
<NButton ghost size="small" class="text-xs" title="上传 PSSR 纸质确认表照片 / 扫描件(JPG / PNG / PDF),系统不做内容识别,上传即视为全部检查项完成确认" @click="pssrFileRef?.click()">
<Icon icon="material-symbols:upload" class="size-14px" />
上传纸质确认表
</NButton>
<input ref="pssrFileRef" type="file" accept="image/*,.pdf" class="hidden" @change="onPssrFile" />
<span class="hidden text-xs text-slate-400 xl:inline">申请阶段点击条目内容直接编辑可增删项完成时点互斥选择审批通过后逐项点击确认PC / 移动端均可),全部完成自动推送属地负责人</span>
</span>
</div>
<div v-if="pssrSheet" class="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2 text-xs text-emerald-700">
<Icon icon="ix:success" class="size-16px" />
已上传 PSSR 纸质确认表{{ pssrSheet }}」:上传即视为全部检查项完成确认未做内容识别),原件已归档至变更档案
</div>
<div v-if="pssrAllDone" class="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-xs text-emerald-700">
<Icon icon="ix:success" class="size-16px" />
全部 PSSR 检查项已确认完成,「具备投用条件信息已自动推送属地负责人车间 / 装置主任),可进行投用确认
</div>
<div v-for="(g, gi) in pssrGroups" :key="g.g" class="border" :class="pssrNa[g.g] ? 'opacity-70' : ''" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">
{{ g.g }}
<NTag v-if="pssrNa[g.g]" size="small" class="ml-2">不涉及</NTag>
<span v-else class="ml-2 text-xs font-normal text-slate-400">{{ g.items.filter((it) => pssrDone[it.c]).length }}/{{ g.items.length }} 已确认</span>
<NButton text type="primary" class="ml-auto text-xs font-normal hover:underline"
:title="pssrNa[g.g] ? '恢复本组检查清单' : '本次变更不涉及本组内容时点击标记,标记后不计入完成统计'"
@click="pssrNa = { ...pssrNa, [g.g]: !pssrNa[g.g] }">
{{ pssrNa[g.g] ? '恢复清单' : '标记不涉及' }}
</NButton>
<NButton text type="primary" v-if="!pssrNa[g.g]" class="ml-3 text-xs font-normal hover:underline"
@click="pssrGroups = pssrGroups.map((x, xi) => xi === gi ? { ...x, items: [...x.items, { c: '新检查项(点击直接编辑内容)', p: '投用前' }] } : x)">
<Icon icon="ic:round-plus" class="size-14px" /> 添加检查项
</NButton>
</div>
<div v-if="pssrNa[g.g]" class="border-t bg-slate-50 px-3 py-3 text-xs text-slate-400">
本组内容经评估本次变更不涉及无现场安装作业 / 不改变保护措施 / 无需归档资料),已不计入 PSSR 完成统计如需恢复检查清单点击右上角恢复清单」。
</div>
<template v-else>
<div v-for="(it, i) in g.items" :key="`${gi}-${i}`" class="flex flex-wrap items-center gap-3 border-t px-3 py-2.5 text-sm">
<span class="w-5 text-slate-400">{{ i + 1 }}</span>
<NInput
v-if="editing === `pssr-c-${gi}-${i}`"
class="flex-1"
v-model:value="it.c"
type="textarea"
autofocus
rows="2"
@blur="editing = null"
/>
<button v-else @click="editing = `pssr-c-${gi}-${i}`" title="点击编辑内容"
class="min-w-0 flex-1 rounded-md px-1 text-left text-slate-800 transition hover:bg-[#EAF0F9] bg-transparent"
>
{{ it.c }}
</button>
<span class="flex items-center gap-1 py-1">
<NRadio :checked="it.p === '投用前'" :value="'投用前'" label="投用前完成"
@change="pssrGroups = pssrGroups.map((x, xi) => xi === gi ? { ...x, items: x.items.map((y, yi) => yi === i ? { ...y, p: '投用前' } : y) } : x)" />
<NRadio :checked="it.p === '关闭前'" :value="'关闭前'" label="关闭前完成"
@change="pssrGroups = pssrGroups.map((x, xi) => xi === gi ? { ...x, items: x.items.map((y, yi) => yi === i ? { ...y, p: '关闭前' } : y) } : x)" />
</span>
<NButton text type="error" title="删除该项"
@click="pssrGroups = pssrGroups.map((x, xi) => xi === gi ? { ...x, items: x.items.filter((_, yi) => yi !== i) } : x)"
>
<Icon icon="iconamoon:trash" class="size-16px" />
</NButton>
</div>
</template>
</div>
<div class="rounded-lg bg-violet-50/70 px-4 py-2.5 text-xs text-violet-700">
完成时点说明:「投用前完成 / 关闭前完成为互斥选择点击即可切换投用前完成的项未确认时属地负责人无法确认投用关闭前完成的项不阻塞投用但纳入关闭校验清单未完成时变更主管部门专责无法确认关闭
</div>
<div class="rounded-lg bg-violet-50/70 px-4 py-2.5 text-xs text-violet-700">
清单由 AI 依据变更类型与风险分析自动生成专业人员审核后随审批发布确认方式不作硬性要求——可在 PC 端直接点击确认也可在移动端拍照上传作为佐证系统自动记录确认人与日期);资料归档组确认后自动同步变更关闭确认表显示已归档
</div>
</template>
<!-- 验收评价 -->
<template v-if="tab === 'accept'">
<div class="flex flex-wrap items-center gap-3">
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
<Icon icon="ix:success" class="size-18px text-emerald-500" />
验收评价依据预期效果设定可量化验收标准
</span>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<span class="ml-auto text-xs text-slate-400">申请阶段由 AI 依据预期效果生成量化标准与验收方式可手动修改投用运行后由车间和各专业按标准验收并给出评估结论</span>
</div>
<div
class="space-y-1.5 rounded-lg border border-[var(--border-color)] bg-[var(--bg-color)] px-4 py-3 text-xs leading-6 text-slate-700"
:style="{
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1),
'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)
}"
>
<div><b :style="{color: themeStore.themeColor}">变更目的</b>{{ form?.purposes?.join('、') || '提高生产效能、改善操作条件' }}</div>
<div><b :style="{color: themeStore.themeColor}">预期效果</b>{{ form?.effect || '在保持转化率不低于98%的前提下,反应时间缩短50%,产能提升一倍' }}</div>
<div
class="border-t border-[var(--border-color)] pt-1.5 text-slate-500"
:style="{'--border-color': getColorWithOpacity(themeStore.themeColor, 0.2)}"
>
<b :style="{color: themeStore.themeColor}">对应说明</b>验收项目由预期效果逐条拆解设定各项目对应的预期效果来源见表格验收项目列下方灰色小字点击可修改);验收时须逐项对照预期效果判定达成程度
</div>
</div>
<div class="overflow-x-auto border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<table class="w-full min-w-[900px] border-collapse text-sm">
<thead>
<tr class="bg-slate-100 text-left text-slate-600">
<th class="w-8 px-3 py-2 font-medium">#</th>
<th class="w-56 px-3 py-2 font-medium">验收项目含对应预期效果</th>
<th class="px-3 py-2 font-medium">量化验收标准</th>
<th class="px-3 py-2 font-medium">验收方式可执行</th>
<th class="w-32 px-3 py-2 font-medium">责任单位/</th>
<th class="px-3 py-2 font-medium">评估结论</th>
<th class="w-12 px-2 py-2 font-medium text-center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in acceptRows" :key="row.id" class="align-top">
<td class="border-t px-3 py-2.5 text-slate-400">{{ i + 1 }}</td>
<td class="border-t px-3 py-2.5">
<NInput
v-if="editing === `acc-${row.id}-item`"
class="text-xs"
v-model:value="row.item"
type="textarea"
autofocus
rows="2"
@blur="editing = null"
/>
<button v-else @click="editing = `acc-${row.id}-item`" title="点击修改"
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs font-medium leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
>
{{ row.item }}
</button>
<NInput
v-if="editing === `acc-${row.id}-basis`"
class="text-[11px]"
size="small"
v-model:value="row.basis"
autofocus
@blur="editing = null"
/>
<button v-else @click="editing = `acc-${row.id}-basis`" title="本项目对应的预期效果来源,点击修改"
class="bg-transparent mt-0.5 block w-full rounded px-2 text-left text-[11px] text-slate-400 transition hover:bg-[#EAF0F9] hover:text-slate-500"
>
对应预期{{ row.basis || '(点击填写对应预期效果)' }}
</button>
</td>
<td v-for="field in (['standard', 'method', 'owner'])" :key="field" class="border-t px-3 py-2.5">
<NInput
v-if="editing === `acc-${row.id}-${field}`"
class="text-xs"
v-model:value="row[field]"
type="textarea"
autofocus
rows="2"
@blur="editing = null"
/>
<button v-else @click="editing = `acc-${row.id}-${field}`" title="点击修改"
class="bg-transparent block w-full rounded-md px-2 py-1 text-left text-xs leading-relaxed text-slate-700 transition hover:bg-[#EAF0F9]"
>
{{ row[field] }}
</button>
</td>
<td class="border-t px-3 py-2.5">
<div class="flex flex-nowrap items-center gap-1">
<NButton v-for="v in (['完全达到', '基本达到', '未达成'])" :key="v"
size="small"
round
@click="acceptRows = acceptRows.map((x) => (x.id === row.id ? { ...x, result: x.result === v ? '待验收' : v } : x))"
class="text-[11px]"
:type="row.result === v
? v === '未达成' ? 'error' : v === '基本达到' ? 'warning' : 'success'
: 'default'">
{{ v }}
</NButton>
</div>
</td>
<td class="border-t px-2 py-2.5 text-center">
<NButton text type="error" title="删除本验收项" @click="acceptRows = acceptRows.filter((x) => x.id !== row.id)">
<Icon icon="iconamoon:trash" class="size-16px" />
</NButton>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center gap-3">
<NButton ghost class="text-xs"
@click="acceptRows = [...acceptRows, { id: Date.now(), item: '新验收项目', basis: '', standard: '', method: '', owner: '', result: '待验收' }]">
<Icon icon="ic:round-plus" class="size-16px" />添加验收项
</NButton>
<span class="text-xs text-slate-400">评估结论在验收阶段由责任单位填写点击选择完全达到 / 基本达到 / 未达成」,再次点击取消行尾可删除验收项</span>
</div>
<div class="rounded-lg border">
<div class="border-b bg-slate-100 px-3 py-2 text-sm font-medium text-slate-700">综合验收结论验收阶段由车间会同各专业评估填写</div>
<div class="space-y-3 p-3">
<div class="flex flex-wrap items-center gap-2">
<span v-for="v in (['完全达到', '基本达到', '未达成'])" :key="v">
<NRadio :checked="acceptNote !== '' && acceptNote.startsWith(v)" :value="'v'" :label="v" @change="acceptNote = v + ''"></NRadio>
</span>
<span class="text-xs text-slate-400">基本达到 / 未达成须说明偏差原因与处置措施并纳入变更关闭审核</span>
</div>
<NInput v-model:value="acceptNote" type="textarea" rows="2" placeholder="验收意见:总体评价、偏差说明、后续跟踪措施…(验收阶段填写)" />
</div>
</div>
<div class="rounded-lg bg-emerald-50/80 px-4 py-2.5 text-xs leading-5 text-emerald-700">
流程说明投用运行满验收周期后系统向车间属地与涉及专业推送验收任务各责任单位按上表逐项给出评估结论并上传佐证数据报表 / 化验报告 / 现场照片),全部完成后汇总至综合验收结论结论与佐证资料一并纳入变更关闭校验验收不通过时退回整改或评估恢复原状
</div>
</template>
<!-- 变更关闭确认表 -->
<template v-if="tab === 'close'">
<div class="flex flex-wrap items-center gap-3">
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
<Icon icon="quill:folder-open" class="size-16px" />
变更关闭确认表资料归档清单
</span>
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<NTag size="small" class="text-xs">AI 辅助判定一次 · 支持人工改判留痕</NTag>
<span class="ml-auto text-xs text-slate-400">判定为的资料全部归档后变更主管部门专责方可确认关闭</span>
</div>
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
<div class="flex items-center gap-3 bg-slate-100 px-3 py-2 text-xs font-medium text-slate-500">
<span class="w-5" />
<span class="flex-1">资料名称与申请表需更新的资料勾选联动勾选资料标记需更新版本」)</span>
<span class="w-48 text-center">是否涉及AI 判定可改判</span>
<span class="w-32 text-center">归档状态</span>
</div>
<template v-for="cat in (['图纸与规程类', '台账与制度类', '过程与结果文件类'])" :key="cat">
<div v-if="closeDocs.filter((d) => d.cat === cat).length">
<button @click="closeFold = { ...closeFold, [cat]: !closeFold[cat] }"
class="flex w-full items-center gap-2 border-t bg-slate-50 px-3 py-2 text-left text-xs font-semibold text-slate-600 transition hover:bg-[#EAF0F9]">
<Icon icon="formkit:right" class="size-14px text-slate-400 transition" :class="closeFold[cat] ? '' : 'rotate-90'" />
{{ cat }}
<span class="font-normal text-slate-400">
{{ closeDocs.filter((d) => d.cat === cat).length }} · 已归档
{{ closeDocs.filter((d) => d.cat === cat && d.scope === '是' && d.archived).length }}/{{ closeDocs.filter((d) => d.cat === cat && d.scope === '是').length }}
</span>
<span class="ml-auto text-[11px] font-normal text-slate-400">{{ closeFold[cat] ? '点击展开' : '点击折叠' }}</span>
</button>
<template v-if="!closeFold[cat]">
<div v-for="d in closeDocs.filter((x) => x.cat === cat)" :key="d.name" class="flex flex-wrap items-center gap-3 border-t px-3 py-2.5 text-sm">
<span class="w-5 text-slate-400">{{ closeDocs.indexOf(d) + 1 }}</span>
<span class="min-w-0 flex-1 text-slate-800">
{{ d.name }}
<NTag v-if="needUpdate(d.name)" size="small" type="info" class="ml-2">需更新版本</NTag>
</span>
<span class="flex w-48 shrink-0 justify-center gap-1.5">
<NButton size="tiny" round v-for="v in (['是', '否', '不涉及'])" :key="v"
@click="changeScope(d, v)"
class="text-[11px]"
:type="d.scope === v ? 'primary' : 'default'">
{{ v }}
</NButton>
</span>
<span class="flex w-32 shrink-0 items-center justify-center gap-2">
<template v-if="d.scope === '是'">
<NTag v-if="d.archived" size="small" type="success">已归档</NTag>
<template v-else>
<NTag size="small" type="warning">待上传</NTag>
<NButton text type="primary" class="text-xs hover:underline" @click="uploadDoc = d.name; uploadSel = null;">上传</NButton>
</template>
</template>
<NTag v-else size="small">{{ d.scope }}</NTag>
</span>
</div>
</template>
</div>
</template>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs text-slate-500">
<span>
统计涉及 <b class="text-[#17407F]">{{ closeDocs.filter((d) => d.scope === '是').length }}</b> ·
已归档 <b class="text-emerald-600">{{ closeDocs.filter((d) => d.scope === '是' && d.archived).length }}</b> ·
待上传 <b class="text-amber-600">{{ closeDocs.filter((d) => d.scope === '是' && !d.archived).length }}</b> ·
需更新版本 <b class="text-[#1D4E9C]">{{ closeDocs.filter((d) => needUpdate(d.name)).length }}</b> 联动申请表勾选
</span>
<span class="text-slate-400">资料上传支持 PC / 移动端拍照上传),版本受控</span>
</div>
<div class="rounded-lg bg-[#EAF0F9] px-4 py-2.5 text-xs leading-5 text-[#17407F]">
关闭校验说明判定为的资料项全部归档后系统将全部流程完毕信号推送变更主管部门专责专责确认后点击关闭系统记录变更关闭时间;「 / 不涉及 AI 辅助判定一次人工改判须填写理由并留痕临时变更恢复完成后恢复操作票与参数核对记录自动追加至本清单
</div>
</template>
</div>
</div>
<!-- 底部 -->
<!-- 全局按钮下载 / 重新生成 / 确认完毕 -->
<div v-if="tab !== 'pre'" class="flex flex-wrap items-center justify-between gap-2 border-t p-4">
<div class="flex flex-wrap gap-2">
<NButton ghost type="success" @click="download">
<Icon icon="material-symbols:download" class="size-16px" /> 下载{{ TAB_NAME[tab] }}
</NButton>
<NButton ghost type="warning" @click="regenerate">
<Icon icon="tdesign:refresh" class="size-14px" /> 重新生成{{ TAB_NAME[tab] }}
</NButton>
<NButton ghost :type="confirmed[tab] ? 'success' : 'primary'" @click="confirmTab(tab)">
<Icon icon="ix:success" class="size-16px" /> {{ confirmed[tab] ? '已确认完毕' : '本标签内容确认完毕' }}
</NButton>
</div>
<span class="text-xs text-slate-400">审批人 / 保存 / 提交均在底部固定行滚动时保持可见</span>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 bg-[#EAF0F9] px-4 py-2.5 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
<template v-if="tab === 'pre'">
<span class="text-xs text-slate-400">预识别阶段不设审批人与提交完成识别与预分析后请切换至变更申请表选择审批人并提交</span>
<div class="ml-auto flex shrink-0 gap-2">
<NButton ghost type="success" size="small" @click="genForm">
<Icon icon="akar-icons:file" class="size-14px" /> 生成变更申请单
</NButton>
<NButton ghost type="warning" size="small" @click="runRiskAnalysis" :disabled="analyzing">
<Icon v-if="analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" />
<Icon v-else icon="mdi:shield-alert-outline" class="size-14px" />风险预分析
</NButton>
<NButton type="primary" size="small" @click="saveDraft">保存</NButton>
</div>
</template>
<template v-else>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500">
<Icon icon="lucide:users" class="size-14px" />
<span class="min-w-0 truncate">
部门审核{{ approvers.部门审核 }} 专业会签{{ Object.keys(signers).length ? Object.values(signers).join('、') : '未涉及专业 · 免会签' }} 最终批准{{ approvers.最终批准 }}
</span>
<NButton ghost size="small" type="primary" @click="sheet = 'approver'">
<Icon icon="octicon:sliders-24" class="size-14px" />
审批流程设置
</NButton>
</div>
<div class="ml-auto flex shrink-0 items-center gap-2">
<NButton size="small" type="primary" @click="saveDraft">保存</NButton>
<span v-if="!form" class="flex items-center text-xs text-amber-600">请先在变更预识别页生成变更申请单</span>
<NButton size="small" type="primary" @click="submitApply" :disabled="!form" :title="form ? '提交进入审批流' : '请先生成变更申请单'">
提交 <Icon icon="formkit:right" class="size-14px" />
</NButton>
</div>
</template>
</div>
</div>
<!-- 附件在线预览弹窗 -->
<NModal
:show="previewFile !== null"
preset="card"
:auto-focus="false"
:style="{ width: '700px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
@update:show="(v: boolean) => !v && (previewFile = null)"
>
<template #header>
<div class="flex items-center gap-2 text-base font-semibold text-slate-800">
<Icon icon="akar-icons:file" class="size-18px" /> {{ previewFile }}
</div>
</template>
<template #default>
<div class="space-y-3">
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<span>上传人{{ ROLES[0].person }}</span><span>上传时间{{ today() }}</span><span>版本V1受控</span>
</div>
<div class="flex h-72 flex-col items-center justify-center gap-2 rounded-lg border bg-slate-50 text-slate-400">
<Icon icon="akar-icons:file" class="size-42px" />
<span class="text-sm">文件在线预览区演示</span>
<span class="text-xs">正式环境由文档预览服务渲染 PDF / Word / 图片内容支持缩放翻页与移动端查看</span>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-[10px]">
<NButton ghost size="small" @click="downloadPdf">
<Icon icon="material-symbols:download" class="size-16px" /> 下载
</NButton>
<NButton size="small" type="primary" @click="previewFile = null">关闭</NButton>
</div>
</template>
</NModal>
<!-- 侧边抽屉AI 类型判定 / 等级判定表 / 审批流程设置 -->
<NDrawer :show="sheet !== null" @update:show="(v: boolean) => !v && (sheet = null)" placement="right" :width="sheet === 'level' ? '45rem' : '28rem'">
<NDrawerContent>
<template #header>
<div v-if="sheet === 'type'" class="text-base font-semibold text-slate-800">AI 变更类型判定说明</div>
<div v-if="sheet === 'level'" class="text-base font-semibold text-slate-800">变更等级判定表</div>
<div v-if="sheet === 'approver'" class="text-base font-semibold text-slate-800">审批流程设置</div>
</template>
<div class="h-full space-y-4 overflow-y-auto">
<!-- AI 变更类型判定说明 -->
<template v-if="sheet === 'type'">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<NTag v-if="typeAiLoading" type="info" size="small">
<Icon icon="ri:loader-4-fill" class="size-12px animate-spin" /> AI 判定中
</NTag>
<template v-else>
<NTag type="info" size="small">判定{{ typeAi?.type || '工艺' }}</NTag>
<NTag size="small">置信度{{ typeAi ? `${Math.round(typeAi.confidence * 100)}%` : '高' }}</NTag>
</template>
</div>
<div v-if="typeAi" class="space-y-3 text-sm leading-relaxed text-slate-700">
<p><b>判定结论</b>{{ typeAi.conclusion }}</p>
<p><b>判定依据</b></p>
<ul class="list-disc space-y-1 pl-5">
<li v-for="r in typeAi.hit_rules" :key="r">命中规则{{ r }}</li>
<li v-for="r in typeAi.exclude_rules" :key="r">排除规则{{ r }}</li>
</ul>
<p v-if="typeAi.knowledge_refs.length"><b>知识库引用</b>{{ typeAi.knowledge_refs.join('、') }}</p>
<p class="rounded-lg bg-amber-50 p-3 text-xs text-amber-700">AI 判定结果仅供参考最终类型以人工确认为准如调整为其他类型系统将记录修改留痕</p>
</div>
<div v-else class="space-y-3 text-sm leading-relaxed text-slate-700">
<p><b>判定结论</b>本次变更涉及反应温度80℃→90℃)、保温时间8h4h等工艺参数调整及 DCS 报警/联锁值修改未涉及设备本体更换或材质变更依据变更类型规则判定为工艺类变更</p>
<p><b>判定依据</b></p>
<ul class="list-disc space-y-1 pl-5">
<li>命中规则 2.1操作参数超出原设计控制范围 工艺类</li>
<li>命中规则 2.4DCS 报警值/联锁值调整 工艺类仪表联动</li>
<li>排除规则 3.2无设备规格型号变化 不构成设备类</li>
</ul>
<p><b>知识库引用</b>工艺卡片 R-101Rev B)、DCS 联锁台账、《变更管理制度 4.2 </p>
<p class="rounded-lg bg-amber-50 p-3 text-xs text-amber-700">AI 判定结果仅供参考最终类型以人工确认为准如调整为其他类型系统将记录修改留痕</p>
</div>
</template>
<!-- 变更等级判定表 -->
<template v-if="sheet === 'level'">
<div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full border border-violet-200 bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700">
AI 生成 · 需人工确认
</span>
<NTag size="small" :type="totalScore >= 20 ? 'error' : 'warning'">
{{ levelResult }}
</NTag>
<span class="text-xs text-slate-400">合计得分 <b class="text-[#17407F]">{{ totalScore }}</b> &lt;20 分为一般,≥20 分为重要</span>
</div>
<div class="grid gap-3 grid-cols-2">
<div v-for="(dim, di) in LEVEL_DIMS" :key="dim.name" class="rounded-lg border p-3">
<div class="mb-2 flex items-baseline justify-between gap-2">
<span class="text-sm font-semibold text-slate-700">
{{ dim.name }}
<span v-if="dim.note" class="ml-1 text-xs font-normal text-slate-400">{{ dim.note }}</span>
</span>
<span class="text-sm text-slate-500">得分 <b class="text-[#17407F]">{{ levelScores[di] }}</b></span>
</div>
<div class="space-y-1.5">
<button v-for="op in dim.options" :key="op.score" @click="levelScores = levelScores.map((s, i) => (i === di ? op.score : s))"
class="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
:class="levelScores[di] === op.score ? 'bg-[#EAF0F9] text-[var(--theme-color)]' : 'text-slate-600 hover:bg-slate-50 bg-white'"
:style="{'--theme-color': themeStore.themeColor}"
>
<span
class="mt-0.5 flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border"
:class="levelScores[di] === op.score ? 'border-[var(--theme-color)]' : 'border-slate-300'"
:style="{'--theme-color': themeStore.themeColor}"
>
<span
v-if="levelScores[di] === op.score"
class="h-1.5 w-1.5 rounded-full bg-[var(--theme-color)]"
:style="{'--theme-color': themeStore.themeColor}"
/>
</span>
<span class="flex-1 leading-snug">{{ op.label }}{{ op.score }}</span>
</button>
</div>
</div>
</div>
<div class="rounded-lg border border-violet-200 bg-violet-50/50 p-4">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-violet-700">
<Icon icon="lucide:sparkles" class="size-14px" />
AI 评分分析过程
<span v-if="levelAiLoading" class="flex items-center gap-1 text-xs font-normal text-violet-500">
<Icon icon="ri:loader-4-fill" class="size-12px animate-spin" /> AI 评分中
</span>
</div>
<div v-if="levelAiText || levelAiBasis.some((x) => x)" class="space-y-3 text-xs leading-relaxed text-slate-600">
<p v-if="levelAiText">{{ levelAiText }}</p>
<p v-for="(dim, di) in LEVEL_DIMS" :key="dim.name">
<template v-if="levelAiBasis[di]"><b class="text-[#17407F]">{{ dim.name }}</b>{{ levelAiBasis[di] }}</template>
</p>
<p class="text-slate-400">各维度得分均可人工改判修改留痕改判后等级自动回填至申请表</p>
</div>
<div v-else class="space-y-3 text-xs leading-relaxed text-slate-600">
<p>我们分析变更内容缩合反应温度从80提高到90℃,反应时间从8小时缩短到4小时设备材质SS304设计温度100℃,设计压力0.2MPa压力不变常压)。物料为原料A与原料B未具体说明毒性腐蚀性逐维度评分如下</p>
<p><b class="text-[#17407F]">1. 设备影响</b>温度从80升至90℃,仍在设计温度100范围内但接近上限SS304 8090 下腐蚀速率可能略有增加但变化不大保守起见给 2 中等损害)。</p>
<p><b class="text-[#17407F]">2. 财务影响</b>变更目的是提升产能但若设计错误可能导致反应失败批次报废温度提高10℃、时间缩短一半控制不好可能影响产品质量损失可能中等1~10), 2 </p>
<p><b class="text-[#17407F]">3. 质量影响</b>涉及主要工艺参数变更且有未知杂质0.05%0.12%结构未定对质量带来明显风险 3 </p>
<p class="text-slate-400">各维度得分均可人工改判修改留痕改判后等级自动回填至申请表</p>
</div>
</div>
</template>
<!-- 审批流程设置 -->
<template v-if="sheet === 'approver'">
<p class="text-xs leading-relaxed text-slate-400">审批链部门审核 专业会签 最终批准专业会签人由申请表涉及专业勾选自动增减可在此调整人选重要变更自动追加安全审查与分管领导终批</p>
<div class="rounded-lg border p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700">
<NTag type="info" size="small">1</NTag> 部门审核
</div>
<NSelect :value="approvers.部门审核" :options="DEPT_APPROVER_OPTS"
@update:value="(v: string) => approvers = { ...approvers, 部门审核: v }" />
</div>
<div class="rounded-lg border p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700">
<NTag type="info" size="small">2</NTag> 专业会签
<span class="text-[11px] font-normal text-slate-400">涉及专业勾选联动</span>
</div>
<p v-if="Object.keys(signers).length === 0" class="text-xs text-slate-400">未勾选涉及专业本环节免会签如需会签请回申请表勾选涉及专业」。</p>
<div v-else class="space-y-2">
<div v-for="(p, d) in signers" :key="d" class="flex items-center gap-2">
<NTag size="small">{{ d }}</NTag>
<NSelect class="flex-1" :value="p" :options="signerOpts(p)"
@update:value="(v: string) => signers = { ...signers, [d]: v }" />
</div>
</div>
</div>
<div class="rounded-lg border p-3">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-700">
<NTag type="info" size="small">3</NTag> 最终批准
<NTag size="small" class="text-11px">制度固定</NTag>
</div>
<NInput :value="approvers.最终批准" disabled />
<p class="mt-1.5 text-[11px] text-slate-400">重要变更自动追加安全审查安全环保部)」分管领导终批节点无需手动设置</p>
</div>
<NButton type="primary" class="w-full" @click="finishApprover">完成设置</NButton>
</template>
</div>
</NDrawerContent>
</NDrawer>
<!-- 资料上传弹窗 -->
<NModal
:show="uploadDoc !== null"
preset="card"
:auto-focus="false"
:style="{ width: '550px', height: 'auto' }"
:segmented="{ content: true, footer: true }"
@update:show="(v: boolean) => !v && (uploadDoc = null)"
>
<template #header>
<div class="flex items-center gap-2 text-base font-semibold text-slate-800">
<Icon icon="material-symbols:upload" class="size-20px" /> 资料上传归档 {{ uploadDoc }}
</div>
</template>
<div class="space-y-3">
<div>
<div class="mb-1 text-xs font-medium text-slate-500">系统文档存储地址受控目录</div>
<div class="flex items-center gap-2 rounded-lg border bg-slate-50 px-3 py-2">
<Icon icon="quill:folder-open" class="size-16px" />
<code class="min-w-0 flex-1 truncate text-xs text-slate-600">{{ uploadDocInfo?.storePath }}</code>
<NButton text type="primary" class="text-xs hover:underline" @click="openDir">打开目录</NButton>
</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-slate-500">方式一从存储地址选择资料</div>
<div class="space-y-1.5">
<p v-for="f in uploadDocInfo?.candidates" :key="f" @click="uploadSel = f"
class="flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left text-xs transition cursor-pointer"
:class="uploadSel === f ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-[var(--theme-color)] text-white' : 'bg-white text-slate-600 hover:border-[var(--theme-color)] hover:text-[var(--theme-color)]'"
:style="{'--theme-color': themeStore.themeColor}"
>
<Icon icon="akar-icons:file" class="size-14px mr-1" /> {{ f }}
<Icon v-if="uploadSel === f" icon="ix:success" class="ml-auto size-16px" />
</p>
</div>
</div>
<div>
<div class="mb-1 text-xs font-medium text-slate-500">方式二拖拽本地文件到区域</div>
<button @click="uploadFiles"
class="flex h-20 w-full flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed px-3 text-xs transition"
:class="uploadSel?.startsWith('本地文件') ? 'border-[var(--theme-color)] bg-[#EAF0F9] text-[var(--theme-color)]' : 'border-slate-300 text-slate-400 hover:border-[var(--theme-color)] hover:text-[var(--theme-color)]'"
:style="{'--theme-color': themeStore.themeColor}"
>
<Icon icon="material-symbols:upload" class="size-24px" />
{{ uploadSel?.startsWith('本地文件') ? uploadSel : '将资料文件拖拽到此处,或点击选择本地文件' }}
</button>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="uploadDoc = null">取消</NButton>
<NButton type="primary" :disabled="!uploadSel" @click="uploadConfirm">
确认上传归档
</NButton>
</div>
</template>
</NModal>
<!-- 插件抽屉 -->
<NDrawer v-model:show="pluginDrawer" :width="1200" placement="right">
<NDrawerContent>
<template v-if="currentPlugin === 'HAZOP'">
<Hazop @back="onBack" @export="exportAll" />
</template>
<template v-else-if="currentPlugin === 'JSA'">
<Jsa @back="onBack" @export="exportAll" />
</template>
<template v-else-if="currentPlugin === 'SCL'">
<Scl @back="onBack" @export="exportAll" />
</template>
<template v-else-if="currentPlugin === 'RISK_CHECK'">
<RiskCheck @back="onBack" />
</template>
</NDrawerContent>
</NDrawer>
</NCard>
</NSpace>
</template>
<style scoped lang="scss">
.scroll {
height: calc(100vh - 326px);
overflow-y: auto;
}
.scroll2 {
height: calc(100vh - 393px);
overflow-y: auto;
}
</style>