@@ -1,41 +1,106 @@
< script setup lang = "ts" >
import { computed , ref } from "vue" ;
import { computed , onMounted , ref } from "vue" ;
import { useMessage } from "naive-ui" ;
import { Icon } from '@iconify/vue'
import { useThemeStore } from '@/store/modules/theme' ;
import { AiError , aiHazopDeviation } from '@/service/api/ai' ;
import Ed from "./Ed.vue" ;
import { matrixConfigApi } from "@/service/api/system" ;
import {
hazopNodesApi ,
hazopNodeAddApi ,
hazopObjAddApi ,
hazopDevAddApi ,
hazopNodeEditApi ,
hazopObjEditApi ,
hazopDevEditApi ,
hazopNodeDelApi ,
hazopObjDelApi ,
hazopDevDelApi ,
hazopRowAddApi
} from "@/service/api/plugin" ;
const themeStore = useThemeStore ( ) ;
const message = useMessage ( ) ;
const emit = defineEmits < {
back : [ ] ;
done : [ records : number ] ;
export : [ items : any [ ] , mode : "manual" | "ai" ] ;
save : [ ] ;
} > ( ) ;
const props = defineProps ( [ 'isAiOpen' , 'currentId' ] )
const emit = defineEmits ( [ 'close' , 'export' ] ) ;
const loading = ref < boolean > ( false ) ;
const treeLoading = ref < boolean > ( false ) ;
// 删除确认弹窗
const delModal = ref < boolean > ( false ) ;
// 删除确定状态
const delLoading = ref < boolean > ( false ) ;
// 新增/编辑弹窗
const editModal = ref < boolean > ( false ) ;
// 当前弹框标题
const currentModalTitle = ref < string > ( '' ) ;
// 当前弹框类型
const currentModalType = ref < string > ( '' ) ;
// 当前弹框对象
const currentModalObj = ref < string > ( '' ) ;
// 弹窗保存状态
const modalSaving = ref < boolean > ( false ) ;
const name = ref < string > ( '' ) ;
// 风险矩阵配置
const riskMatrixConfig = ref < any > ( null ) ;
const l _options = computed ( ( ) => {
const newArr = riskMatrixConfig . value ? . consequence ? . map ( ( item : { key : string , label : string } , index : number ) => ( {
... item ,
key : Number ( index + 1 )
} ) ) ;
return newArr ;
} ) ;
const s _options = computed ( ( ) => {
const newArr = riskMatrixConfig . value ? . probability ? . map ( ( item : { key : string , label : string } , index : number ) => ( {
... item ,
key : Number ( index + 1 )
} ) ) ;
return newArr ;
} ) ;
// 右键菜单状态
const contextMenuShow = ref < boolean > ( false )
const contextMenuX = ref < number > ( 0 )
const contextMenuY = ref < number > ( 0 )
// 存储当前右键点击的目标数据
const contextTarget = ref < { type : string , node : any , obj : any , dev : any } > ( {
type : '' , // 'node' | 'obj' | 'dev'
node : null ,
obj : null ,
dev : null ,
} )
// 当前节点
const currentNode = computed ( ( ) => {
const targetId = currentDev . value ? . id ;
if ( ! targetId ) return null ;
for ( const top of nodes . value ) {
const matchedObj = top . objects ? . find ( ( obj : any ) => obj . deviations ? . some ( ( dev : any ) => dev . id === targetId ) ) ;
if ( matchedObj ) return top ;
}
return null ;
} ) ;
// 当前对象
const currentObj = computed ( ( ) => {
const targetId = currentDev . value ? . id ;
if ( ! targetId ) return null ;
for ( const top of nodes . value ) {
const matchedObj = top . objects ? . find ( ( obj : any ) => obj . deviations ? . some ( ( dev : any ) => dev . id === targetId ) ) ;
if ( matchedObj ) return matchedObj ;
}
return null ;
} ) ;
// 当前偏离
const currentDev = ref < any > ( null ) ;
// 展开收起
const collapsed = ref < Record < string , boolean > > ( { } )
// 节点列表
const nodes = ref < any > ( [ ] ) ;
// 表格样式
const TH = "border bg-slate-100 px-2 py-1.5 text-[11px] font-medium text-slate-500" ;
const TD = "border px-1.5 py-1 align-top" ;
const HAZOP _INIT = [
{ name : "节点1:缩合反应釜 R-101" , obj : "R-101 缩合反应釜" , devs : [
{ dev : "温度过高" , rows : [
{ cause : "温控回路失效或冷却水中断;90℃ 新工艺窗口更接近溶剂回流温度,超温溜温概率上升" , cons : "反应溜温冲料,釜压骤升,物料喷溅造成灼烫与环境污染" , l : 3 , s : 4 , safe : "DCS 温度高报 95℃、高高报及联锁 100℃(随变更上调并重新整定验证);紧急冷却有效" , sug : "投用前完成联锁测试并留存记录;投用首周每班复核温度趋势" } ,
{ cause : "未知杂质含量升高(0.05%→0.12%),热稳定性尚未定性,长期累积加速釜壁结垢" , cons : "副反应加剧,可能引发二次分解放热,搅拌负荷异常" , l : 2 , s : 4 , safe : "小试热稳定性筛查;安全阀泄放能力不受本次调整影响,仍然有效" , sug : "将杂质含量纳入日常分析计划(每周 1 次,连续 8 周)" } ,
] } ,
{ dev : "温度过低" , rows : [
{ cause : "保温时间缩短至 4h,反应终点判断窗口变窄,人为误判" , cons : "转化率不足 98%,批次报废或返工,增加处置风险" , l : 3 , s : 2 , safe : "DCS 批次记录与转化率化验联动判定" , sug : "明确反应终点取样判定规程,首 5 批加密化验" } ,
] } ,
{ dev : "搅拌失效" , rows : [ ] } ,
{ dev : "冷却水中断" , rows : [ ] } ,
] } ,
{ name : "节点2: DCS 温控与联锁" , obj : "DCS 温控回路及联锁" , devs : [
{ dev : "报警 / 联锁值设置不当" , rows : [
{ cause : "报警 / 联锁值上调后(高报 85→95℃、联锁 90→100℃),原有报警裕度改变" , cons : "误报、漏报导致操作人员对真实超温失去预警" , l : 2 , s : 4 , safe : "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对" , sug : "联锁逻辑图与台账同步更新,纳入变更资料归档" } ,
] } ,
{ dev : "联锁拒动 / 误动" , rows : [ ] } ,
] } ,
] ;
const HAZOP _AI : any = {
"搅拌失效" : [
{ cause : "搅拌器机械故障或变频器跳闸,釜内物料传热不均" , cons : "局部过热引发暴沸冲料;温度测量失真误导操作" , l : 2 , s : 4 , safe : "搅拌电流 DCS 在线监视与低电流报警;跳闸联锁停进料" , sug : "变更后复核搅拌电流裕量,纳入交接班巡检" } ,
@@ -55,14 +120,13 @@ const lowerOf = (lv: "高" | "中" | "低"): "高" | "中" | "低" => {
return lv === "高" ? "中" : "低" ;
}
const nodes = ref < any > ( JSON . parse ( JSON . stringify ( HAZOP _INIT ) ) ) ;
const sel = ref ( { n : 0 , d : 0 } ) ;
const analyzing = ref ( false ) ;
const cur = computed ( ( ) => nodes . value [ sel . value . n ] . devs [ sel . value . d ] ) ;
const devTotal = computed ( ( ) => nodes . value . reduce ( ( a , n ) => a + n . devs . length , 0 ) ) ;
const devDone = computed ( ( ) => nodes . value . reduce ( ( a , n ) => a + n . devs . filter ( ( d ) => d . rows . length > 0 ) . length , 0 ) ) ;
const recTotal = computed ( ( ) => nodes . value . reduce ( ( a , n ) => a + n . devs . reduce ( ( b , d ) => b + d . rows . length , 0 ) , 0 ) ) ;
const devTotal = computed ( ( ) => 0 ) ;
const devDone = computed ( ( ) => 0 ) ;
const recTotal = computed ( ( ) => 0 ) ;
const toRec = ( obj : string , dev : string , row : any ) : any => ( {
item : ` ${ dev } ( ${ obj } ) ` ,
@@ -75,19 +139,19 @@ const toRec = (obj: string, dev: string, row: any): any => ({
} ) ;
const updRow = ( ri : number , patch : Partial < any > ) => {
const row = cur . value . rows [ ri ] ;
const row = currentDev . value . rows [ ri ] ;
if ( row ) Object . assign ( row , patch ) ;
} ;
const clampLS = ( v : string ) => Math . max ( 1 , Math . min ( 5 , + v || 1 ) ) ;
const runAi = async ( ) => {
if ( cur . value . rows . length ) return message . info ( "当前偏离已有分析记录,可手动新增行补充" ) ;
if ( currentDev . value . rows . length ) return message . info ( "当前偏离已有分析记录,可手动新增行补充" ) ;
analyzing . value = true ;
try {
const node = nodes . value [ sel . value . n ] ;
const res = await aiHazopDeviation ( {
node _name : node . name ,
deviation : cur . value . dev ,
deviation : currentDev . value . name ,
object _name : node . obj ,
change _context : {
change : "缩合反应温度及时间优化" ,
@@ -99,30 +163,21 @@ const runAi = async () => {
safe : r . safeguards ? ? "" , sug : r . suggestions ? ? "" ,
} ) ) ;
if ( ! rows . length ) throw new AiError ( 0 , "AI 未返回分析结果" ) ;
cur . value . rows = rows ;
message . success ( ` AI 已完成「 ${ cur . value . dev } 」偏离分析,生成 ${ rows . length } 条记录(支持手动修改) ` ) ;
currentDev . value . rows = rows ;
message . success ( ` AI 已完成「 ${ currentDev . value . name } 」偏离分析,生成 ${ rows . length } 条记录(支持手动修改) ` ) ;
} catch ( e : any ) {
const msg = e instanceof AiError ? e . message : "AI 服务异常" ;
window . $message ? . warning ( ` AI 分析失败: ${ msg } ,可手动填写 ` ) ;
// 静态示例数据作 fallback,不阻塞页面
const gen = HAZOP _AI [ cur . value . dev ] ? ? [
const gen = HAZOP _AI [ currentDev . value . name ] ? ? [
{ cause : "(AI 生成)偏离场景原因分析" , cons : "( AI 生成)可能后果描述" , l : 2 , s : 3 , safe : "现有保护层说明" , sug : "建议补充的管控措施" } ,
] ;
cur . value . rows = gen . map ( ( x : any ) => ( { ... x } ) ) ;
currentDev . value . rows = gen . map ( ( x : any ) => ( { ... x } ) ) ;
} finally {
analyzing . value = false ;
}
} ;
const addRow = ( ) => cur . value . rows . push ( { cause : "" , cons : "" , l : 2 , s : 3 , safe : "" , sug : "" } ) ;
const delRow = ( ri : number ) => cur . value . rows . splice ( ri , 1 ) ;
const exportAll = ( ) => {
emit ( "export" , nodes . value . flatMap ( ( n ) => n . devs . flatMap ( ( d ) => d . rows . map ( ( row ) => toRec ( n . obj , d . dev , row ) ) ) ) , "ai" ) ;
}
const save = ( ) => {
message . success ( "已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)" ) ;
} ;
const ANALYSIS _POOL = [
{ name : "张工艺" , org : "一车间" , post : "工艺工程师" } ,
{ name : "王仪表" , org : "技术部" , post : "仪表工程师" } ,
@@ -133,173 +188,588 @@ const ANALYSIS_POOL = [
{ name : "吴海涛" , org : "设备科" , post : "维修班长" } ,
{ name : "陈国栋" , org : "一车间" , post : "班长" } ,
] ;
const open = ref ( false ) ;
const q = ref ( "" ) ;
const sels = ref < string [ ] > ( [ "张工艺" , "王仪表" ] ) ;
const list = computed ( ( ) => ANALYSIS _POOL . filter ( ( x ) => ! q . value || x . name . includes ( q . value ) ) ) ;
const toggle = ( n : string ) => {
// 选择分析人员弹窗
const openSelectWorker = ref ( false ) ;
// 搜索分析人员
const searchWorker = ref ( "" ) ;
// 分析人员列表
const list = computed ( ( ) => ANALYSIS _POOL . filter ( ( x ) => ! searchWorker . value || x . name . includes ( searchWorker . value ) ) ) ;
// 选择分析人员
const selectWorker = ( n : string ) => {
sels . value = sels . value . includes ( n ) ? sels . value . filter ( ( x ) => x !== n ) : [ ... sels . value , n ] ;
} ;
const rrOf = ( l : number , s : number ) => {
const v = l * s ;
if ( v > = 15 ) return { t : ` 重大 ( ${ v } ) ` , c : "bg-red-500 text-white" } ;
if ( v > = 10 ) return { t : ` 较大 ( ${ v } ) ` , c : "bg-[#F5A623] text-white" } ;
if ( v > = 5 ) return { t : ` 一般 ( ${ v } ) ` , c : "bg-amber-300 text-amber-900" } ;
return { t : ` 低 ( ${ v } ) ` , c : "bg-emerald-500 text-white" } ;
// 根据l和s获取风险等级
const riskLevel = ( l : number , s : number ) => {
const num = l * s ;
for ( let i = 0 ; i < riskMatrixConfig . value ? . risk _grades . length ; i ++ ) {
const item = riskMatrixConfig . value ? . risk _grades [ i ] ;
if ( num >= item . min && num <= item . max ) {
return item ; // 返回整条数据
}
}
return null ;
}
// 新增行
const addRow = async ( ) => {
console . log ( l _options . value )
console . log ( s _options . value )
loading . value = true ;
const { data , error } = await hazopRowAddApi ( currentDev . value ? . id , {
cause : "" ,
consequence : "" ,
l _value : l _options . value [ 0 ] ? . key || 1 ,
s _value : s _options . value [ 0 ] ? . key || 1 ,
risk _value : riskLevel ( l _options . value [ 0 ] ? . key || 1 , s _options . value [ 0 ] ? . key || 1 ) ? . name || '' ,
safeguards : "" ,
suggestions : "" ,
} )
if ( ! error ) {
console . log ( data )
// getHazopNodes();
window . $message ? . success ( "新增行成功" )
}
loading . value = false ;
} ;
// 删除行
const delRow = ( index : number ) => currentDev . value . rows . splice ( index , 1 ) ;
// 展开收起
const leftToggle = ( id : string ) => {
collapsed . value = { ... collapsed . value , [ id ] : ! collapsed . value [ id ] }
}
// 保存
const save = ( ) => {
window . $message ? . success ( "已手动保存,分析记录数已同步至工具卡片(内容修改实时自动保存,退出不丢失)" ) ;
} ;
// AI整理如表
const exportAll = ( ) => {
emit ( "export" , nodes . value . flatMap ( ( n ) => n . devs . flatMap ( ( d ) => d . rows . map ( ( row ) => toRec ( n . obj , d . name , row ) ) ) ) , "ai" ) ;
}
// 导出 Word
const exportWord = ( ) => {
window . $message ? . success ( 'HAZOP 分析报告已导出(Word) ' )
}
// 打开弹窗
const openModal = ( type : string , obj : string , title : string , editName : string = '' ) => {
if ( type === 'add' ) {
name . value = '' ;
} else {
name . value = editName ;
}
currentModalType . value = type ;
currentModalObj . value = obj ;
currentModalTitle . value = title ;
editModal . value = true ;
}
// 新增/编辑保存
const modalSave = async ( ) => {
if ( name . value === '' ) {
return window . $message ? . warning ( "名称不能为空" )
}
modalSaving . value = true ;
// 新增节点
if ( currentModalType . value === 'add' && currentModalObj . value === 'node' ) {
const { error } = await hazopNodeAddApi ( props . currentId , { node _name : name . value } ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "新增节点成功" )
editModal . value = false ;
}
}
// 编辑节点
if ( currentModalType . value === 'edit' && currentModalObj . value === 'node' ) {
const { error } = await hazopNodeEditApi ( contextTarget . value ? . node ? . id , { node _name : name . value } ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "编辑节点成功" )
editModal . value = false ;
}
}
// 新增分析对象
if ( currentModalType . value === 'add' && currentModalObj . value === 'obj' ) {
const { error } = await hazopObjAddApi ( contextTarget . value ? . node ? . id , { obj _name : name . value } ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "新增分析对象成功" )
editModal . value = false ;
}
}
// 编辑分析对象
if ( currentModalType . value === 'edit' && currentModalObj . value === 'obj' ) {
const { error } = await hazopObjEditApi ( contextTarget . value ? . obj ? . id , { obj _name : name . value } ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "编辑分析对象成功" )
editModal . value = false ;
}
}
// 新增偏离
if ( currentModalType . value === 'add' && currentModalObj . value === 'dev' ) {
const { error } = await hazopDevAddApi ( contextTarget . value ? . obj ? . id , { deviation : name . value } ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "新增偏离成功" )
editModal . value = false ;
}
}
// 编辑偏离
if ( currentModalType . value === 'edit' && currentModalObj . value === 'dev' ) {
const { error } = await hazopDevEditApi ( contextTarget . value ? . dev ? . id , { deviation : name . value } ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "编辑偏离成功" )
editModal . value = false ;
}
}
modalSaving . value = false ;
}
// 打开删除弹框
const openDelModal = ( type : string ) => {
currentModalType . value = type ;
delModal . value = true ;
}
// 删除确认
const delConfirm = async ( ) => {
delLoading . value = true ;
// 删除节点
if ( currentModalType . value === 'node' ) {
const { error } = await hazopNodeDelApi ( contextTarget . value ? . node ? . id ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "删除节点成功" )
delModal . value = false ;
}
}
// 删除分析对象
if ( currentModalType . value === 'obj' ) {
const { error } = await hazopObjDelApi ( contextTarget . value ? . obj ? . id ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "删除分析对象成功" )
delModal . value = false ;
}
}
// 删除偏离
if ( currentModalType . value === 'dev' ) {
const { error } = await hazopDevDelApi ( contextTarget . value ? . dev ? . id ) ;
if ( ! error ) {
getHazopNodes ( ) ;
window . $message ? . success ( "删除偏离成功" )
delModal . value = false ;
}
}
delLoading . value = false ;
}
// 菜单选项配置(根据不同层级返回不同菜单)
const getMenuOptions = ( type : string ) => {
const nodeOptions = [
{ label : '新增分析对象' , key : 'addObj' } ,
{ label : '编辑节点' , key : 'editNode' } ,
{ label : '删除节点' , key : 'delNode' } ,
]
const objOptions = [
{ label : '新增偏离' , key : 'addDev' } ,
{ label : '编辑分析对象' , key : 'editObj' } ,
{ label : '删除分析对象' , key : 'delObj' } ,
]
const devOptions = [
{ label : '编辑分离' , key : 'editDev' } ,
{ label : '删除分离' , key : 'delDev' } ,
]
const map : any = {
node : nodeOptions ,
obj : objOptions ,
dev : devOptions ,
}
return map [ type ] || [ ]
}
// 计算当前菜单选项
const contextMenuOptions = computed ( ( ) => {
return getMenuOptions ( contextTarget . value . type )
} )
// 点击页面其他区域关闭菜单(可选)
const closeMenu = ( ) => {
contextMenuShow . value = false
}
// 右键菜单事件处理
const handleContextMenu = ( event : MouseEvent , type : string , node : any , obj = null , dev = null ) => {
// 阻止浏览器默认右键菜单
event . preventDefault ( )
// 记录目标数据
contextTarget . value = { type , node , obj , dev }
// 计算菜单位置(防止超出视口)
const menuWidth = 200 // 预估菜单宽度
const menuHeight = 300 // 预估菜单高度
let x = event . clientX
let y = event . clientY
// 如果菜单会超出右边界,向左偏移
if ( x + menuWidth > window . innerWidth ) {
x = window . innerWidth - menuWidth - 10
}
// 如果菜单会超出下边界,向上偏移
if ( y + menuHeight > window . innerHeight ) {
y = window . innerHeight - menuHeight - 10
}
contextMenuX . value = x
contextMenuY . value = y
contextMenuShow . value = true
}
// 菜单选中回调
// ============================================================
const handleMenuSelect = ( key : string ) => {
const { type , node , obj , dev } = contextTarget . value
switch ( key ) {
// ---------- 节点操作 ----------
case 'addObj' :
// 新增分析对象
openModal ( 'add' , 'obj' , '新增分析对象' ) ;
break
case 'editNode' :
// 编辑节点
openModal ( 'edit' , 'node' , '编辑节点' , node ? . node _name ) ;
break
case 'delNode' :
console . log ( '[删除节点] 节点:' , node ? . node _name )
// 删除
openDelModal ( 'node' ) ;
break
// ---------- 对象操作 ----------
case 'addDev' :
// 新增偏离
openModal ( 'add' , 'dev' , '新增偏离' ) ;
break
case 'editObj' :
// 编辑分析对象
openModal ( 'edit' , 'obj' , '编辑分析对象' , obj ? . obj _name ) ;
break
case 'delObj' :
// 删除
openDelModal ( 'obj' ) ;
break
// ---------- 偏离操作 ----------
case 'editDev' :
// 编辑偏离
openModal ( 'edit' , 'dev' , '编辑偏离' , dev ? . deviation ) ;
break
case 'delDev' :
// 删除
openDelModal ( 'dev' ) ;
break
default :
console . warn ( '未知菜单项:' , key )
}
// 关闭菜单
closeMenu ( )
}
// 监听全局点击关闭菜单
// 注意:这里需要排除菜单自身,使用 Naive UI 的 mask 或手动处理
// 简单起见,我们利用 n-dropdown 的 mask: false,并在外部点击时关闭
// 但更好的方式是监听 document 的 click 事件
if ( typeof window !== 'undefined' ) {
document . addEventListener ( 'click' , ( e ) => {
// 如果点击的不是菜单内部,关闭菜单
const menuEl = document . querySelector ( '.n-dropdown' )
if ( menuEl && ! menuEl . contains ( e . target as Node ) ) {
closeMenu ( )
}
} )
}
// 获取风险矩阵配置
const getRiskMatrixConfig = async ( ) => {
treeLoading . value = true ;
const { data , error } = await matrixConfigApi ( "HAZOP" ) ;
if ( ! error ) {
console . log ( data )
riskMatrixConfig . value = data ;
getHazopNodes ( ) ;
} else {
treeLoading . value = false ;
}
}
// 获取HAZOP节点列表
const getHazopNodes = async ( ) => {
treeLoading . value = true ;
const { data , error } = await hazopNodesApi ( props . currentId ) ;
if ( ! error ) {
nodes . value = data ;
}
treeLoading . value = false ;
}
onMounted ( ( ) => {
getRiskMatrixConfig ( ) ;
} )
< / script >
< template >
< div class = "space-y-3 ">
< div
class = "flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
: style = "{
'--theme-color': themeStore.themeColor
}"
>
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="emit('back')" >
< Icon icon = "basil:arrow-left-outline" class = "size-18px" / >
返回工具列表
< / NButton >
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="save" > 保存 < / NButton >
< span class = "text-sm font-bold text-[var(--theme-color)] "> HAZOP 分析插件 < / span >
< NTag size = "small" type = "info "> 插件 < / NTag >
< NTag size = "small" > MOC 变更 : 缩合反应温度及时间优化 ( 演示 ) < / NTag >
< span class = "text-[11px] text-slate-500" > { { ` 分析进度 ${ devDone } / ${ devTotal } 个偏离 · ${ recTotal } 条记录 ` } } < / span >
< div class = "relative" >
< NButton size = "small" ghost type = "primary" class = "text-[11px]" @click ="open = !open ">
< Icon icon = "lucide:users " class = "size-12 px" / > 分析组成员 · 已选 { { sels . length } } 人
< / NButton >
< template v-if = "open" >
< div class = "fixed inset-0 z-10" @click ="open = false" > < / div >
< div class = "absolute right-0 top-8 z-20 w-72 rounded-lg b ord er bg-white p-2 shadow-xl" >
< div class = "mb-1.5 flex items-center justify-between " >
< span class = "text-xs font-semibold text-slate-700" > 选择分析组成员 ( 岗位人员库 ) < / span >
< span class = "text-[11px] text-slate-4 00" > 已选 { { sels . length } } 人 < / span >
< / div >
< NInput v -model :value = "q" size = "small" placeholder = "搜索人名…" class = "mb-1.5 text-xs" / >
< div class = "max-h-48 space-y-0.5 overflow-y-auto" >
< div v-for = "x in list" :key="x.name" @click="toggle(x.name)"
class = "cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
: class = "sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'" >
< span class = "flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border text-[10px]"
: class = "sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--th eme -color)] text-white' : 'border-slate-300'" > { { sels . includes ( x . name ) ? "✓" : "" } } < / span >
< span class = "font-medium text -slate-8 00" > { { x . name } } < / span >
< span class = "text-[11px] text-slate-4 00" > { { x . org } } · { { x . post } } < / span >
< NTag v-if = "sels[0] === x.name" size="small" type="primary" class="ml-auto " > 组长 < / NTag >
< / div >
< / div >
< div class = "mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400" >
首位选择者默认为组长 ; 成员为选择式 ( 非手写 ) , 参与记录自动归属到人 , 用于 「 参与风险分析次数 」 统计采集 。
< / div >
< / div >
< / template >
< / div >
< div class = "ml-auto flex items-center gap-1.5" >
< NButton size = "small" ghost type = "primary" class = "text-xs" : disabled = "recTotal === 0" @click ="exportAll ">
< Icon icon = "lucide:sparkles" class = "size-12px mr-1" / > AI 整理入表
< / NButton >
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="message.success('HAZOP 分析报告已导出(Word) ')" >
< Icon icon = "material-symbols:download" class = "size-14px" / > 导出 Word
< / NButton >
< NButton size = "small" type = "primary" class = "text-xs" :disabled = "analyzing" @click ="runAi" >
< NSpin :show = "loading" size = "small ">
< div class = "space-y-3" >
< div
class = "flex flex-wrap items-center gap-2 rounded-lg border border-[#ACC6E5] bg-[#EAF0F9] px-3 py-2"
: style = "{
'--theme-color': themeStore.themeColor
}"
>
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="emit('close')" >
< Icon icon = "basil:arrow-left-outline" class = "size-18px" / >
返回工具列表
< / NButton >
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="save "> 保存 < / NButton >
< span class = "text-sm font-bold text-[var(--theme-color)] " > HAZOP 分析 插件< / span >
< NTag size = "small" type = "info" > 插件 < / NTag >
< NTag size = "small" > MOC 变更 : 缩合反应温度及时间优化 ( 演示 ) < / NTag >
< span class = "text-[11px] text-slate-500" > { { ` 分析进度 ${ devDone } / ${ devTotal } 个偏离 · ${ recTotal } 条记录 ` } } < / span >
< div class = "relative ">
< NButton size = "small" ghost type = "primary " class = "text-[11 px] " @click ="openSelectWorker = !openSelectWorker" >
< Icon icon = "lucide:users" class = "size-12px" / > 分析组成员 · 已选 { { sels . length } } 人
< / NButton >
< template v-if = "openSelectWorker" >
< div class = "fixed inset-0 z-10" @click ="openSelectW ork er = false" > < / div >
< div class = "absolute right-0 top-8 z-20 w-72 rounded-lg border bg-white p-2 shadow-xl " >
< div class = "mb-1.5 flex items-center justify-between" >
< span class = "text-xs font-semibold text-slate-7 00" > 选择分析组成员 ( 岗位人员库 ) < / span >
< span class = "text-[11px] text-slate-400" > 已选 { { sels . length } } 人 < / span >
< / div >
< NInput v -model :value = "searchWorker" size = "small" placeholder = "搜索人名…" class = "mb-1.5 text-xs" / >
< div class = "max-h-48 space-y-0.5 overflow-y-auto" >
< div v-for = "x in list" :key="x.name" @click="selectWorker(x.name)"
class = "cursor-pointer flex w-full items-center min-h-34px gap-2 rounded-md px-2 py-1.5 text-left text-xs"
: class = "sels.includes(x.name) ? 'bg-[#DFE9F6]' : 'hover:bg-slate-50'" >
< span class = "flex h-3.5 w-3.5 shrink-0 it ems -center justify-center rounded border text-[10px]"
: class = "sels.includes(x.name) ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'border -slate-3 00' " > { { sels . includes ( x . name ) ? "✓" : "" } } < / span >
< span class = "font-medium text-slate-8 00" > { { x . name } } < / span >
< span class = "text-[11px] text-slate-400 " > { { x . org } } · { { x . post } } < / span >
< NTag v-if = "sels[0] === x.name" size="small" type="primary" class="ml-auto" > 组长 < / NTag >
< / div >
< / div >
< div class = "mt-1.5 border-t pt-1.5 text-[11px] leading-4 text-slate-400" >
首位选择者默认为组长 ; 成员为选择式 ( 非手写 ) , 参与记录自动归属到人 , 用于 「 参与风险分析次数 」 统计采集 。
< / div >
< / div >
< / template >
< / div >
< div class = "ml-auto flex items-center gap-1.5 " >
< NButton v-if = "isAiOpen" size="small" ghost type="primary" class="text-xs" :disabled="recTotal === 0" @click="exportAll" >
< Icon icon = "lucide:sparkles" class = "size-12px mr-1" / > AI 整理入表
< / NButton >
< NButton size = "s mall" ghost type = "primary" class = "text-xs" @click ="exportWord" >
< Icon icon = "material-symbols:download" class = "size-14px" / > 导出 Word
< / NButton >
< NButton v-if = "isAiOpen" size="small" type="primary" class="text-xs" :disabled="analyzing" @click="runAi" >
< Icon v-if = "analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin" / >
< Icon v-else icon = "lucide:sparkles" class = "size-12px" / >
AI 分析当前偏离
< / NButton >
< / div >
< / div >
< div class = "flex items-center gap-2 text-xs text-slate-500" >
< span > 当前位置 : < / span >
< NTag size = "small" > { { nodes [ sel . n ] . name } } < / NTag >
< span > › < / span >
< NTag size = "small" type = "success" > 分析对象 : { { nodes [ sel . n ] . obj } } < / NTag >
< span > › < / span >
< NTag size = "small" type = "warning" > 偏离 : { { cur . dev } } < / NTag >
< / div >
< div
class = "flex gap-3"
: style = "{
'--theme-color': themeStore.themeColor,
'--success-color': themeStore.otherColor.success
}"
>
<!-- 左 : 节点 / 偏离树 -- >
< div class = "shrink-0 rounded-lg border bg-white p-2" >
< div v-for = "(n, i) in nodes" :key="n.name" class="mb-1" >
< div class = "px-2 py-1.5 text-xs font-semibold text-slate-700" > { { n . name } } < / div >
< div v-for = "(d, j) in n.devs" :key="d.dev" @click="sel = { n: i, d: j }"
class = "cursor-pointer flex w-full items-center gap-1.5 rounded-md px-3 py-1.5 text-left text-xs transition"
: class = "sel.n === i && sel.d === j ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'" >
< span class = "flex-1" > { { d . dev } } < / span >
< span class = "rounded-full px-1.5 text-[10px]"
: class = "d.rows.length
? (sel.n === i && sel.d === j ? 'bg-white/25 text-white' : 'bg-emerald-100 text-[var(--success-color)]')
: (sel.n === i && sel.d === j ? 'bg-white/30 text-white' : 'bg-slate-100 text-slate-400')" >
{ { d . rows . length ? ` ${ d . rows . length } 条 ` : "待分析" } }
< / span >
< / NButton >
< / div >
< / div >
< / div >
<!-- 右 : HAZOP 记录表 -- >
< div class = "min-w-0 flex-1 bg-white " >
< div class = "flex items-center gap-2 pb-2" >
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="addRow" >
< Icon icon = "ic:round-plus" class = "size-14px" / > 新增行
< / NButton >
< span class = "text-[11px] text-slate-400" > 单元格点击即可修改 ; L / S 取值 1 - 5 , 风险等级按风险矩阵实时重算 ; 「 入表 」 将本条选入风险分析记录表 < / span >
< / div >
< table class = "w-full min-w-[860px] border-collapse text-xs" >
< thead >
< tr >
< th :class = "TH" class = "w-16" > 操作 < / th >
< th :class = "TH" > 原因 < / th >
< th :class = "TH" > 后果 < / th >
< th :class = "TH" class = "w-8" > L < / th >
< th :class = "TH" class = "w-8" > S < / th >
< th :class = "TH" class = "w-20" > RR < / th >
< th :class = "TH" > 安全措施 < / th >
< th :class = "TH" > 建议措施 < / th >
< / tr >
< / thead >
< tbody >
< tr v-if = "cur.rows.length === 0" >
< td colspan = "8" class = "px-3 py-8 text-center text-xs text-slate-400 ">
当前偏离暂无分析记录 , 点击右上角 「 AI 分析当前偏离 」 自动生成 , 或 「 新增行 」 手动填写
< / td >
< / tr >
< tr v-for = "(row, ri) in cur.rows" :key="ri" >
< td :class = "TD" >
< div class = "flex items-center justify-between px-1 mt-1" >
< NButton text type = "primary" class = "mt-1px text-[11px] hover:underline" title = "选入风险分析记录表"
@click ="message.success('已选入 1 条风险记录,可继续挑选或返回查看记录表')" > 入表
< / NButton >
< NButton text type = "error" title = "删除行" @click ="delRow(ri)" >
< Icon icon = "ep:delete" class = "size-14px" / >
< / NButton >
< div class = "flex items-center gap-2 text-xs text-slate-500 " >
< span > 当前位置 : < / span >
< NTag size = "small" > 节点 : { { currentNode ? . node _name ? ? '未选择' } } < / NTag >
< span > › < / span >
< NTag size = "small" type = "success" > 分析对象 : { { currentObj ? . obj _name ? ? '未选择' } } < / NTag >
< span > › < / span >
< NTag size = "small" type = "warning" > 偏离 : { { currentDev ? . deviation ? ? '未选择' } } < / NTag >
< / div >
< div
class = "flex gap-3"
: style = "{
'--theme-color': themeStore.themeColor,
'--success-color': themeStore.otherColor.success
}"
>
<!-- 左 : 节点 / 偏离树 -- >
< div class = "w-240px" >
< NSpin :show = "treeLoading" size = "small" >
< NButton size = "small" type = "primary" ghost block class = "mb-2 text-xs" @click ="openModal('add','node','新增节点')" >
< Icon icon = "ic:round-plus" class = "size-14px" / > 新增节点
< / NButton >
< div class = "shrink-0 rounded-md border p-2 scroll ">
< div v-for = "(node, nodeIndex) in nodes" :key="nodeIndex" >
< div
class = "cursor-pointer flex items-center select-none"
@click ="leftToggle(node.id)"
@contextmenu.prevent ="handleContextMenu($event, 'node', node)"
>
< Icon v-if = "collapsed[node.id]" icon="akar-icons:chevron-right" class="size-12px" / >
< Icon v-else icon = "akar-icons:chevron-down" class = "size-12px" / >
< p class = "flex-1 px-2 py-1.5 text-xs font-semibold text-slate-700" > 节点 { { Number ( nodeIndex ) + 1 } } : { { node . node _name } } < / p >
< / div >
< / td >
< td :class = "TD" > < Ed :v = "row.cause" @update ="(x) => updRow(ri, { cause: x })" / > < / td >
< td :class = "TD" > < Ed :v = "row.cons" @update ="(x) => updRow(ri, { cons: x })" / > < / td >
< td :class = "TD" class = "text-center" >
< input :value = "row.l" @change ="(e) => updRow(ri, { l: clampLS((e.target as HTMLInputElement).value) } )"
class = "w-7 rounded border border-transparent text-center hover:border-slate-200" / >
< / td >
< td :class = "TD" class = "text-center" >
< input :value = "row.s" @change ="(e) => updRow(ri, { s: clampLS((e.target as HTMLInputElement).value) })"
class = "w-7 rounded border border-transparent text-center hover:border -slate-2 00" / >
< / td >
< td :class = "TD" class = "text-center ">
< span class = "inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" : class = "rrOf(row.l, row.s).c" > { { rrOf ( row . l , row . s ) . t } } < / span >
< / td >
< td :class = "TD" > < Ed :v = "row.safe" @update ="(x) => updRow(ri, { safe: x })" / > < / td >
< td :class = "TD" > < Ed :v = "row.sug" @update ="(x) => updRow(ri, { sug: x })" / > < / td >
< / tr >
< / tbody >
< / table >
< template v-if = "!collapsed[node.id]" >
< div v-for = "(obj, objIndex) in node.objects" :key="objIndex" class="mb-1 ml-3" >
< div
class = "cursor-pointer flex items-center select-none"
@click ="leftToggle(node.id+'_'+obj.id )"
@contextmenu.prevent ="handleContextMenu($event, 'obj', node, obj)"
>
< Icon v-if = "collapsed[node.id+'_'+obj.id]" icon="akar-icons:chevron-right" class="size-12px" / >
< Icon v-else icon = "akar-icons:chevron-down" class = "size-12px" / >
< p class = "flex-1 px-2 py-1.5 text-xs font-semibold text -slate-7 00"> { { obj . obj _name } } < / p >
< / div >
< template v-if = "!collapsed[node.id+'_'+obj.id] ">
< div v-for = "(dev, devIndex) in obj.deviations" :key="devIndex"
@click ="currentDev = dev"
@contextmenu.prevent ="handleContextMenu($event, 'dev', node, obj, dev)"
class = "cursor-pointer flex w-full items-center ml-2 gap-1.5 rounded-md px-3 py-1.5 text-left text-xs transition"
: class = "currentDev?.id === dev.id ? 'bg-[var(--theme-color)] text-white' : 'text-slate-600 hover:bg-slate-100'"
>
< span class = "flex-1" > { { dev . deviation } } < / span >
< Icon v-if = "dev.analyzing" icon="ri:loader-4-fill" class="size-14px animate-spin text-blue-500" / >
< span v-else-if = "dev.rows.length" class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600" >
{{ dev.rows.length }} 条
< / span >
< span v-else class = "rounded-full bg-slate-100 px-1.5 text-[10px] text-slate-400" > 待分析 < / span >
< / div >
< / template >
< / div >
< / template >
< / div >
< / div >
< / NSpin >
< / div >
<!-- 右 : HAZOP 记录表 -- >
< div class = "min-w-0 flex-1 bg-white" >
< div class = "flex items-center gap-2 pb-2" >
< NButton size = "small" ghost type = "primary" class = "text-xs" @click ="addRow" >
< Icon icon = "ic:round-plus" class = "size-14px" / > 新增行
< / NButton >
< span class = "text-[11px] text-slate-400" > 单元格点击即可修改 ; 风险等级按风险矩阵L / S 取值实时重算 ; 「 入表 」 将本条选入风险分析记录表 < / span >
< / div >
< table class = "w-full min-w-[860px] border-collapse text-xs" >
< thead >
< tr >
< th :class = "TH" class = "w-16" > 操作 < / th >
< th :class = "TH" > 原因 < / th >
< th :class = "TH" > 后果 < / th >
< th :class = "TH" class = "w-8" > L < / th >
< th :class = "TH" class = "w-8" > S < / th >
< th :class = "TH" class = "w-20" > RR < / th >
< th :class = "TH" > 安全措施 < / th >
< th :class = "TH" > 建议措施 < / th >
< / tr >
< / thead >
< tbody >
< tr v-if = "currentDev?.rows?.length === 0" >
< td colspan = "8" class = "px-3 py-8 text-center text-xs text-slate-400" >
当前偏离暂无分析记录 , 点击右上角 「 AI 分析当前偏离 」 自动生成 , 或 「 新增行 」 手动填写
< / td >
< / tr >
< tr v-for = "(row, ri) in currentDev?.rows" :key="ri" >
< td :class = "TD" >
< div class = "flex items-center justify-between px-1 mt-1" >
< NButton text type = "primary" class = "mt-1px text-[11px] hover:underline" title = "选入风险分析记录表"
@click ="message.success('已选入 1 条风险记录,可继续挑选或返回查看记录表')" > 入表
< / NButton >
< NButton text type = "error" title = "删除行" @click ="delRow(Number(ri))" >
< Icon icon = "ep:delete" class = "size-14px" / >
< / NButton >
< / div >
< / td >
< td :class = "TD" >
< Ed :v = "row.cause" @update ="(x) => updRow(Number(ri), { cause: x })" / >
< / td >
< td :class = "TD" >
< Ed :v = "row.consequence" @update ="(x) => updRow(Number(ri), { consequence: x })" / >
< / td >
< td :class = "TD" class = "text-center" >
< NInputNumber v -model :value = "row.lvalue" size = "tiny" :min = "l_options[0].key" :max = "l_options[l_options.length-1].key" :show-button = "false" class = "text-center w-10" / >
< / td >
< td :class = "TD" class = "text-center" >
< NInputNumber v -model :value = "row.svalue" size = "tiny" :min = "s_options[0].key" :max = "l_options[s_options.length-1].key" :show-button = "false" class = "text-center w-10" / >
< / td >
< td :class = "TD" class = "text-center" >
< span class = "inline-block rounded px-1.5 py-0.5 text-[11px] font-medium" >
{ { row . risk _value } }
< / span >
< / td >
< td :class = "TD" >
< Ed :v = "row.safeguards" @update ="(x) => updRow(Number(ri), { safeguards: x })" / >
< / td >
< td :class = "TD" >
< Ed :v = "row.suggestions" @update ="(x) => updRow(Number(ri), { suggestions: x })" / >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / div >
< / div >
< / div >
< / NSpin >
<!-- === === === = 右键菜单 === === === = -- >
< NDropdown
v -model :show = "contextMenuShow"
:x = "contextMenuX"
:y = "contextMenuY"
:options = "contextMenuOptions"
@select ="handleMenuSelect"
placement = "bottom-start"
trigger = "manual"
:mask = "false"
/ >
<!-- 新增 / 编辑弹窗 -- >
< NModal
v -model :show = "editModal"
preset = "card"
:title = "currentModalTitle"
:auto-focus = "false"
: style = "{ width: '450px', height: 'auto' }"
: segmented = "{ content: true, footer: true }"
>
< div >
< NInput v -model :value = "name" placeholder = "请输入名称" / >
< / div >
< template # footer >
< div class = "flex justify-end gap-[10px]" >
< NButton size = "small" @click ="editModal = false" > 取消 < / NButton >
< NButton size = "small" type = "primary" :loading = "modalSaving" @click ="modalSave" > 确定 < / NButton >
< / div >
< / template >
< / NModal >
< ! - - 删除弹窗 - - >
< NModal
v -model :show = "delModal"
preset = "card"
title = "删除"
:auto-focus = "false"
: style = "{ width: '450px', height: 'auto' }"
: segmented = "{ content: true, footer: true }"
>
< p v-if = "currentModalType === 'node'" > 节点 : {{ contextTarget ? .node ? .node_name }} , 确定删除吗 ? < / p >
< p v-else-if = "currentModalType === 'obj'" > 分析对象 : {{ contextTarget ? .obj ? .obj_name }} , 确定删除吗 ? < / p >
< p v-else-if = "currentModalType === 'dev'" > 偏离 : {{ contextTarget ? .dev ? .deviation }} , 确定删除吗 ? < / p >
< template # footer >
< div class = "flex justify-end gap-[10px]" >
< NButton size = "small" @click ="delModal = false" > 取消 < / NButton >
< NButton size = "small" type = "primary" :loading = "delLoading" @click ="delConfirm" > 确定 < / NButton >
< / div >
< / template >
< / NModal >
< / template >
< style scoped lang = "scss" >
. scroll {
height : calc ( 100 vh - 200 px ) ;
overflow - y : auto ;
}
< / style >