337 lines
14 KiB
Vue
337 lines
14 KiB
Vue
<!-- 通用底部操作 -->
|
||
<script setup lang="ts">
|
||
import { computed, inject, ref, watch } from 'vue';
|
||
import { Icon } from '@iconify/vue'
|
||
import { useAiAnalysisApi } from '@/service/api/change';
|
||
|
||
const props = defineProps([
|
||
'type',
|
||
'currentId',
|
||
'title',
|
||
'isAiOpen',
|
||
'currentForm',
|
||
'confirmed',
|
||
'members',
|
||
'steps'
|
||
])
|
||
const emit = defineEmits(['save','submit','confirmTab','regenerate','update:steps']);
|
||
|
||
watch(() => props.confirmed, (newVal:boolean) => {
|
||
confirmed.value = newVal;
|
||
})
|
||
|
||
const generalLoading = inject('generalLoading', {saveLoading: ref<boolean>(false),btnDisabled: ref<boolean>(false)})
|
||
const {saveLoading,btnDisabled} = generalLoading
|
||
|
||
const approvalDrawerShow = ref<boolean>(false);
|
||
const downloadLoading = ref<boolean>(false);
|
||
const regenerateLoading = ref<boolean>(false);
|
||
const submitLoading = ref<boolean>(false);
|
||
const confirmed = ref<boolean>(false);
|
||
|
||
// 专业
|
||
const SPECIALTIES = [
|
||
{ label: '生产', value: '生产' },
|
||
{ label: '技术', value: '技术' },
|
||
{ label: '仪表', value: '仪表' },
|
||
{ label: '电气', value: '电气' },
|
||
{ label: '设备', value: '设备' },
|
||
{ label: '安全', value: '安全' },
|
||
{ label: '质量', value: '质量' }
|
||
];
|
||
// 是否重新生成
|
||
const regenerateTypes = ref<Record<string, boolean>>({
|
||
'form':false,
|
||
'risk':false,
|
||
'train':false,
|
||
'pssr':false,
|
||
'evaluation':false,
|
||
'close':false,
|
||
});
|
||
|
||
const flowList = ref<any[]>([]);
|
||
const currentSteps = ref<any>(null);
|
||
// 监听步骤变化,更新本地步骤
|
||
watch(() => props.steps, (v:any) => {
|
||
currentSteps.value = v;
|
||
},{ immediate: true })
|
||
|
||
// 审批链
|
||
const approvalFlowText = computed(() => {
|
||
if (!currentSteps.value || currentSteps.value.length === 0) return '暂无步骤'
|
||
// 按 step_order 排序
|
||
const sorted = [...currentSteps.value].sort((a, b) => a.step_order - b.step_order)
|
||
return sorted.map(item => item.name).join(' → ')
|
||
})
|
||
|
||
// 打开审批流程抽屉
|
||
const openApprovalDrawer = () => {
|
||
if(props.type==='form'){
|
||
if(!props.currentForm?.change_type || !props.currentForm?.change_level || !props.currentForm?.duration_type){
|
||
return window.$message?.warning('请先选择变更类型、变更等级、变更时限');
|
||
}
|
||
}
|
||
approvalDrawerShow.value = true;
|
||
}
|
||
|
||
// 数据转换
|
||
const transformFlowInfo = (steps: any[]) => {
|
||
const sortedSteps = [...steps || []].sort((a, b) => a.step_order - b.step_order)
|
||
const result = sortedSteps.map(step => {
|
||
return {
|
||
stepId: step.id,
|
||
name: step.name,
|
||
countersign: step.countersign,
|
||
selectedMember: step.members,
|
||
};
|
||
});
|
||
return result;
|
||
}
|
||
|
||
// 审批链
|
||
const approvalChain = computed(() => {
|
||
let template = props.steps;
|
||
if (!template || template.length === 0) return '暂无审批步骤'
|
||
flowList.value = transformFlowInfo(template);
|
||
const parts = flowList.value.map((step: any) => {
|
||
// 根据 countersign 决定显示名称
|
||
const displayName = step.name
|
||
const members = step.selectedMember
|
||
let memberText;
|
||
if (members && members.length > 0) {
|
||
memberText = members.map((m: any) => `${m.user_name}(${m.org_name})`).join('、');
|
||
} else {
|
||
memberText = '未指定';
|
||
}
|
||
return `${displayName}:${memberText}`;
|
||
})
|
||
return parts.join(' → ')
|
||
})
|
||
|
||
// 下载
|
||
const download = async () => {
|
||
window.$message?.info('差接口');
|
||
}
|
||
// 重新生成:AI 调用逻辑由各标签页组件实现(按 type 分发),此处仅派发事件
|
||
const regenerate = async () => {
|
||
regenerateTypes.value[props.type] = true;
|
||
emit('regenerate', props.type);
|
||
}
|
||
// 供父组件控制「重新生成」按钮 loading
|
||
const setRegenerateLoading = (v: boolean) => {
|
||
regenerateLoading.value = v;
|
||
}
|
||
|
||
// 确认标签内容
|
||
const confirmTab = async () => {
|
||
let stepId = 0;
|
||
if(props.type==='form'){
|
||
stepId = 1;
|
||
}
|
||
if(props.type==='risk'){
|
||
stepId = 2;
|
||
}
|
||
if(props.type==='train'){
|
||
stepId = 3;
|
||
}
|
||
if(props.type==='pssr'){
|
||
stepId = 4;
|
||
}
|
||
if(props.type==='evaluation'){
|
||
stepId = 5;
|
||
}
|
||
const {error} = await useAiAnalysisApi(props.currentId, stepId, {action: !confirmed.value ? 'CONFIRM' : 'CANCEL'});
|
||
if(!error){
|
||
confirmed.value = !confirmed.value;
|
||
emit('confirmTab', props.type, confirmed.value);
|
||
}
|
||
}
|
||
// 保存
|
||
const saveDraft = async () => {
|
||
emit('save');
|
||
}
|
||
// 提交
|
||
const submitApply = () => {
|
||
emit('submit');
|
||
}
|
||
|
||
// 完成设置
|
||
const finishApprover = async () => {
|
||
emit('update:steps', currentSteps.value);
|
||
window.$message?.success("审批流程设置完成");
|
||
approvalDrawerShow.value = false;
|
||
}
|
||
|
||
/**
|
||
* 在组织树中根据 user_id 查找用户信息
|
||
* @param {Array} tree - 组织树数据
|
||
* @param {string|number} targetUserId - 要查找的 user_id
|
||
* @returns {Object|null} - 返回 { userLabel, parentLabel, parentValue } 或 null
|
||
*/
|
||
function findUserInTree(tree: any[], targetUserId: string | number) {
|
||
for (const org of tree) {
|
||
const children = org.children || [];
|
||
for (const user of children) {
|
||
if (String(user.user_id) === String(targetUserId)) {
|
||
return {
|
||
user_name: user.label,
|
||
org_name: org.label,
|
||
org_id: org.value
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return null; // 未找到
|
||
}
|
||
// 设置专业会签步骤
|
||
const setStepCountersign = (target: any, v: boolean) => {
|
||
currentSteps.value.forEach((s:any) => {
|
||
s.countersign = s === target ? v : false;
|
||
if (!s.countersign) s.members.forEach((m:any) => { m.specialty = ""; m.is_sign = 0; });
|
||
});
|
||
}
|
||
// 选择专业
|
||
const selectSpecialty = (idx: number, mIdx: number, v: string | null) => {
|
||
currentSteps.value[idx].members[mIdx].specialty = v ?? null;
|
||
}
|
||
// 选择是否必须参与会签
|
||
const selectSign = (idx: number, mIdx: number, v: number) => {
|
||
currentSteps.value[idx].members[mIdx].is_sign = v;
|
||
}
|
||
// 当前打开人员选择器的步骤(步骤行「+」按钮切换)
|
||
const pickerStepId = ref<number | null>(null);
|
||
const pick = ref<string | null>(null);
|
||
const togglePicker = (s: any) => {
|
||
pickerStepId.value = pickerStepId.value === s.unique_id ? null : s.unique_id;
|
||
pick.value = null;
|
||
}
|
||
// 添加审批步骤人员
|
||
const addMember = (s: any, v: string | null) => {
|
||
if (v && !s.members.some((m:any) => m.user_name === v)){
|
||
let info = findUserInTree(props.members, v);
|
||
s.members.push({ user_id: Number(v), user_name: info?.user_name || '', org_id: info?.org_id || '', org_name: info?.org_name || '', specialty: "", is_sign: 0 });
|
||
pick.value = null;
|
||
pickerStepId.value = null;
|
||
}
|
||
}
|
||
// 删除审批步骤人员
|
||
const delMember = (s: any, p: number) => {
|
||
s.members = s.members.filter((m:any) => m.user_id !== p);
|
||
}
|
||
|
||
defineExpose({setRegenerateLoading})
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<div class="flex flex-wrap items-center justify-between gap-2 border-t py-3 px-4">
|
||
<div class="flex flex-wrap gap-2">
|
||
<NButton v-if="isAiOpen && type!=='risk'" size="small" ghost type="warning" :disabled="btnDisabled || regenerateLoading" @click="regenerate">
|
||
<Icon icon="tdesign:refresh" class="size-14px mr-1" :class="regenerateLoading?'animate-spin':''" />
|
||
<template v-if="regenerateLoading">
|
||
<span>生成中...</span>
|
||
</template>
|
||
<template v-else>
|
||
<span v-if="type==='form'">{{regenerateTypes[type]?'重新生成变更申请表':'生成变更申请表'}}</span>
|
||
<span v-if="type==='train'">{{regenerateTypes[type]?'重新生成变更培训内容':'生成变更培训内容'}}</span>
|
||
<span v-if="type==='pssr'">{{regenerateTypes[type]?'重新生成PSSR检查内容':'生成PSSR检查内容'}}</span>
|
||
<span v-if="type==='evaluation'">{{regenerateTypes[type]?'重新生成验收评价':'生成验收评价'}}</span>
|
||
<span v-if="type==='close'">{{regenerateTypes[type]?'重新生成变更关闭确认表':'生成变更关闭确认表'}}</span>
|
||
</template>
|
||
</NButton>
|
||
<NButton size="small" ghost type="success" :disabled="btnDisabled || downloadLoading" @click="download">
|
||
<Icon v-if="downloadLoading" icon="tdesign:refresh" class="size-14px mr-1 animate-spin" />
|
||
<Icon v-else icon="material-symbols:download" class="size-16px" />
|
||
<span v-if="type==='form'">下载变更申请表</span>
|
||
<span v-if="type==='risk'">下载风险分析记录表</span>
|
||
<span v-if="type==='train'">下载变更培训内容</span>
|
||
<span v-if="type==='pssr'">下载PSSR检查内容</span>
|
||
<span v-if="type==='evaluation'">下载验收评价</span>
|
||
<span v-if="type==='close'">下载变更关闭确认表</span>
|
||
</NButton>
|
||
<NButton v-if="isAiOpen" size="small" ghost :type="confirmed ? 'success' : 'primary'" :disabled="btnDisabled" @click="confirmTab">
|
||
<Icon icon="ix:success" class="size-16px mr-1" /> {{ confirmed ? '已确认完毕' : '本标签内容确认完毕' }}
|
||
</NButton>
|
||
</div>
|
||
</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)]">
|
||
<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">
|
||
审核:{{ approvalChain }}
|
||
</span>
|
||
<NButton v-if="currentSteps && currentSteps.length" ghost size="small" type="primary" :disabled="btnDisabled" @click="openApprovalDrawer">
|
||
<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" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
|
||
<NButton size="small" type="primary" @click="submitApply" :disabled="btnDisabled" :loading="submitLoading">
|
||
提交 <Icon icon="formkit:right" class="size-14px" />
|
||
</NButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 审批流程设置 -->
|
||
<NDrawer v-model:show="approvalDrawerShow" placement="right" width="37rem">
|
||
<NDrawerContent>
|
||
<template #header>
|
||
<div class="text-base font-semibold text-slate-800">审批流程设置</div>
|
||
</template>
|
||
<div class="h-full space-y-4 overflow-y-auto">
|
||
<p class="text-xs leading-relaxed text-slate-400">审批链:{{ approvalFlowText }}。专业会签人由申请表「涉及专业」勾选自动增减,可在此调整人选;重要变更自动追加安全审查与分管领导终批。</p>
|
||
<div v-for="(s, idx) in currentSteps" :key="s.id" class="rounded-lg border border-slate-100 bg-slate-50/40 p-3">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<NTag type="info" size="small">{{Number(idx)+1}}</NTag>
|
||
<p class="text-sm">{{s.name}}</p>
|
||
<NCheckbox :checked="s.countersign" size="small" @update:checked="(v) => setStepCountersign(s, v)">
|
||
<span class="text-xs text-slate-500">专业会签步骤</span>
|
||
</NCheckbox>
|
||
<NButton ghost type="primary" size="tiny" @click="togglePicker(s)" title="添加预设人员" class="ml-auto">
|
||
<Icon icon="ic:round-plus" class="size-16px" />
|
||
</NButton>
|
||
</div>
|
||
<!-- 预设人员:人员必选;仅会签步骤可选择专业(必选),且人员可勾选「必须进行专业会签」 -->
|
||
<div class="mt-2 flex gap-2 pl-8">
|
||
<span class="shrink-0 text-xs leading-7 text-slate-400">预设人员:</span>
|
||
<div class="min-w-0 flex-1 space-y-1.5">
|
||
<div v-for="(m, mIdx) in s.members" :key="m.id" class="flex min-h-7 flex-wrap items-center gap-2">
|
||
<span class="shrink-0 truncate text-xs font-medium text-slate-700">{{ m.user_name }}</span>
|
||
<template v-if="s.countersign">
|
||
<span class="text-xs text-slate-400"><span class="text-red-500">*</span>专业:</span>
|
||
<NSelect :value="m.specialty || null" clearable size="small" placeholder="请选择专业" class="w-28"
|
||
:options="SPECIALTIES"
|
||
@update:value="(v) => selectSpecialty(Number(idx), Number(mIdx), v)"
|
||
/>
|
||
<NCheckbox :checked="m.is_sign" size="small"
|
||
:checked-value="1"
|
||
:unchecked-value="0"
|
||
@update:checked="(v) => selectSign(Number(idx), Number(mIdx), v)"
|
||
>
|
||
<span class="text-xs text-slate-500">必须参与会签</span>
|
||
</NCheckbox>
|
||
</template>
|
||
<NButton text type="error" @click="delMember(s, m.user_id)" title="删除该人员">
|
||
<Icon icon="iconamoon:trash" class="size-16px" />
|
||
</NButton>
|
||
</div>
|
||
<p v-if="s.countersign && s.members.length" class="text-[11px] leading-5 text-slate-400">
|
||
必须参与会签:勾选的人员必须参与专业会签,未勾选的人员为可选会签(按需参加)
|
||
</p>
|
||
<NCascader v-if="pickerStepId === s.unique_id" :value="pick" :options="props.members"
|
||
placeholder="请选择人员" size="small" check-strategy="child" :show-path="false" expand-trigger="click"
|
||
class="w-56" :on-update:value="(v) => addMember(s, v)" />
|
||
|
||
<span v-if="s.members.length === 0" class="text-xs leading-7 text-slate-300">未预设</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<NButton v-if="currentSteps.length" type="primary" class="w-full" @click="finishApprover">完成设置</NButton>
|
||
</template>
|
||
</NDrawerContent>
|
||
</NDrawer>
|
||
</template> |