Compare commits
182
Commits
master
..
7b71038949
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b71038949 | ||
|
|
7c5f0dea0b | ||
|
|
2756ed15e2 | ||
|
|
174d0a69dd | ||
|
|
be94e01fe9 | ||
|
|
69a46e28ae | ||
|
|
3680b728f9 | ||
|
|
cdc8d84009 | ||
|
|
6053337f67 | ||
|
|
63eb2217f5 | ||
|
|
8a5c0a7169 | ||
|
|
21abd142c4 | ||
|
|
9af3fe952d | ||
|
|
52089406f3 | ||
|
|
2ec8a458e3 | ||
|
|
203c4eca0c | ||
|
|
a27168bbb0 | ||
|
|
a188aae60d | ||
|
|
7e224d6df6 | ||
|
|
d82e9fa83e | ||
|
|
ea7ce0f819 | ||
|
|
9be1ca3ad9 | ||
|
|
5310c394ec | ||
|
|
e751477e73 | ||
|
|
1f2ab85a68 | ||
|
|
f9483c00ec | ||
|
|
6d1a3c048b | ||
|
|
16a18a49b9 | ||
|
|
3f3b5910b1 | ||
|
|
98625c5c94 | ||
|
|
7e0fb9e516 | ||
|
|
b759ab60db | ||
|
|
4d87b4dd2e | ||
|
|
a5282019c0 | ||
|
|
5b981d4fa6 | ||
|
|
bde8ff512b | ||
|
|
26582cf421 | ||
|
|
167b3de60d | ||
|
|
043e66c9f5 | ||
|
|
37a2b3a61f | ||
|
|
214409e4be | ||
|
|
a26075d61b | ||
|
|
4ab213e396 | ||
|
|
f8c25dc9b8 | ||
|
|
99ab0a1183 | ||
|
|
3d1f91e814 | ||
|
|
2e9b20d559 | ||
|
|
9f34602b3a | ||
|
|
d4d16ad0d9 | ||
|
|
f711b0ff64 | ||
|
|
8cc5f07883 | ||
|
|
3a08d062ad | ||
|
|
2a78a35286 | ||
|
|
9f674eb5c6 | ||
|
|
071d751f79 | ||
|
|
8206121d46 | ||
|
|
253df87b31 | ||
|
|
bcfc167905 | ||
|
|
3f6b16f8be | ||
|
|
7352d5c186 | ||
|
|
ef7c65be05 | ||
|
|
bd7cad2740 | ||
|
|
f671337bc7 | ||
|
|
6f64a39586 | ||
|
|
b48d89d389 | ||
|
|
e09e2fcb53 | ||
|
|
e3154adf3f | ||
|
|
09d679efd9 | ||
|
|
62a3e5acec | ||
|
|
e9d40296b0 | ||
|
|
532ff6e0c0 | ||
|
|
8a06e330a9 | ||
|
|
894638bcc4 | ||
|
|
6e0b91c834 | ||
|
|
4e7b217588 | ||
|
|
c31924a8d8 | ||
|
|
ccfd8cb260 | ||
|
|
354237d278 | ||
|
|
55c4d80211 | ||
|
|
252b960f04 | ||
|
|
c6ebecda4b | ||
|
|
c16933a46d | ||
|
|
bffefea0da | ||
|
|
b2f6e57d73 | ||
|
|
467952583b | ||
|
|
c73d5ba8a6 | ||
|
|
86a3520ecb | ||
|
|
0e42842611 | ||
|
|
f2770b752a | ||
|
|
e2409fe19b | ||
|
|
ffe0a974e8 | ||
|
|
461298ce59 | ||
|
|
7a5952cf66 | ||
|
|
67c9090827 | ||
|
|
c679642c40 | ||
|
|
680c43b3d5 | ||
|
|
3fe8da170c | ||
|
|
1c85b2ae92 | ||
|
|
92162ae4dc | ||
|
|
da6930034b | ||
|
|
3dfb99f0f2 | ||
|
|
7ea6ef4f2d | ||
|
|
05b7357ca6 | ||
|
|
0bf469d37d | ||
|
|
1d8e0c321b | ||
|
|
ebfb52d1db | ||
|
|
ace1401089 | ||
|
|
f97e137f85 | ||
|
|
683e3187ac | ||
|
|
1d980ebcd0 | ||
|
|
da61394d36 | ||
|
|
ef8bca3c92 | ||
|
|
9eff872e14 | ||
|
|
347c6839cd | ||
|
|
4b31fbc3f1 | ||
|
|
79ab4b9b9f | ||
|
|
eff7f4498d | ||
|
|
c35f7252d1 | ||
|
|
30c2a79f51 | ||
|
|
8272d0653f | ||
|
|
098e92026e | ||
|
|
46b50e052e | ||
|
|
fa6206573d | ||
|
|
8d9e89a590 | ||
|
|
9aa849781e | ||
|
|
052894332f | ||
|
|
8dffb76a16 | ||
|
|
c91851e0b0 | ||
|
|
daddb87330 | ||
|
|
9bd865cb2c | ||
|
|
a7cd8f1901 | ||
|
|
c310ab351e | ||
|
|
1c4c8e7b62 | ||
|
|
7030414146 | ||
|
|
8e2bc84b02 | ||
|
|
db8100130a | ||
|
|
c36a5bbc85 | ||
|
|
bb41d5a849 | ||
|
|
a5cca53127 | ||
|
|
000178f04d | ||
|
|
d7b1f4b2ef | ||
|
|
30bf3aa01a | ||
|
|
376f6b7cd5 | ||
|
|
6f41d613ba | ||
|
|
f1a515b70c | ||
|
|
563c830363 | ||
|
|
0212c254ac | ||
|
|
b7cdde9263 | ||
|
|
a202b40c7d | ||
|
|
deea4c4991 | ||
|
|
d74ebb4ecc | ||
|
|
607a36a1fb | ||
|
|
fa268fec92 | ||
|
|
10115eb422 | ||
|
|
8706ec4b92 | ||
|
|
7d05236399 | ||
|
|
cb918ea562 | ||
|
|
4f47ac0318 | ||
|
|
09dbd8ba2d | ||
|
|
378829da21 | ||
|
|
935df1d5e6 | ||
|
|
5c459b29e1 | ||
|
|
d2d90f1c6f | ||
|
|
040226b2e0 | ||
|
|
3d6525e3d4 | ||
|
|
a4c7adb317 | ||
|
|
bc629e9189 | ||
|
|
83132dc31c | ||
|
|
a3e03466e1 | ||
|
|
8bb82e7bdf | ||
|
|
7b7f8ad98d | ||
|
|
2c89967af0 | ||
|
|
5ecd8ac95c | ||
|
|
76fe37d378 | ||
|
|
4923c4e5d6 | ||
|
|
77a69ec7a3 | ||
|
|
a473951901 | ||
|
|
6651cb6749 | ||
|
|
97a0465890 | ||
|
|
f850774d65 | ||
|
|
d4be566459 | ||
|
|
f5176ada20 |
@@ -71,6 +71,7 @@
|
||||
"json5": "2.2.3",
|
||||
"naive-ui": "2.44.1",
|
||||
"nprogress": "0.2.0",
|
||||
"pdf-vue3": "^2.0.1",
|
||||
"pinia": "3.0.4",
|
||||
"pinyin-pro": "3.28.0",
|
||||
"print-js": "1.6.0",
|
||||
@@ -84,6 +85,7 @@
|
||||
"vue-i18n": "11.4.2",
|
||||
"vue-pdf-embed": "2.1.4",
|
||||
"vue-router": "5.0.7",
|
||||
"vue3-office-preview": "^0.1.2",
|
||||
"wangeditor": "4.7.15",
|
||||
"xgplayer": "3.0.23",
|
||||
"xlsx": "0.18.5"
|
||||
|
||||
Generated
+2738
-1501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import PDF from 'pdf-vue3'
|
||||
import { VueOfficeDocx, VueOfficeExcel, VueOfficePptx } from 'vue3-office-preview'
|
||||
import 'vue3-office-preview/lib/style.css'
|
||||
|
||||
const props = defineProps(['previewFile',])
|
||||
|
||||
// 判断文件类型
|
||||
const handleFileType = (type:string) => {
|
||||
if(type){
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
|
||||
const wordExtensions = ['docx']
|
||||
const excelExtensions = ['xlsx']
|
||||
const pptExtensions = ['pptx']
|
||||
if(imageExtensions.includes(type)){
|
||||
return 'image'
|
||||
}
|
||||
if(wordExtensions.includes(type)){
|
||||
return 'word'
|
||||
}
|
||||
if(excelExtensions.includes(type)){
|
||||
return 'excel'
|
||||
}
|
||||
if(pptExtensions.includes(type)){
|
||||
return 'ppt'
|
||||
}
|
||||
if(type==='pdf'){
|
||||
return 'pdf'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-h-[70vh] overflow-x-auto rounded-lg border">
|
||||
<!-- 图片预览 -->
|
||||
<template v-if="handleFileType(previewFile?.file_type) === 'image'">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<NImage :src="previewFile?.file_url" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- Word 预览 -->
|
||||
<template v-else-if="handleFileType(previewFile?.file_type) === 'word'">
|
||||
<VueOfficeDocx :src="previewFile?.file_url" class="w-full items-center" />
|
||||
</template>
|
||||
<!-- Excel 预览 -->
|
||||
<template v-else-if="handleFileType(previewFile?.file_type) === 'excel'">
|
||||
<VueOfficeExcel :src="previewFile?.file_url" class="w-full items-center" />
|
||||
</template>
|
||||
<!-- Ppt 预览 -->
|
||||
<template v-else-if="handleFileType(previewFile?.file_type) === 'ppt'">
|
||||
<VueOfficePptx :src="previewFile?.file_url" class="w-full items-center" />
|
||||
</template>
|
||||
<!-- PDF 预览 -->
|
||||
<template v-else-if="handleFileType(previewFile?.file_type) === 'pdf'">
|
||||
<div class="h-[68vh]">
|
||||
<PDF :src="previewFile?.file_url" class="w-full h-full items-center" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 其他类型文件预览 -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>暂不支持预览该文件类型</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+2
-1
@@ -3,5 +3,6 @@ export enum SetupStoreId {
|
||||
Theme = 'theme-store',
|
||||
Auth = 'auth-store',
|
||||
Route = 'route-store',
|
||||
Tab = 'tab-store'
|
||||
Tab = 'tab-store',
|
||||
Change = 'change-store',
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ export function useRouterPush(inSetup = true) {
|
||||
function routerPushByKeyWithMetaQuery(key: RouteKey) {
|
||||
const allRoutes = router.getRoutes();
|
||||
const meta = allRoutes.find(item => item.name === key)?.meta || null;
|
||||
// 处理 my_initiatechange 路由,如果 id 存在,直接跳转
|
||||
// 判断发起变更是否是编辑
|
||||
if(meta?.title==='发起变更' && route.value?.query?.id){
|
||||
return
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { VNode } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useSvgIcon } from '@/hooks/common/icon';
|
||||
import { localStg } from '@/utils/storage';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserAvatar'
|
||||
@@ -33,9 +34,9 @@ type DropdownOption =
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
label: '个人中心',
|
||||
label: '修改密码',
|
||||
key: 'user-center',
|
||||
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
||||
icon: SvgIconVNode({ icon: 'boxicons:lock-open', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
@@ -58,7 +59,7 @@ function logout() {
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
authStore.resetStore();
|
||||
authStore.logout();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -67,8 +68,7 @@ function handleDropdown(key: DropdownKey) {
|
||||
if (key === 'logout') {
|
||||
logout();
|
||||
} else {
|
||||
// If your other options are jumps from other routes, they will be directly supported here
|
||||
// routerPushByKey(key);
|
||||
routerPushByKey('changepassword');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,7 +81,10 @@ function handleDropdown(key: DropdownKey) {
|
||||
<div>
|
||||
<ButtonIcon>
|
||||
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
|
||||
<span class="text-16px font-medium">{{ authStore.userInfo.username }}</span>
|
||||
<div class="flex items-center text-16px font-medium">
|
||||
<span>{{ localStg.get('userInfo')?.username || '' }} </span>
|
||||
<p v-if="localStg.get('userInfo')?.roles[0]" class="ml-1">·<span class="ml-1">{{ localStg.get('userInfo')?.roles[0] }}</span></p>
|
||||
</div>
|
||||
</ButtonIcon>
|
||||
</div>
|
||||
</NDropdown>
|
||||
|
||||
@@ -8,6 +8,7 @@ import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
import GlobalMessage from '../global-message/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalHeader'
|
||||
@@ -40,6 +41,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<div class="h-full flex-y-center justify-end">
|
||||
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
|
||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||
<!-- <GlobalMessage /> -->
|
||||
<LangSwitch
|
||||
v-if="themeStore.header.multilingual.visible"
|
||||
:lang="appStore.locale"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
defineOptions({ name: 'GlobalMessage' });
|
||||
|
||||
const value = ref('')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover trigger="hover">
|
||||
<template #trigger>
|
||||
<NButton quaternary><icon-bi:bell class="size-4.5" /></NButton>
|
||||
</template>
|
||||
<div class="w-300px">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>消息</span>
|
||||
<NButton text type="primary" size="small" class="text-xs">查看全部</NButton>
|
||||
</div>
|
||||
<div class="border-t border-b my-1.5 px-2 py-2 space-y-2">
|
||||
<div class="max-h-200px overflow-y-auto">
|
||||
<div>联锁绑定导入成功</div>
|
||||
<div>联锁绑定导入成功</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<NButton text type="primary" size="small" class="text-xs">全部已读</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NPopover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -28,7 +28,7 @@ const drawerWidth = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
|
||||
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth" :auto-focus="false">
|
||||
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
|
||||
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
|
||||
<NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
|
||||
|
||||
+11
-11
@@ -48,7 +48,7 @@ const local: App.I18n.Schema = {
|
||||
yesOrNo: {
|
||||
yes: '是',
|
||||
no: '否'
|
||||
}
|
||||
},
|
||||
},
|
||||
request: {
|
||||
logout: '请求失败后登出用户',
|
||||
@@ -224,23 +224,22 @@ const local: App.I18n.Schema = {
|
||||
403: '无权限',
|
||||
404: '页面不存在',
|
||||
500: '服务器错误',
|
||||
details: '详情',
|
||||
details_closedoc: '资料关闭',
|
||||
details_index: '变更详情',
|
||||
details_disposal: '处置申请',
|
||||
details_approval: '审批详情',
|
||||
changepassword: '修改密码',
|
||||
home: '首页',
|
||||
my: '我的操作台',
|
||||
my_initiatechange: '发起变更',
|
||||
my_highrisk: '本专业高风险',
|
||||
my_highriskdetail: '高风险场景详情',
|
||||
my_highrisk: '高风险',
|
||||
my_mytask: '我的任务',
|
||||
my_mydraft: '我的草稿',
|
||||
my_pending: '待我处理',
|
||||
my_process: '流程演示',
|
||||
my_processdetail: '流程演示详情',
|
||||
my_pendingchange: '待投用变更',
|
||||
my_effect: '变更投入和效果',
|
||||
my_datachangecenter: '变更数据中心',
|
||||
my_closeconfirm: '关闭确认',
|
||||
changeledger: '变更台账',
|
||||
changeledger_mychanges: '我发起的变更',
|
||||
changeledger_workshopchanges: '本车间变更',
|
||||
changestatistics: '本部门变更统计',
|
||||
docs: '资料管理',
|
||||
docs_archives: '资料档案库',
|
||||
@@ -251,11 +250,12 @@ const local: App.I18n.Schema = {
|
||||
systemmanage_template: '审批模板管理',
|
||||
my_pssr: 'PSSR监督',
|
||||
riskstatistics: '安全与风险统计',
|
||||
|
||||
report: '统计报表',
|
||||
signstatistics: '质量会签统计',
|
||||
professionalstatistics: '本专业变更统计',
|
||||
factoryoverview: '全工厂统计概览',
|
||||
systemmanage_setting: '系统配置'
|
||||
workshopstatistics: '本车间变更统计',
|
||||
systemmanage_setting: '系统配置',
|
||||
},
|
||||
form: {
|
||||
required: '不能为空',
|
||||
|
||||
@@ -18,27 +18,27 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
403: () => import("@/views/_builtin/403/index.vue"),
|
||||
404: () => import("@/views/_builtin/404/index.vue"),
|
||||
500: () => import("@/views/_builtin/500/index.vue"),
|
||||
changeledger_mychanges: () => import("@/views/changeLedger/myChanges/index.vue"),
|
||||
changeledger_workshopchanges: () => import("@/views/changeLedger/workshopChanges/index.vue"),
|
||||
changeledger: () => import("@/views/changeLedger/index.vue"),
|
||||
changepassword: () => import("@/views/changePassword/index.vue"),
|
||||
changestatistics: () => import("@/views/changeStatistics/index.vue"),
|
||||
details_approval: () => import("@/views/details/approval/index.vue"),
|
||||
details_closedoc: () => import("@/views/details/closeDoc/index.vue"),
|
||||
details_disposal: () => import("@/views/details/disposal/index.vue"),
|
||||
details_index: () => import("@/views/details/index/index.vue"),
|
||||
docs_archives: () => import("@/views/docs/archives/index.vue"),
|
||||
docs_query: () => import("@/views/docs/query/index.vue"),
|
||||
factoryoverview: () => import("@/views/factoryOverview/index.vue"),
|
||||
home: () => import("@/views/home/index.vue"),
|
||||
login: () => import("@/views/login/index.vue"),
|
||||
my_closeconfirm: () => import("@/views/my/closeConfirm/index.vue"),
|
||||
my_datachangecenter: () => import("@/views/my/dataChangeCenter/index.vue"),
|
||||
my_effect: () => import("@/views/my/effect/index.vue"),
|
||||
my_highrisk: () => import("@/views/my/highRisk/index.vue"),
|
||||
my_highriskdetail: () => import("@/views/my/highRiskDetail/index.vue"),
|
||||
my_initiatechange: () => import("@/views/my/initiateChange/index.vue"),
|
||||
my_mydraft: () => import("@/views/my/myDraft/index.vue"),
|
||||
my_mytask: () => import("@/views/my/myTask/index.vue"),
|
||||
my_pending: () => import("@/views/my/pending/index.vue"),
|
||||
my_pendingchange: () => import("@/views/my/pendingChange/index.vue"),
|
||||
my_process: () => import("@/views/my/process/index.vue"),
|
||||
my_processdetail: () => import("@/views/my/processDetail/index.vue"),
|
||||
my_pssr: () => import("@/views/my/pssr/index.vue"),
|
||||
professionalstatistics: () => import("@/views/professionalStatistics/index.vue"),
|
||||
report: () => import("@/views/report/index.vue"),
|
||||
riskstatistics: () => import("@/views/riskStatistics/index.vue"),
|
||||
signstatistics: () => import("@/views/signStatistics/index.vue"),
|
||||
@@ -46,4 +46,5 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
systemmanage_setting: () => import("@/views/systemManage/setting/index.vue"),
|
||||
systemmanage_template: () => import("@/views/systemManage/template/index.vue"),
|
||||
systemmanage_user: () => import("@/views/systemManage/user/index.vue"),
|
||||
workshopstatistics: () => import("@/views/workshopStatistics/index.vue"),
|
||||
};
|
||||
|
||||
+153
-151
@@ -42,56 +42,102 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
{
|
||||
name: 'changeledger',
|
||||
path: '/changeledger',
|
||||
component: 'layout.base',
|
||||
component: 'layout.base$view.changeledger',
|
||||
meta: {
|
||||
title: 'changeledger',
|
||||
i18nKey: 'route.changeledger',
|
||||
title: '变更台账',
|
||||
icon: 'tabler:table',
|
||||
order: 3
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'changeledger_mychanges',
|
||||
path: '/changeledger/mychanges',
|
||||
component: 'view.changeledger_mychanges',
|
||||
meta: {
|
||||
title: 'changeledger_mychanges',
|
||||
i18nKey: 'route.changeledger_mychanges',
|
||||
icon: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'changeledger_workshopchanges',
|
||||
path: '/changeledger/workshopchanges',
|
||||
component: 'view.changeledger_workshopchanges',
|
||||
meta: {
|
||||
title: 'changeledger_workshopchanges',
|
||||
i18nKey: 'route.changeledger_workshopchanges',
|
||||
icon: ''
|
||||
}
|
||||
}
|
||||
]
|
||||
order: 3,
|
||||
i18nKey: 'route.changeledger'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'changepassword',
|
||||
path: '/changepassword',
|
||||
component: 'layout.base$view.changepassword',
|
||||
meta: {
|
||||
title: '修改密码',
|
||||
i18nKey: 'route.changepassword',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'changestatistics',
|
||||
path: '/changestatistics',
|
||||
component: 'layout.base$view.changestatistics',
|
||||
meta: {
|
||||
title: 'changestatistics',
|
||||
i18nKey: 'route.changestatistics',
|
||||
title: '本部门变更统计',
|
||||
icon: 'ix:barchart',
|
||||
order: 3
|
||||
order: 3,
|
||||
i18nKey: 'route.changestatistics'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
path: '/details',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: '详情',
|
||||
i18nKey: 'route.details',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'details_approval',
|
||||
path: '/details/approval',
|
||||
component: 'view.details_approval',
|
||||
meta: {
|
||||
title: '审批详情',
|
||||
i18nKey: 'route.details_approval',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'details_closedoc',
|
||||
path: '/details/closedoc',
|
||||
component: 'view.details_closedoc',
|
||||
meta: {
|
||||
title: '资料关闭',
|
||||
i18nKey: 'route.details_closedoc',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'details_disposal',
|
||||
path: '/details/disposal',
|
||||
component: 'view.details_disposal',
|
||||
meta: {
|
||||
title: '处置申请',
|
||||
i18nKey: 'route.details_disposal',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'details_index',
|
||||
path: '/details/index',
|
||||
component: 'view.details_index',
|
||||
meta: {
|
||||
title: '变更详情',
|
||||
i18nKey: 'route.details_index',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'docs',
|
||||
path: '/docs',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'docs',
|
||||
i18nKey: 'route.docs',
|
||||
title: '资料管理',
|
||||
icon: 'mage:book',
|
||||
order: 4
|
||||
order: 4,
|
||||
i18nKey: 'route.docs'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -99,9 +145,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/docs/archives',
|
||||
component: 'view.docs_archives',
|
||||
meta: {
|
||||
title: 'docs_archives',
|
||||
i18nKey: 'route.docs_archives',
|
||||
icon: ''
|
||||
title: '资料档案库',
|
||||
i18nKey: 'route.docs_archives'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -109,9 +154,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/docs/query',
|
||||
component: 'view.docs_query',
|
||||
meta: {
|
||||
title: 'docs_query',
|
||||
i18nKey: 'route.docs_query',
|
||||
icon: ''
|
||||
title: '变更归档查询',
|
||||
i18nKey: 'route.docs_query'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -121,10 +165,10 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/factoryoverview',
|
||||
component: 'layout.base$view.factoryoverview',
|
||||
meta: {
|
||||
title: 'factoryoverview',
|
||||
i18nKey: 'route.factoryoverview',
|
||||
title: '全工厂统计概览',
|
||||
icon: 'ix:barchart',
|
||||
order: 3
|
||||
order: 3,
|
||||
i18nKey: 'route.factoryoverview'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -132,10 +176,10 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/home',
|
||||
component: 'layout.base$view.home',
|
||||
meta: {
|
||||
title: 'home',
|
||||
i18nKey: 'route.home',
|
||||
title: '首页',
|
||||
icon: 'ant-design:home-outlined',
|
||||
order: 1
|
||||
order: 1,
|
||||
i18nKey: 'route.home'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -144,7 +188,7 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
component: 'layout.blank$view.login',
|
||||
props: true,
|
||||
meta: {
|
||||
title: 'login',
|
||||
title: '登录',
|
||||
i18nKey: 'route.login',
|
||||
constant: true,
|
||||
hideInMenu: true
|
||||
@@ -155,30 +199,19 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'my',
|
||||
title: '我的操作台',
|
||||
i18nKey: 'route.my',
|
||||
icon: 'heroicons:computer-desktop',
|
||||
order: 2
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'my_closeconfirm',
|
||||
path: '/my/closeconfirm',
|
||||
component: 'view.my_closeconfirm',
|
||||
meta: {
|
||||
title: 'my_closeconfirm',
|
||||
i18nKey: 'route.my_closeconfirm',
|
||||
icon: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'my_datachangecenter',
|
||||
path: '/my/datachangecenter',
|
||||
component: 'view.my_datachangecenter',
|
||||
meta: {
|
||||
title: 'my_datachangecenter',
|
||||
i18nKey: 'route.my_datachangecenter',
|
||||
icon: ''
|
||||
title: '变更数据中心',
|
||||
i18nKey: 'route.my_datachangecenter'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -186,9 +219,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/effect',
|
||||
component: 'view.my_effect',
|
||||
meta: {
|
||||
title: 'my_effect',
|
||||
i18nKey: 'route.my_effect',
|
||||
icon: ''
|
||||
title: '变更投入和效果',
|
||||
i18nKey: 'route.my_effect'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -196,20 +228,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/highrisk',
|
||||
component: 'view.my_highrisk',
|
||||
meta: {
|
||||
title: 'my_highrisk',
|
||||
i18nKey: 'route.my_highrisk',
|
||||
icon: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'my_highriskdetail',
|
||||
path: '/my/highriskdetail',
|
||||
component: 'view.my_highriskdetail',
|
||||
meta: {
|
||||
title: 'my_highriskdetail',
|
||||
i18nKey: 'route.my_highriskdetail',
|
||||
icon: '',
|
||||
hideInMenu: true
|
||||
title: '高风险',
|
||||
i18nKey: 'route.my_highrisk'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -217,9 +237,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/initiatechange',
|
||||
component: 'view.my_initiatechange',
|
||||
meta: {
|
||||
title: 'my_initiatechange',
|
||||
i18nKey: 'route.my_initiatechange',
|
||||
icon: ''
|
||||
title: '发起变更',
|
||||
i18nKey: 'route.my_initiatechange'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -227,9 +246,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/mydraft',
|
||||
component: 'view.my_mydraft',
|
||||
meta: {
|
||||
title: 'my_mydraft',
|
||||
i18nKey: 'route.my_mydraft',
|
||||
icon: ''
|
||||
title: '我的草稿',
|
||||
i18nKey: 'route.my_mydraft'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -237,19 +255,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/mytask',
|
||||
component: 'view.my_mytask',
|
||||
meta: {
|
||||
title: 'my_mytask',
|
||||
i18nKey: 'route.my_mytask',
|
||||
icon: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'my_pending',
|
||||
path: '/my/pending',
|
||||
component: 'view.my_pending',
|
||||
meta: {
|
||||
title: 'my_pending',
|
||||
i18nKey: 'route.my_pending',
|
||||
icon: ''
|
||||
title: '我的任务',
|
||||
i18nKey: 'route.my_mytask'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -257,30 +264,8 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/pendingchange',
|
||||
component: 'view.my_pendingchange',
|
||||
meta: {
|
||||
title: 'my_pendingchange',
|
||||
i18nKey: 'route.my_pendingchange',
|
||||
icon: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'my_process',
|
||||
path: '/my/process',
|
||||
component: 'view.my_process',
|
||||
meta: {
|
||||
title: 'my_process',
|
||||
i18nKey: 'route.my_process',
|
||||
icon: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'my_processdetail',
|
||||
path: '/my/processdetail',
|
||||
component: 'view.my_processdetail',
|
||||
meta: {
|
||||
title: 'my_processdetail',
|
||||
i18nKey: 'route.my_processdetail',
|
||||
icon: '',
|
||||
hideInMenu: true
|
||||
title: '待投用变更',
|
||||
i18nKey: 'route.my_pendingchange'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -288,22 +273,32 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/my/pssr',
|
||||
component: 'view.my_pssr',
|
||||
meta: {
|
||||
title: 'my_pssr',
|
||||
i18nKey: 'route.my_pssr',
|
||||
icon: ''
|
||||
title: 'PSSR监督',
|
||||
i18nKey: 'route.my_pssr'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'professionalstatistics',
|
||||
path: '/professionalstatistics',
|
||||
component: 'layout.base$view.professionalstatistics',
|
||||
meta: {
|
||||
title: '本专业变更统计',
|
||||
icon: 'ix:barchart',
|
||||
order: 3,
|
||||
i18nKey: 'route.professionalstatistics'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'report',
|
||||
path: '/report',
|
||||
component: 'layout.base$view.report',
|
||||
meta: {
|
||||
title: 'report',
|
||||
i18nKey: 'route.report',
|
||||
title: '统计报表',
|
||||
icon: 'ix:barchart',
|
||||
order: 3
|
||||
order: 3,
|
||||
i18nKey: 'route.report'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -311,10 +306,10 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/riskstatistics',
|
||||
component: 'layout.base$view.riskstatistics',
|
||||
meta: {
|
||||
title: 'riskstatistics',
|
||||
i18nKey: 'route.riskstatistics',
|
||||
title: '安全与风险统计',
|
||||
icon: 'ix:barchart',
|
||||
order: 3
|
||||
order: 3,
|
||||
i18nKey: 'route.riskstatistics'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -322,10 +317,10 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/signstatistics',
|
||||
component: 'layout.base$view.signstatistics',
|
||||
meta: {
|
||||
title: 'signstatistics',
|
||||
i18nKey: 'route.signstatistics',
|
||||
title: '质量会签统计',
|
||||
icon: 'ix:barchart',
|
||||
order: 3
|
||||
order: 3,
|
||||
i18nKey: 'route.signstatistics'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -333,10 +328,10 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/systemmanage',
|
||||
component: 'layout.base',
|
||||
meta: {
|
||||
title: 'systemmanage',
|
||||
i18nKey: 'route.systemmanage',
|
||||
title: '系统设置',
|
||||
icon: 'uil:setting',
|
||||
order: 5
|
||||
order: 5,
|
||||
i18nKey: 'route.systemmanage'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -344,10 +339,9 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/systemmanage/role',
|
||||
component: 'view.systemmanage_role',
|
||||
meta: {
|
||||
title: 'systemmanage_role',
|
||||
i18nKey: 'route.systemmanage_role',
|
||||
icon: '',
|
||||
order: 2
|
||||
title: '角色管理',
|
||||
order: 2,
|
||||
i18nKey: 'route.systemmanage_role'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -355,10 +349,9 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/systemmanage/setting',
|
||||
component: 'view.systemmanage_setting',
|
||||
meta: {
|
||||
title: 'systemmanage_setting',
|
||||
i18nKey: 'route.systemmanage_setting',
|
||||
icon: '',
|
||||
order: 4
|
||||
title: '系统配置',
|
||||
order: 4,
|
||||
i18nKey: 'route.systemmanage_setting'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -366,10 +359,9 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/systemmanage/template',
|
||||
component: 'view.systemmanage_template',
|
||||
meta: {
|
||||
title: 'systemmanage_template',
|
||||
i18nKey: 'route.systemmanage_template',
|
||||
icon: '',
|
||||
order: 3
|
||||
title: '审批模板管理',
|
||||
order: 3,
|
||||
i18nKey: 'route.systemmanage_template'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -377,12 +369,22 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
path: '/systemmanage/user',
|
||||
component: 'view.systemmanage_user',
|
||||
meta: {
|
||||
title: 'systemmanage_user',
|
||||
i18nKey: 'route.systemmanage_user',
|
||||
icon: '',
|
||||
order: 1
|
||||
title: '架构及用户管理',
|
||||
order: 1,
|
||||
i18nKey: 'route.systemmanage_user'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'workshopstatistics',
|
||||
path: '/workshopstatistics',
|
||||
component: 'layout.base$view.workshopstatistics',
|
||||
meta: {
|
||||
title: '本车间变更统计',
|
||||
icon: 'ix:barchart',
|
||||
order: 3,
|
||||
i18nKey: 'route.workshopstatistics'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -167,9 +167,13 @@ const routeMap: RouteMap = {
|
||||
"404": "/404",
|
||||
"500": "/500",
|
||||
"changeledger": "/changeledger",
|
||||
"changeledger_mychanges": "/changeledger/mychanges",
|
||||
"changeledger_workshopchanges": "/changeledger/workshopchanges",
|
||||
"changepassword": "/changepassword",
|
||||
"changestatistics": "/changestatistics",
|
||||
"details": "/details",
|
||||
"details_approval": "/details/approval",
|
||||
"details_closedoc": "/details/closedoc",
|
||||
"details_disposal": "/details/disposal",
|
||||
"details_index": "/details/index",
|
||||
"docs": "/docs",
|
||||
"docs_archives": "/docs/archives",
|
||||
"docs_query": "/docs/query",
|
||||
@@ -177,19 +181,15 @@ const routeMap: RouteMap = {
|
||||
"home": "/home",
|
||||
"login": "/login/:module(pwd-login)?",
|
||||
"my": "/my",
|
||||
"my_closeconfirm": "/my/closeconfirm",
|
||||
"my_datachangecenter": "/my/datachangecenter",
|
||||
"my_effect": "/my/effect",
|
||||
"my_highrisk": "/my/highrisk",
|
||||
"my_highriskdetail": "/my/highriskdetail",
|
||||
"my_initiatechange": "/my/initiatechange",
|
||||
"my_mydraft": "/my/mydraft",
|
||||
"my_mytask": "/my/mytask",
|
||||
"my_pending": "/my/pending",
|
||||
"my_pendingchange": "/my/pendingchange",
|
||||
"my_process": "/my/process",
|
||||
"my_processdetail": "/my/processdetail",
|
||||
"my_pssr": "/my/pssr",
|
||||
"professionalstatistics": "/professionalstatistics",
|
||||
"report": "/report",
|
||||
"riskstatistics": "/riskstatistics",
|
||||
"signstatistics": "/signstatistics",
|
||||
@@ -197,7 +197,8 @@ const routeMap: RouteMap = {
|
||||
"systemmanage_role": "/systemmanage/role",
|
||||
"systemmanage_setting": "/systemmanage/setting",
|
||||
"systemmanage_template": "/systemmanage/template",
|
||||
"systemmanage_user": "/systemmanage/user"
|
||||
"systemmanage_user": "/systemmanage/user",
|
||||
"workshopstatistics": "/workshopstatistics"
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* ai-server OpenApi(MOC 插件)接入层
|
||||
* 契约:《变更管理系统插件接口说明文档 V1.0》(ai-server Module/OpenApi)
|
||||
* - base 走 vite 代理 /proxy-ai -> http://127.0.0.1:12333(见 vite.config.ts)
|
||||
* - 鉴权:app 凭据换应用令牌(缓存 2h)→ exchange 换会员 JWT(ai_token,会话级缓存)
|
||||
* - 本地联调形态:前端直连 exchange(moc-server 的登录双令牌下发链路就绪后,
|
||||
* 改为登录时由 moc-server 下发 ai_token,本层仅需替换 getAiToken 来源)
|
||||
*/
|
||||
import { request } from '../request/ai';
|
||||
/** ai-server 统一响应 */
|
||||
interface AiResp<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class AiError extends Error {
|
||||
code: number;
|
||||
data: any;
|
||||
constructor(code: number, message: string, data?: any) {
|
||||
super(message || `AI 服务错误(${code})`);
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = (import.meta.env.VITE_AI_BASE_URL as string) || '/proxy-ai';
|
||||
const APP_ID = (import.meta.env.VITE_AI_APP_ID as string) || 'moc_jcyh';
|
||||
const APP_SECRET = (import.meta.env.VITE_AI_APP_SECRET as string) || 'moc-jcyh-dev-secret';
|
||||
|
||||
/** exchange 用的当前 MOC 用户身份(登录成功后由调用方注入) */
|
||||
export interface AiUserIdentity {
|
||||
platform_user_id: string;
|
||||
align_key: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
let identity: AiUserIdentity = {
|
||||
// 本地联调默认用户(文档 §8.2)
|
||||
platform_user_id: '1001',
|
||||
align_key: 'zhang.gong@jcyh.local',
|
||||
display_name: '张工'
|
||||
};
|
||||
|
||||
/** 登录后注入真实用户身份;用户变更会强制重新 exchange */
|
||||
export function setAiUser(user: Partial<AiUserIdentity>) {
|
||||
identity = { ...identity, ...user };
|
||||
aiTokenCache = null;
|
||||
}
|
||||
|
||||
let appTokenCache: { token: string; expireAt: number } | null = null;
|
||||
let aiTokenCache: { token: string; userKey: string } | null = null;
|
||||
let requestSeq = 0;
|
||||
|
||||
async function rawFetch<T>(path: string, init: RequestInit): Promise<AiResp<T>> {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${BASE}${path}`, init);
|
||||
} catch (e: any) {
|
||||
throw new AiError(-1, `AI 服务不可达:${e?.message ?? e}`);
|
||||
}
|
||||
try {
|
||||
return (await resp.json()) as AiResp<T>;
|
||||
} catch {
|
||||
throw new AiError(resp.status, `AI 服务响应异常(HTTP ${resp.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAppToken(): Promise<string> {
|
||||
if (appTokenCache && appTokenCache.expireAt > Date.now() + 60_000) return appTokenCache.token;
|
||||
const body = await rawFetch<{ access_token: string; expires_in: number }>('/open/v1/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET })
|
||||
});
|
||||
if (body.code !== 0) throw new AiError(body.code, body.message || '应用令牌获取失败', body.data);
|
||||
appTokenCache = { token: body.data.access_token, expireAt: Date.now() + (body.data.expires_in ?? 7200) * 1000 };
|
||||
return appTokenCache.token;
|
||||
}
|
||||
|
||||
async function getAiToken(): Promise<string> {
|
||||
const userKey = identity.platform_user_id || identity.align_key;
|
||||
if (aiTokenCache && aiTokenCache.userKey === userKey) return aiTokenCache.token;
|
||||
const cached = sessionStorage.getItem(`ai_token:${userKey}`);
|
||||
if (cached) {
|
||||
aiTokenCache = { token: cached, userKey };
|
||||
return cached;
|
||||
}
|
||||
const appToken = await getAppToken();
|
||||
const body = await rawFetch<{ token: string }>('/open/v1/auth/exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${appToken}` },
|
||||
body: JSON.stringify({
|
||||
platform: 'moc',
|
||||
platform_user_id: identity.platform_user_id,
|
||||
align_key: identity.align_key,
|
||||
display_name: identity.display_name
|
||||
})
|
||||
});
|
||||
if (body.code !== 0) throw new AiError(body.code, body.message || 'ai_token 签发失败', body.data);
|
||||
aiTokenCache = { token: body.data.token, userKey };
|
||||
sessionStorage.setItem(`ai_token:${userKey}`, body.data.token);
|
||||
return body.data.token;
|
||||
}
|
||||
|
||||
/** ai_token 失效(40101)时清缓存并自动重新 exchange 重试一次 */
|
||||
async function aiFetch<T = any>(path: string, payload?: unknown, method: 'GET' | 'POST' = 'POST', retried = false): Promise<T> {
|
||||
const token = await getAiToken();
|
||||
const body = await rawFetch<T>(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: method === 'GET' ? undefined : JSON.stringify(payload ?? {})
|
||||
});
|
||||
if (body.code === 0) return body.data;
|
||||
if (body.code === 40101 && !retried) {
|
||||
aiTokenCache = null;
|
||||
sessionStorage.removeItem(`ai_token:${identity.platform_user_id || identity.align_key}`);
|
||||
return aiFetch<T>(path, payload, method, true);
|
||||
}
|
||||
throw new AiError(body.code, body.message || 'AI 分析失败', body.data);
|
||||
}
|
||||
|
||||
function nextRequestId(prefix: string): string {
|
||||
requestSeq += 1;
|
||||
return `moc-${Date.now()}-${requestSeq}`;
|
||||
}
|
||||
|
||||
/* ---------- 类型与默认载荷 ---------- */
|
||||
|
||||
export interface CompareRow {
|
||||
item: string;
|
||||
before_text: string;
|
||||
after_text: string;
|
||||
}
|
||||
|
||||
export interface MatrixPayload {
|
||||
size: string;
|
||||
l_range?: [number, number];
|
||||
s_range?: [number, number];
|
||||
rr?: { min: number; level: string; name: string }[];
|
||||
default_level?: string;
|
||||
}
|
||||
|
||||
export interface LevelDim {
|
||||
dim: string;
|
||||
name: string;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** MOC 官方风险矩阵(对齐 service_system_config: risk.matrix.official,5×5 LS) */
|
||||
export const DEFAULT_MATRIX: MatrixPayload = {
|
||||
size: '5x5',
|
||||
l_range: [1, 5],
|
||||
s_range: [1, 5],
|
||||
rr: [
|
||||
{ min: 15, level: 'MAJOR', name: '重大' },
|
||||
{ min: 10, level: 'HIGH', name: '较大' },
|
||||
{ min: 5, level: 'MID', name: '一般' }
|
||||
],
|
||||
default_level: 'LOW'
|
||||
};
|
||||
|
||||
/** MOC 等级评分维度(对齐 change.level.dims,6 维固化) */
|
||||
export const DEFAULT_LEVEL_DIMS: LevelDim[] = [
|
||||
{ dim: 'EQUIPMENT', name: '设备影响', max: 4 },
|
||||
{ dim: 'PROCESS', name: '工艺影响', max: 4 },
|
||||
{ dim: 'SAFETY', name: '安全风险', max: 4 },
|
||||
{ dim: 'ENVIRONMENT', name: '环境影响', max: 4 },
|
||||
{ dim: 'QUALITY', name: '质量影响', max: 4 },
|
||||
{ dim: 'SCHEDULE', name: '进度与成本', max: 4 }
|
||||
];
|
||||
|
||||
/* ---------- 变更智能分析(同步) ---------- */
|
||||
|
||||
export interface RecognizeResult {
|
||||
rows: CompareRow[];
|
||||
is_change: boolean;
|
||||
same_kind_replace: boolean;
|
||||
materials_suggest: string[];
|
||||
}
|
||||
|
||||
export function aiRecognize(content: string, deviceTags: string[] = []) {
|
||||
return aiFetch<RecognizeResult>('/open/v1/moc/change/recognize', {
|
||||
request_id: nextRequestId('rec'),
|
||||
content,
|
||||
device_tags: deviceTags
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ApplicationResult {
|
||||
name: string;
|
||||
main_changes: string[];
|
||||
related_changes: string[];
|
||||
purposes: string[];
|
||||
effect: string;
|
||||
change_type: string;
|
||||
level_suggestion: string | null;
|
||||
duration_suggestion: string | null;
|
||||
materials_text: string;
|
||||
update_docs: string[];
|
||||
disciplines: string[];
|
||||
signers: Record<string, string> | null;
|
||||
risk_tools: string[];
|
||||
department_opinions?: any;
|
||||
}
|
||||
|
||||
export const aiApplication = (content: string, rows: CompareRow[]) =>
|
||||
aiFetch<ApplicationResult>('/open/v1/moc/change/application', {
|
||||
request_id: nextRequestId('app'),
|
||||
content,
|
||||
rows
|
||||
});
|
||||
|
||||
export interface PreRiskResult {
|
||||
severity: string;
|
||||
probability: string;
|
||||
protection: string;
|
||||
}
|
||||
// 风险预分析
|
||||
export const aiRiskPreAnalysis = (content: string, rows: CompareRow[]) =>
|
||||
aiFetch<PreRiskResult>('/open/v1/moc/change/risk-pre-analysis', {
|
||||
request_id: nextRequestId('pre'),
|
||||
content,
|
||||
rows
|
||||
});
|
||||
// export function aiRiskPreAnalysis (params: { content: string, rows: CompareRow[] }) {
|
||||
// return request({
|
||||
// url: '/open/v1/moc/change/risk-pre-analysis',
|
||||
// method: 'post',
|
||||
// data: params
|
||||
// })
|
||||
// }
|
||||
|
||||
export interface TypeJudgeResult {
|
||||
type: string;
|
||||
confidence: number;
|
||||
conclusion: string;
|
||||
hit_rules: string[];
|
||||
exclude_rules: string[];
|
||||
knowledge_refs: string[];
|
||||
}
|
||||
|
||||
export const aiTypeJudge = (content: string, rows: CompareRow[]) =>
|
||||
aiFetch<TypeJudgeResult>('/open/v1/moc/change/type-judge', {
|
||||
request_id: nextRequestId('type'),
|
||||
content,
|
||||
rows
|
||||
});
|
||||
|
||||
export interface LevelScoreResult {
|
||||
dims: { dim: string; name: string; score: number | null; basis: string }[];
|
||||
total_score: number;
|
||||
analysis_text: string;
|
||||
}
|
||||
|
||||
export const aiLevelScore = (content: string, rows: CompareRow[], levelDims: LevelDim[]) =>
|
||||
aiFetch<LevelScoreResult>('/open/v1/moc/change/level-score', {
|
||||
request_id: nextRequestId('level'),
|
||||
content,
|
||||
rows,
|
||||
level_dims: levelDims
|
||||
});
|
||||
|
||||
export interface RiskCheckItem {
|
||||
no?: number;
|
||||
group: string;
|
||||
q: string;
|
||||
ans: string;
|
||||
risk?: string;
|
||||
measure?: string;
|
||||
}
|
||||
|
||||
export const aiRiskCheck = (content: string, rows: CompareRow[], checkTemplate?: object) =>
|
||||
aiFetch<{ items: RiskCheckItem[] }>('/open/v1/moc/change/risk-check', {
|
||||
request_id: nextRequestId('check'),
|
||||
content,
|
||||
rows,
|
||||
...(checkTemplate ? { check_template: checkTemplate } : {})
|
||||
});
|
||||
|
||||
export interface RiskRecord {
|
||||
item: string;
|
||||
scene: string;
|
||||
l: number | null;
|
||||
s: number | null;
|
||||
existing: string;
|
||||
suggest: string;
|
||||
residual_l: number | null;
|
||||
residual_s: number | null;
|
||||
}
|
||||
|
||||
export const aiRiskOrganize = (content: string, rows: CompareRow[], toolResults?: object) =>
|
||||
aiFetch<{ records: RiskRecord[] }>('/open/v1/moc/risk/organize', {
|
||||
request_id: nextRequestId('org'),
|
||||
content,
|
||||
rows,
|
||||
tool_results: toolResults ?? {},
|
||||
matrix_payload: DEFAULT_MATRIX
|
||||
});
|
||||
|
||||
/* ---------- 风险分析插件(同步,仅出 L/S) ---------- */
|
||||
|
||||
export interface HazopRow {
|
||||
cause: string;
|
||||
consequence: string;
|
||||
l: number | null;
|
||||
s: number | null;
|
||||
safeguards: string;
|
||||
suggestions: string;
|
||||
}
|
||||
|
||||
export const aiHazopDeviation = (payload: {
|
||||
node_name: string;
|
||||
deviation: string;
|
||||
object_name?: string;
|
||||
change_context?: object;
|
||||
}) =>
|
||||
aiFetch<{ rows: HazopRow[] }>('/open/v1/moc/hazop/deviation-analyze', {
|
||||
request_id: nextRequestId('hazop'),
|
||||
matrix_payload: DEFAULT_MATRIX,
|
||||
...payload
|
||||
});
|
||||
|
||||
export interface JsaStep {
|
||||
step: string;
|
||||
desc: string;
|
||||
rows: { hazard: string; consequence: string; l: number | null; s: number | null; safeguards: string; suggestions: string }[];
|
||||
}
|
||||
|
||||
export const aiJsaAnalyze = (payload: {
|
||||
job_info: { job_name: string; job_type?: string; job_location?: string };
|
||||
steps?: any[];
|
||||
change_context?: object;
|
||||
mode?: 'supplement' | 'full';
|
||||
}) =>
|
||||
aiFetch<{ steps: JsaStep[] }>('/open/v1/moc/jsa/analyze', {
|
||||
request_id: nextRequestId('jsa'),
|
||||
mode: 'supplement',
|
||||
matrix_payload: DEFAULT_MATRIX,
|
||||
...payload
|
||||
});
|
||||
|
||||
export interface SclGroup {
|
||||
g: string;
|
||||
rows: { item: string; std: string; consequence: string; l: number | null; s: number | null; controls: string; suggestions: string }[];
|
||||
}
|
||||
|
||||
export const aiSclAnalyze = (payload: {
|
||||
device: { name: string; material_info?: string; run_params?: string };
|
||||
category: string;
|
||||
scl_template?: object;
|
||||
change_context?: object;
|
||||
}) =>
|
||||
aiFetch<{ groups: SclGroup[] }>('/open/v1/moc/scl/analyze', {
|
||||
request_id: nextRequestId('scl'),
|
||||
matrix_payload: DEFAULT_MATRIX,
|
||||
...payload
|
||||
});
|
||||
|
||||
/* ---------- 辅助类 ---------- */
|
||||
|
||||
export const aiTraining = (content: string, rows: CompareRow[], riskRecords?: any[]) =>
|
||||
aiFetch<{ training_text: string; suggest_posts: string[] }>('/open/v1/moc/change/training', {
|
||||
request_id: nextRequestId('train'),
|
||||
content,
|
||||
rows,
|
||||
risk_records: riskRecords ?? []
|
||||
});
|
||||
|
||||
export const aiPssr = (payload: { content: string; change_type?: string; risk_records?: any[]; action_items?: any[] }) =>
|
||||
aiFetch<{ groups: { g: string; items: { content: string; phase: string }[] }[] }>('/open/v1/moc/change/pssr', {
|
||||
request_id: nextRequestId('pssr'),
|
||||
...payload
|
||||
});
|
||||
|
||||
export const aiAcceptance = (payload: { purpose?: string; effect?: string }) =>
|
||||
aiFetch<{ rows: { item: string; basis: string; standard: string; method: string; owner_suggest: string }[] }>(
|
||||
'/open/v1/moc/change/acceptance',
|
||||
{ request_id: nextRequestId('acc'), ...payload }
|
||||
);
|
||||
|
||||
export const aiDisposalDraft = (payload: { original_summary: string; disposal_type: string; long_term_scene?: string }) =>
|
||||
aiFetch<{ reason: string; risk: string; long_term_risk_points: string[] }>('/open/v1/moc/disposal/draft', {
|
||||
request_id: nextRequestId('disp'),
|
||||
...payload
|
||||
});
|
||||
|
||||
export const aiGlossary = () => aiFetch<{ terms: { word: string; explanation: string }[] }>('/open/v1/moc/glossary', undefined, 'GET');
|
||||
|
||||
/* ---------- 异步任务 ---------- */
|
||||
|
||||
export interface OpenTask {
|
||||
task_id: string;
|
||||
task_type?: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
current_step?: string;
|
||||
steps?: { step: string; status: string }[];
|
||||
error_msg?: string;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export const aiApprovalAssist = (payload: {
|
||||
snapshot: object;
|
||||
change_id_ref?: string;
|
||||
submit_version?: number;
|
||||
inflight_changes?: any[];
|
||||
manual_filled?: string[];
|
||||
}) =>
|
||||
aiFetch<{ task_id: string; status: string; estimated_seconds: number }>('/open/v1/moc/approval/assist', {
|
||||
request_id: nextRequestId('assist'),
|
||||
matrix_payload: DEFAULT_MATRIX,
|
||||
level_dims: DEFAULT_LEVEL_DIMS,
|
||||
...payload
|
||||
});
|
||||
|
||||
export const aiKnowledgeIngest = (doc: {
|
||||
name: string;
|
||||
file_url?: string;
|
||||
file_base64?: string;
|
||||
version?: string;
|
||||
category?: string;
|
||||
hash?: string;
|
||||
}) =>
|
||||
aiFetch<{ task_id: string; status: string; estimated_seconds: number }>('/open/v1/moc/knowledge/ingest', {
|
||||
request_id: nextRequestId('ingest'),
|
||||
doc
|
||||
});
|
||||
|
||||
export const getTask = (taskId: string) => aiFetch<OpenTask>(`/open/v1/tasks/${taskId}`, undefined, 'GET');
|
||||
|
||||
export const getTaskResult = (taskId: string) => aiFetch<OpenTask>(`/open/v1/tasks/${taskId}/result`, undefined, 'GET');
|
||||
|
||||
/** 轮询任务直至完成/失败 */
|
||||
export async function pollTask(
|
||||
taskId: string,
|
||||
opts: { intervalMs?: number; timeoutMs?: number; onProgress?: (t: OpenTask) => void } = {}
|
||||
): Promise<any> {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 300_000);
|
||||
for (;;) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const t = await getTask(taskId);
|
||||
opts.onProgress?.(t);
|
||||
if (t.status === 'completed') {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const r = await getTaskResult(taskId);
|
||||
return r?.result ?? r;
|
||||
}
|
||||
if (t.status === 'failed') throw new AiError(50202, t.error_msg || 'AI 任务执行失败');
|
||||
if (Date.now() > deadline) throw new AiError(50202, 'AI 任务等待超时,请稍后查看');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康检查(联调验证用) */
|
||||
export const aiHealth = () => aiFetch<{ status: string }>('/open/v1/health', undefined, 'GET');
|
||||
@@ -24,6 +24,16 @@ export function fetchGetUserInfo() {
|
||||
return request<Api.Auth.UserInfo>({ url: '/api/auth/me' });
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePasswordApi(data: { old_password: string, new_password: string, confirm_password: string }) {
|
||||
return request({
|
||||
url: '/api/auth/change-password',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Refresh token
|
||||
*
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { request } from '../request';
|
||||
|
||||
/** 查询变更未保存数据
|
||||
*
|
||||
*/
|
||||
export function queryUnsavedApi() {
|
||||
return request({
|
||||
url: `/api/changes/unsaved`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存
|
||||
export function saveChangeApi(id: number, params: any) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/form`,
|
||||
method: 'put',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 提交
|
||||
export function submitChangeApi(id: number, params: any) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/form/submit`,
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 获取详情
|
||||
export function getChangeDetailApi(id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 使用ai分析-步骤确认/取消
|
||||
export function useAiAnalysisApi(id: number, stepId: number, params: {action: string}) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/steps/${stepId}/confirm`,
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 催办
|
||||
export function urgeApi(id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/urge`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 列表查询
|
||||
export function changeLedgerListApi(params:any) {
|
||||
return request({
|
||||
url: `/api/changes`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 导出
|
||||
export function exportChangeLedgerApi(params:any) {
|
||||
return request({
|
||||
url: `/api/reports/changes/export`,
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob', // 重点,responseType设置为 blob
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 资料库树状列表
|
||||
export function docTreeListApi() {
|
||||
return request({
|
||||
url: `/api/knowledge/tree`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 新增文件夹
|
||||
export function addFolderApi(data: {name: string, parent_id: number, org_id: number}) {
|
||||
return request({
|
||||
url: `/api/knowledge/folder`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 编辑文件夹
|
||||
export function editFolderApi(id: number, data: {name: string}) {
|
||||
return request({
|
||||
url: `/api/knowledge/folder/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 删除文件夹
|
||||
export function deleteFolderApi(id: number) {
|
||||
return request({
|
||||
url: `/api/knowledge/folder/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 资料列表
|
||||
export function docListApi(params:{folder_id:number,name?:string}) {
|
||||
return request({
|
||||
url: `/api/knowledge/files`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 上传资料
|
||||
export function uploadDocApi(params: any) {
|
||||
return request({
|
||||
url: `/api/knowledge/upload`,
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
// 更新资料
|
||||
export function updateDocApi(id: number, params: any) {
|
||||
return request({
|
||||
url: `/api/knowledge/${id}/update`,
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 历史版本列表
|
||||
export function docHistoryListApi(doc_id:number) {
|
||||
return request({
|
||||
url: `/api/knowledge/${doc_id}/history`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 归档目录树
|
||||
export function docArchiveTreeListApi(params: {from:string | number | null,to:string | number | null,org_id: number,type: number | string,change_no?:string}) {
|
||||
return request({
|
||||
url: `/api/changes/archived/tree`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 归档详情
|
||||
export function docArchiveDetailsApi(id: number) {
|
||||
return request({
|
||||
url: `/api/changes/archived/${id}/attachments`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { request } from '../request';
|
||||
|
||||
/** 草稿列表
|
||||
*
|
||||
*/
|
||||
export function draftListApi(params: {page_num: number, page_size: number}) {
|
||||
return request({
|
||||
url: `/api/changes/my-drafts`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
// 删除草稿
|
||||
export function deleteDraftApi(id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 上传文件
|
||||
export function uploadFileApi(id: number, params: any) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/attachments`,
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
export function deleteFileApi(id: number, fileId: number) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/attachments/${fileId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 文件列表
|
||||
export function fileListApi(change_id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/attachments`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 列表查询
|
||||
export function highRiskListApi(params:any) {
|
||||
return request({
|
||||
url: `/api/risk-records/high`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
// 变更分析记录列表
|
||||
export function analysisRecordListApi(id:number) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/analysis-records`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 历史分析检索
|
||||
export function historyAnalysisListApi(params:{kw:string,method:string}) {
|
||||
return request({
|
||||
url: `/api/analysis-records/search`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './auth';
|
||||
export * from './route';
|
||||
export * from './system-manage';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 获取列表
|
||||
export function myTasksListApi() {
|
||||
return request({
|
||||
url: `/api/my-tasks`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 撤回
|
||||
export function myTasksWithdrawApi(id:number,data: {reason:string}) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/withdraw`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 关闭变更
|
||||
export function myTasksCloseApi(change_id:number) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/close`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
// 资料关闭
|
||||
export function myTasksCloseMaterialApi(change_id:number,data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/material-close`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 处置申请保存提交
|
||||
export function myTasksDisposalSubmitApi(change_id:number,data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/disposal`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 新增检查项
|
||||
export function myTasksAddItemApi(change_id:number,data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/pssr/item`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 驳回变更
|
||||
export function rejectApi(data: {change_id:number,opinion:string}) {
|
||||
return request({
|
||||
url: `/api/approvals/reject`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 同意变更
|
||||
export function agreeApi(data: {change_id:number,opinion:string}) {
|
||||
return request({
|
||||
url: `/api/approvals/agree`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 待投用变更列表查询
|
||||
export function pendingChangeListApi(params:{page_num:number,page_size:number}) {
|
||||
return request({
|
||||
url: `/api/pssr/pending-use`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 变更投入和效果列表查询
|
||||
export function effectListApi(params:{page_num:number,page_size:number}) {
|
||||
return request({
|
||||
url: `/api/reports/invest-effect`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 变更投入和效果导出
|
||||
export function exportEffectApi() {
|
||||
return request({
|
||||
url: `/api/reports/invest-effect/export`,
|
||||
method: 'get',
|
||||
responseType: 'blob', // 重点,responseType设置为 blob
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 获取风险预分析详情
|
||||
export function riskPreDetailApi(change_id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/risk-summary`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 风险预分析保存
|
||||
export function riskPreSaveApi(change_id: number, data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/risk-summary`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取HAZOP节点列表
|
||||
export function hazopNodesApi(id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${id}/hazop/nodes`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// HAZOP保存
|
||||
export function hazopSaveApi(change_id: number, data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/hazop`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取FMEA节点列表
|
||||
export function fmeaNodesApi(change_id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/fmea/nodes`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// FMEA保存
|
||||
export function fmeaSaveApi(change_id: number, data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/fmea`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取JSA详情
|
||||
export function jsaDetailApi(change_id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/jsa`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// JSA保存
|
||||
export function jsaSaveApi(change_id: number, data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/jsa`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取风险检查表详情
|
||||
export function riskCheckDetailApi(change_id: number) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/questionnaire`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 风险检查表保存
|
||||
export function riskCheckSaveApi(change_id: number, data: any) {
|
||||
return request({
|
||||
url: `/api/changes/${change_id}/questionnaire`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 列表
|
||||
export function pssrListApi(params: {page_num: number, page_size: number,kw: string,is_close: number,from: string | null,to: string | null}) {
|
||||
return request({
|
||||
url: `/api/pssr/supervise`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 数据统计
|
||||
import { request } from '../request';
|
||||
|
||||
// 首页(申请人)
|
||||
export function homeApi() {
|
||||
return request({
|
||||
url: `/api/workbench`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 首页(专业会签)
|
||||
export function homeProfessionalApi() {
|
||||
return request({
|
||||
url: `/api/workbench/specialty-sign`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 首页(安全工程师)
|
||||
export function homeSafeApi() {
|
||||
return request({
|
||||
url: `/api/workbench/safety`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 首页(车间主任)
|
||||
export function homeManagerApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/workbench/dept`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
// 首页(分管领导)
|
||||
export function homeLeaderApi() {
|
||||
return request({
|
||||
url: `/api/workbench/vp`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 首页(系统管理员)
|
||||
export function homeAdminApi() {
|
||||
return request({
|
||||
url: `/api/workbench/admin`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 首页(质量工程师)
|
||||
export function homeQualityApi() {
|
||||
return request({
|
||||
url: `/api/workbench/quality`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 首页(非审批人员)
|
||||
export function homeNonApproverApi() {
|
||||
return request({
|
||||
url: `/api/workbench/viewer`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 变更数据中心
|
||||
export function changeDataCenterApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/data-center`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 变更数据中心导出
|
||||
export function exportChangeDataCenterApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/data-center/export`,
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob', // 重点,responseType设置为 blob
|
||||
});
|
||||
}
|
||||
|
||||
// 安全与风险统计
|
||||
export function safeRiskStatisticsApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/safety-risk`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 本部门变更统计
|
||||
export function departmentStatisticsApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/dept-stats`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 本专业变更统计(质量会签统计)
|
||||
export function specialtyStatisticsApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/dept-stats/specialty-sign`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 本车间变更统计
|
||||
export function deptStatisticsApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/dept-stats/dept`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 全工厂统计概览
|
||||
export function factoryStatisticsApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/plant-overview`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 统计报表
|
||||
export function reportStatisticsApi(params: {from: string | null, to: string | null}) {
|
||||
return request({
|
||||
url: `/api/reports/admin-center`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { request } from '../request';
|
||||
|
||||
/** get role list */
|
||||
export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) {
|
||||
return request<Api.SystemManage.RoleList>({
|
||||
url: '/systemManage/getRoleList',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* get all roles
|
||||
*
|
||||
* these roles are all enabled
|
||||
*/
|
||||
export function fetchGetAllRoles() {
|
||||
return request<Api.SystemManage.AllRole[]>({
|
||||
url: '/systemManage/getAllRoles',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** get user list */
|
||||
export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) {
|
||||
return request<Api.SystemManage.UserList>({
|
||||
url: '/systemManage/getUserList',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/** get menu list */
|
||||
export function fetchGetMenuList() {
|
||||
return request<Api.SystemManage.MenuList>({
|
||||
url: '/systemManage/getMenuList/v2',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** get all pages */
|
||||
export function fetchGetAllPages() {
|
||||
return request<string[]>({
|
||||
url: '/systemManage/getAllPages',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** get menu tree */
|
||||
export function fetchGetMenuTree() {
|
||||
return request<Api.SystemManage.MenuTree[]>({
|
||||
url: '/systemManage/getMenuTree',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 获取配置
|
||||
export function systemConfigApi() {
|
||||
return request({
|
||||
url: '/api/system-config',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 添加配置项
|
||||
export function addSystemConfigApi(data:{group_key:string, label:string,color:string,remark?:string,value:string}) {
|
||||
return request({
|
||||
url: '/api/system-config',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 编辑配置项
|
||||
export function editSystemConfigApi(id:number, data:any) {
|
||||
return request({
|
||||
url: `/api/system-config/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 批量修改ai配置项和变更编号规则
|
||||
export function updateConfigApi(data:{items:any[]}) {
|
||||
return request({
|
||||
url: '/api/system-config/batch',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 删除配置项
|
||||
export function deleteSystemConfigApi(id:number) {
|
||||
return request({
|
||||
url: `/api/system-config/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 审批模板列表
|
||||
export function templateListApi() {
|
||||
return request({
|
||||
url: '/api/approval-templates',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 审批用户列表
|
||||
export function peopleListApi() {
|
||||
return request({
|
||||
url: '/api/approval-flow/users',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 新增审批模板
|
||||
export function addTemplateApi(data:any) {
|
||||
return request({
|
||||
url: '/api/approval-templates',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 编辑审批模板
|
||||
export function editTemplateApi(id:number,data:any) {
|
||||
return request({
|
||||
url: `/api/approval-templates/${id}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 删除审批模板
|
||||
export function deleteTemplateApi(id:number) {
|
||||
return request({
|
||||
url: `/api/approval-templates/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取审批流程(按三键匹配模板)
|
||||
export function getFlowApi(data:{category:number | null,level:number | null,duration:number | null}) {
|
||||
return request({
|
||||
url: '/api/approval-templates/match',
|
||||
method: 'get',
|
||||
params: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取风险矩阵配置
|
||||
export function matrixConfigApi() {
|
||||
return request({
|
||||
url: `/api/matrix`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 保存风险矩阵配置(HAZOP)
|
||||
export function saveMatrixConfigApi(bizType:string,data:any) {
|
||||
return request({
|
||||
url: `/api/risk-matrix/${bizType}`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
// 保存风险矩阵配置(FMEA)
|
||||
export function saveFmeaMatrixConfigApi(data:any) {
|
||||
return request({
|
||||
url: `/api/fmea-matrix`,
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { request } from '../request';
|
||||
|
||||
// 组织列表
|
||||
export function orgListApi() {
|
||||
return request({
|
||||
url: '/api/organization/tree',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
// 新增组织
|
||||
export function addOrgApi(params: { label: string, parent_id: number, sort: number, is_bottom: number, org_code: string }) {
|
||||
return request({
|
||||
url: '/api/organization',
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
// 编辑组织
|
||||
export function editOrgApi(id: number, params: { label: string, parent_id: number, sort: number, is_bottom: number, org_code: string }) {
|
||||
return request({
|
||||
url: `/api/organization/${id}`,
|
||||
method: 'put',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
// 删除组织
|
||||
export function deleteOrgApi(id: number) {
|
||||
return request({
|
||||
url: `/api/organization/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
export function userListApi(params: {org_id: number, keyword: string, page: number, limit: number }) {
|
||||
return request({
|
||||
url: '/api/user',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
// 新增用户
|
||||
export function addUserApi(params: { real_name: string, username: string, gender: number, mobile?: string, email?: string, dept_ids: number[], role_id: number | null, org_ids: number[], enabled: boolean }) {
|
||||
return request({
|
||||
url: '/api/user',
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
export function editUserApi(id: number, params: { real_name: string, username: string, gender: number, mobile?: string, email?: string, dept_ids: number[], role_id: number | null, org_ids: number[], enabled: boolean }) {
|
||||
return request({
|
||||
url: `/api/user/${id}`,
|
||||
method: 'put',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export function deleteUserApi(params: { id: number, type: number }) {
|
||||
return request({
|
||||
url: '/api/user/operation',
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
// 重置数据权限
|
||||
export function resetDataApi() {
|
||||
return request({
|
||||
url: `/api/user/reset-data-permission`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
// 下载用户导入模板
|
||||
export function downloadTemplateApi() {
|
||||
return request({
|
||||
url: `/api/user/template`,
|
||||
method: 'get',
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
// 导入用户
|
||||
export function importUserApi(params: FormData) {
|
||||
return request({
|
||||
url: `/api/user/import`,
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
// 重置用户密码
|
||||
export function resetPasswordApi(id: number) {
|
||||
return request({
|
||||
url: `/api/user/${id}/reset-password`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import { BACKEND_ERROR_CODE, createFlatRequest } from '@sa/axios';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { getServiceAiURL } from '@/utils/service';
|
||||
import { $t } from '@/locales';
|
||||
import { getAiAuthorization, handleExpiredRequest, showErrorMsg } from './shared';
|
||||
import type { RequestInstanceState } from './type';
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceAiURL(import.meta.env, isHttpProxy);
|
||||
|
||||
export const request = createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
headers: {}
|
||||
},
|
||||
{
|
||||
defaultState: {
|
||||
errMsgStack: [],
|
||||
refreshTokenPromise: null
|
||||
} as RequestInstanceState,
|
||||
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
||||
return response.data.data;
|
||||
},
|
||||
async onRequest(config) {
|
||||
const Authorization = getAiAuthorization();
|
||||
Object.assign(config.headers, { Authorization });
|
||||
|
||||
return config;
|
||||
},
|
||||
isBackendSuccess(response) {
|
||||
// when the backend response code is "0000"(default), it means the request is success
|
||||
// to change this logic by yourself, you can modify the `VITE_SERVICE_SUCCESS_CODE` in `.env` file
|
||||
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
|
||||
},
|
||||
async onBackendFail(response, instance) {
|
||||
const authStore = useAuthStore();
|
||||
const responseCode = String(response.data.code);
|
||||
|
||||
function handleLogout() {
|
||||
authStore.resetStore();
|
||||
}
|
||||
|
||||
function logoutAndCleanup() {
|
||||
handleLogout();
|
||||
window.removeEventListener('beforeunload', handleLogout);
|
||||
|
||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
|
||||
}
|
||||
|
||||
// when the backend response code is in `logoutCodes`, it means the user will be logged out and redirected to login page
|
||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
|
||||
if (logoutCodes.includes(responseCode)) {
|
||||
handleLogout();
|
||||
return null;
|
||||
}
|
||||
|
||||
// when the backend response code is in `modalLogoutCodes`, it means the user will be logged out by displaying a modal
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
||||
|
||||
// prevent the user from refreshing the page
|
||||
window.addEventListener('beforeunload', handleLogout);
|
||||
|
||||
window.$dialog?.error({
|
||||
title: $t('common.error'),
|
||||
content: response.data.msg,
|
||||
positiveText: $t('common.confirm'),
|
||||
maskClosable: false,
|
||||
closeOnEsc: false,
|
||||
onPositiveClick() {
|
||||
logoutAndCleanup();
|
||||
},
|
||||
onClose() {
|
||||
logoutAndCleanup();
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// when the backend response code is in `expiredTokenCodes`, it means the token is expired, and refresh token
|
||||
// the api `refreshToken` can not return error code in `expiredTokenCodes`, otherwise it will be a dead loop, should return `logoutCodes` or `modalLogoutCodes`
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
||||
if (expiredTokenCodes.includes(responseCode)) {
|
||||
const success = await handleExpiredRequest(request.state);
|
||||
if (success) {
|
||||
const Authorization = getAuthorization();
|
||||
Object.assign(response.config.headers, { Authorization });
|
||||
|
||||
return instance.request(response.config) as Promise<AxiosResponse>;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
onError(error) {
|
||||
// when the request is fail, you can show error message
|
||||
let message = error.message;
|
||||
let backendErrorCode = '';
|
||||
|
||||
// get backend error message and code
|
||||
if (error.code === BACKEND_ERROR_CODE) {
|
||||
message = error.response?.data?.message || message;
|
||||
backendErrorCode = String(error.response?.data?.code || '');
|
||||
}
|
||||
|
||||
// the error message is displayed in the modal
|
||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
||||
if (modalLogoutCodes.includes(backendErrorCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the token is expired, refresh token and retry request, so no need to show error message
|
||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
||||
if (expiredTokenCodes.includes(backendErrorCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorMsg(request.state, message);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -12,9 +12,7 @@ const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
export const request = createFlatRequest(
|
||||
{
|
||||
baseURL,
|
||||
headers: {
|
||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
||||
}
|
||||
headers: {}
|
||||
},
|
||||
{
|
||||
defaultState: {
|
||||
|
||||
@@ -3,12 +3,20 @@ import { localStg } from '@/utils/storage';
|
||||
import { fetchRefreshToken } from '../api';
|
||||
import type { RequestInstanceState } from './type';
|
||||
|
||||
// 获取token
|
||||
export function getAuthorization() {
|
||||
const token = localStg.get('token');
|
||||
const Authorization = token ? `Bearer ${token}` : null;
|
||||
|
||||
return Authorization;
|
||||
}
|
||||
// 获取ai token
|
||||
export function getAiAuthorization() {
|
||||
const token = localStg.get('aiToken');
|
||||
const Authorization = token ? `Bearer ${token}` : null;
|
||||
|
||||
return Authorization;
|
||||
}
|
||||
|
||||
/** refresh token */
|
||||
async function handleRefreshToken() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { fetchGetUserInfo, fetchLogin } from '@/service/api';
|
||||
import { setAiUser } from '@/service/api/ai';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
@@ -21,7 +22,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const token = ref('');
|
||||
|
||||
const userInfo: Api.Auth.UserInfo = reactive({
|
||||
userId: '',
|
||||
user_id: 0,
|
||||
username: '',
|
||||
roles: [],
|
||||
buttons: []
|
||||
@@ -37,6 +38,12 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
/** Is login */
|
||||
const isLogin = computed(() => Boolean(token.value));
|
||||
|
||||
function logout() {
|
||||
clearAuthStorage();
|
||||
localStg.clear();
|
||||
toLogin();
|
||||
}
|
||||
|
||||
/** Reset auth store */
|
||||
async function resetStore() {
|
||||
recordUserId();
|
||||
@@ -55,12 +62,12 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
|
||||
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
|
||||
function recordUserId() {
|
||||
if (!userInfo.userId) {
|
||||
if (!userInfo.user_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store current user ID locally for next login comparison
|
||||
localStg.set('lastLoginUserId', userInfo.userId);
|
||||
localStg.set('lastLoginUserId', String(userInfo.user_id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,14 +76,14 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
* @returns {boolean} Whether to clear all tabs
|
||||
*/
|
||||
function checkTabClear(): boolean {
|
||||
if (!userInfo.userId) {
|
||||
if (!userInfo.user_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastLoginUserId = localStg.get('lastLoginUserId');
|
||||
|
||||
// Clear all tabs if current user is different from previous user
|
||||
if (!lastLoginUserId || lastLoginUserId !== userInfo.userId) {
|
||||
if (!lastLoginUserId || lastLoginUserId !== String(userInfo.user_id)) {
|
||||
localStg.remove('globalTabs');
|
||||
tabStore.clearTabs();
|
||||
|
||||
@@ -130,7 +137,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
async function loginByToken(loginToken: Api.Auth.LoginToken) {
|
||||
// 1. stored in the localStorage, the later requests need it in headers
|
||||
localStg.set('token', loginToken.token);
|
||||
localStg.set('refreshToken', loginToken.refreshToken);
|
||||
localStg.set('aiToken', loginToken.ai_token);
|
||||
|
||||
// 2. get user info
|
||||
const pass = await getUserInfo();
|
||||
@@ -148,9 +155,17 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const { data: info, error } = await fetchGetUserInfo();
|
||||
|
||||
if (!error) {
|
||||
localStg.set('userInfo', info);
|
||||
// update store
|
||||
Object.assign(userInfo, info);
|
||||
|
||||
// 同步 AI 插件用户身份(exchange 换 ai_token 用)
|
||||
setAiUser({
|
||||
platform_user_id: String(info.user_id),
|
||||
align_key: info.username,
|
||||
display_name: info.username
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -178,6 +193,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
loginLoading,
|
||||
resetStore,
|
||||
login,
|
||||
initUserInfo
|
||||
initUserInfo,
|
||||
logout
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,5 +8,8 @@ export function getToken() {
|
||||
/** Clear auth storage */
|
||||
export function clearAuthStorage() {
|
||||
localStg.remove('token');
|
||||
localStg.remove('aiToken');
|
||||
localStg.remove('userInfo');
|
||||
localStg.remove('globalTabs');
|
||||
localStg.remove('refreshToken');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ROOT_ROUTE } from '@/router/routes/builtin';
|
||||
import { getRouteName, getRoutePath } from '@/router/elegant/transform';
|
||||
import { useAuthStore } from '../auth';
|
||||
import { useTabStore } from '../tab';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import {
|
||||
filterAuthRoutesByRoles,
|
||||
getBreadcrumbsByRoute,
|
||||
@@ -177,7 +178,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
/** Init auth route */
|
||||
async function initAuthRoute() {
|
||||
// check if user info is initialized
|
||||
if (!authStore.userInfo.userId) {
|
||||
if (!authStore.userInfo.user_id) {
|
||||
await authStore.initUserInfo();
|
||||
}
|
||||
|
||||
@@ -190,14 +191,65 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
tabStore.initHomeTab();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据允许的路径列表,过滤路由树
|
||||
* @param {Array} routes - 路由对象数组(可嵌套 children)
|
||||
* @param {Array} allowedPaths - 允许的路径字符串数组
|
||||
* @param {string} parentPath - 父级路径(用于拼接相对路径,本例中所有路径都是绝对路径,可忽略)
|
||||
* @returns {Array} 过滤后的路由数组
|
||||
*/
|
||||
function filterRoutesByPaths(routes: ElegantConstRoute[], allowedPaths: string[], parentPath = ''):any {
|
||||
const result = [];
|
||||
const pathSet = new Set(allowedPaths);
|
||||
|
||||
for (const route of routes) {
|
||||
// 计算完整路径(如果路径以 '/' 开头则为绝对路径,否则拼接父路径)
|
||||
const fullPath = route.path.startsWith('/')
|
||||
? route.path
|
||||
: `${parentPath}/${route.path}`.replace(/\/+/g, '/');
|
||||
|
||||
// 检查当前路由是否在允许列表中
|
||||
const isAllowed = pathSet.has(fullPath);
|
||||
|
||||
// 递归处理子路由
|
||||
let filteredChildren = [];
|
||||
if (route.children && route.children.length) {
|
||||
filteredChildren = filterRoutesByPaths(route.children, allowedPaths, fullPath);
|
||||
}
|
||||
|
||||
// 只要当前路由匹配,或有子路由匹配,则保留该路由
|
||||
if (isAllowed || filteredChildren.length > 0) {
|
||||
result.push({
|
||||
...route,
|
||||
path: fullPath, // 使用完整路径(可确保路径一致性)
|
||||
children: filteredChildren.length ? filteredChildren : route.children
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
/** Init static auth route */
|
||||
function initStaticAuthRoute() {
|
||||
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
|
||||
// 全部静态路由
|
||||
let allowRouter = staticAuthRoutes
|
||||
// 权限
|
||||
let perms = localStg.get('userInfo')?.perms || []
|
||||
if(perms){
|
||||
if(perms.length>0 && perms[0]!=='*'){
|
||||
const filteredRoutes = filterRoutesByPaths(staticAuthRoutes, perms);
|
||||
if (filteredRoutes.length) {
|
||||
allowRouter = filteredRoutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (authStore.isStaticSuper) {
|
||||
addAuthRoutes(staticAuthRoutes);
|
||||
addAuthRoutes(allowRouter);
|
||||
} else {
|
||||
const filteredAuthRoutes = filterAuthRoutesByRoles(staticAuthRoutes, authStore.userInfo.roles);
|
||||
const filteredAuthRoutes = filterAuthRoutesByRoles(allowRouter, authStore.userInfo.roles);
|
||||
|
||||
addAuthRoutes(filteredAuthRoutes);
|
||||
}
|
||||
@@ -318,6 +370,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
||||
}
|
||||
|
||||
async function onRouteSwitchWhenLoggedIn() {
|
||||
initAuthRoute();
|
||||
// some global init logic when logged in and switch route
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
||||
colourWeakness: false,
|
||||
recommendColor: false,
|
||||
themeColor: '#2080f0',
|
||||
themeRadius: 6,
|
||||
themeRadius: 4,
|
||||
otherColor: {
|
||||
info: '#2080f0',
|
||||
success: '#52c41a',
|
||||
|
||||
Vendored
+2
-1
@@ -6,12 +6,13 @@ declare namespace Api {
|
||||
*/
|
||||
namespace Auth {
|
||||
interface LoginToken {
|
||||
ai_token: string;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
user_id: number;
|
||||
username: string;
|
||||
roles: string[];
|
||||
buttons: string[];
|
||||
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
declare namespace Api {
|
||||
/**
|
||||
* namespace Change
|
||||
*
|
||||
* backend api module: "change"
|
||||
*/
|
||||
namespace Change {
|
||||
interface MemberInfo {
|
||||
id?: number;
|
||||
is_sign?: number;
|
||||
org_id: number;
|
||||
org_name: string;
|
||||
specialty?: string;
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
}
|
||||
interface StepInfo {
|
||||
id: number;
|
||||
countersign: boolean;
|
||||
name: string;
|
||||
step_order: number;
|
||||
members: MemberInfo[];
|
||||
}
|
||||
interface TemplateInfo {
|
||||
id: number;
|
||||
category: number;
|
||||
created_at?: string;
|
||||
duration: number;
|
||||
level: number;
|
||||
steps: StepInfo[];
|
||||
updated_at?: string;
|
||||
}
|
||||
interface FlowInfo {
|
||||
stepId: number;
|
||||
countersign: boolean;
|
||||
name: string;
|
||||
selectedMember: MemberInfo[];
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+22
-2
@@ -23,6 +23,7 @@ declare module 'vue' {
|
||||
GithubLink: typeof import('./../components/custom/github-link.vue')['default']
|
||||
IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||
IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||
'IconBi:bell': typeof import('~icons/bi/bell')['default']
|
||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
@@ -30,6 +31,7 @@ declare module 'vue' {
|
||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||
IconUilBell: typeof import('~icons/uil/bell')['default']
|
||||
IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
@@ -42,6 +44,8 @@ declare module 'vue' {
|
||||
NCascader: typeof import('naive-ui')['NCascader']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
@@ -56,16 +60,20 @@ declare module 'vue' {
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGi: typeof import('naive-ui')['NGi']
|
||||
NGrid: typeof import('naive-ui')['NGrid']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||
NMenu: typeof import('naive-ui')['NMenu']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NPopover: typeof import('naive-ui')['NPopover']
|
||||
NPopselect: typeof import('naive-ui')['NPopselect']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NRadio: typeof import('naive-ui')['NRadio']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
@@ -82,8 +90,10 @@ declare module 'vue' {
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
NTree: typeof import('naive-ui')['NTree']
|
||||
NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
NWatermark: typeof import('naive-ui')['NWatermark']
|
||||
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||
PreviewFile: typeof import('./../components/previewFile/index.vue')['default']
|
||||
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
@@ -111,6 +121,7 @@ declare global {
|
||||
const GithubLink: typeof import('./../components/custom/github-link.vue')['default']
|
||||
const IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default']
|
||||
const IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default']
|
||||
const 'IconBi:bell': typeof import('~icons/bi/bell')['default']
|
||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
const IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||
@@ -118,6 +129,7 @@ declare global {
|
||||
const IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||
const IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||
const IconTooltip: typeof import('./../components/common/icon-tooltip.vue')['default']
|
||||
const IconUilBell: typeof import('~icons/uil/bell')['default']
|
||||
const IconUilSearch: typeof import('~icons/uil/search')['default']
|
||||
const LangSwitch: typeof import('./../components/common/lang-switch.vue')['default']
|
||||
const LookForward: typeof import('./../components/custom/look-forward.vue')['default']
|
||||
@@ -130,6 +142,8 @@ declare global {
|
||||
const NCascader: typeof import('naive-ui')['NCascader']
|
||||
const NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
const NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
const NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
const NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
const NColorPicker: typeof import('naive-ui')['NColorPicker']
|
||||
const NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
const NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
@@ -144,16 +158,20 @@ declare global {
|
||||
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
const NGi: typeof import('naive-ui')['NGi']
|
||||
const NGrid: typeof import('naive-ui')['NGrid']
|
||||
const NIcon: typeof import('naive-ui')['NIcon']
|
||||
const NImage: typeof import('naive-ui')['NImage']
|
||||
const NInput: typeof import('naive-ui')['NInput']
|
||||
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||
const NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||
const NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
|
||||
const NMenu: typeof import('naive-ui')['NMenu']
|
||||
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
const NModal: typeof import('naive-ui')['NModal']
|
||||
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
const NPagination: typeof import('naive-ui')['NPagination']
|
||||
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
const NPopover: typeof import('naive-ui')['NPopover']
|
||||
const NPopselect: typeof import('naive-ui')['NPopselect']
|
||||
const NProgress: typeof import('naive-ui')['NProgress']
|
||||
const NRadio: typeof import('naive-ui')['NRadio']
|
||||
const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
@@ -170,8 +188,10 @@ declare global {
|
||||
const NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
const NTree: typeof import('naive-ui')['NTree']
|
||||
const NTreeSelect: typeof import('naive-ui')['NTreeSelect']
|
||||
const NUpload: typeof import('naive-ui')['NUpload']
|
||||
const NWatermark: typeof import('naive-ui')['NWatermark']
|
||||
const PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
|
||||
const PreviewFile: typeof import('./../components/previewFile/index.vue')['default']
|
||||
const ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
|
||||
Vendored
+20
-14
@@ -21,9 +21,13 @@ declare module "@elegant-router/types" {
|
||||
"404": "/404";
|
||||
"500": "/500";
|
||||
"changeledger": "/changeledger";
|
||||
"changeledger_mychanges": "/changeledger/mychanges";
|
||||
"changeledger_workshopchanges": "/changeledger/workshopchanges";
|
||||
"changepassword": "/changepassword";
|
||||
"changestatistics": "/changestatistics";
|
||||
"details": "/details";
|
||||
"details_approval": "/details/approval";
|
||||
"details_closedoc": "/details/closedoc";
|
||||
"details_disposal": "/details/disposal";
|
||||
"details_index": "/details/index";
|
||||
"docs": "/docs";
|
||||
"docs_archives": "/docs/archives";
|
||||
"docs_query": "/docs/query";
|
||||
@@ -31,19 +35,15 @@ declare module "@elegant-router/types" {
|
||||
"home": "/home";
|
||||
"login": "/login/:module(pwd-login)?";
|
||||
"my": "/my";
|
||||
"my_closeconfirm": "/my/closeconfirm";
|
||||
"my_datachangecenter": "/my/datachangecenter";
|
||||
"my_effect": "/my/effect";
|
||||
"my_highrisk": "/my/highrisk";
|
||||
"my_highriskdetail": "/my/highriskdetail";
|
||||
"my_initiatechange": "/my/initiatechange";
|
||||
"my_mydraft": "/my/mydraft";
|
||||
"my_mytask": "/my/mytask";
|
||||
"my_pending": "/my/pending";
|
||||
"my_pendingchange": "/my/pendingchange";
|
||||
"my_process": "/my/process";
|
||||
"my_processdetail": "/my/processdetail";
|
||||
"my_pssr": "/my/pssr";
|
||||
"professionalstatistics": "/professionalstatistics";
|
||||
"report": "/report";
|
||||
"riskstatistics": "/riskstatistics";
|
||||
"signstatistics": "/signstatistics";
|
||||
@@ -52,6 +52,7 @@ declare module "@elegant-router/types" {
|
||||
"systemmanage_setting": "/systemmanage/setting";
|
||||
"systemmanage_template": "/systemmanage/template";
|
||||
"systemmanage_user": "/systemmanage/user";
|
||||
"workshopstatistics": "/workshopstatistics";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -87,16 +88,20 @@ declare module "@elegant-router/types" {
|
||||
| "404"
|
||||
| "500"
|
||||
| "changeledger"
|
||||
| "changepassword"
|
||||
| "changestatistics"
|
||||
| "details"
|
||||
| "docs"
|
||||
| "factoryoverview"
|
||||
| "home"
|
||||
| "login"
|
||||
| "my"
|
||||
| "professionalstatistics"
|
||||
| "report"
|
||||
| "riskstatistics"
|
||||
| "signstatistics"
|
||||
| "systemmanage"
|
||||
| "workshopstatistics"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -116,27 +121,27 @@ declare module "@elegant-router/types" {
|
||||
| "403"
|
||||
| "404"
|
||||
| "500"
|
||||
| "changeledger_mychanges"
|
||||
| "changeledger_workshopchanges"
|
||||
| "changeledger"
|
||||
| "changepassword"
|
||||
| "changestatistics"
|
||||
| "details_approval"
|
||||
| "details_closedoc"
|
||||
| "details_disposal"
|
||||
| "details_index"
|
||||
| "docs_archives"
|
||||
| "docs_query"
|
||||
| "factoryoverview"
|
||||
| "home"
|
||||
| "login"
|
||||
| "my_closeconfirm"
|
||||
| "my_datachangecenter"
|
||||
| "my_effect"
|
||||
| "my_highrisk"
|
||||
| "my_highriskdetail"
|
||||
| "my_initiatechange"
|
||||
| "my_mydraft"
|
||||
| "my_mytask"
|
||||
| "my_pending"
|
||||
| "my_pendingchange"
|
||||
| "my_process"
|
||||
| "my_processdetail"
|
||||
| "my_pssr"
|
||||
| "professionalstatistics"
|
||||
| "report"
|
||||
| "riskstatistics"
|
||||
| "signstatistics"
|
||||
@@ -144,6 +149,7 @@ declare module "@elegant-router/types" {
|
||||
| "systemmanage_setting"
|
||||
| "systemmanage_template"
|
||||
| "systemmanage_user"
|
||||
| "workshopstatistics"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
// ===================== 数据模型与 Mock 数据 =====================
|
||||
|
||||
export type RoleId = "applicant" | "process" | "process2" | "safety" | "quality" | "dept" | "vp" | "admin" | "viewer";
|
||||
|
||||
export interface Role {
|
||||
id: RoleId;
|
||||
name: string;
|
||||
person: string;
|
||||
org: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export const ROLES: Role[] = [
|
||||
{ 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: "以信息查看为主,不参与变更流程" },
|
||||
];
|
||||
|
||||
export type ChangeType = "工艺" | "设备" | "管理";
|
||||
export type ChangeLevel = "重要" | "一般";
|
||||
export type ChangeStatus =
|
||||
| "审批中"
|
||||
| "已批准"
|
||||
| "实施中"
|
||||
| "待PSSR"
|
||||
| "待验收"
|
||||
| "待关闭"
|
||||
| "已关闭";
|
||||
|
||||
export interface FlowNode {
|
||||
name: string;
|
||||
kind: "审批" | "会签" | "最终批准";
|
||||
roles: RoleId[];
|
||||
members?: { name: string; done: boolean }[];
|
||||
status: "done" | "current" | "todo";
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangeOrder {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ChangeType;
|
||||
level: ChangeLevel;
|
||||
duration: "永久" | "临时";
|
||||
urgent: boolean;
|
||||
status: ChangeStatus;
|
||||
applicant: string;
|
||||
org: string;
|
||||
device: string;
|
||||
date: string;
|
||||
purpose: string;
|
||||
effect: string;
|
||||
before: string;
|
||||
after: string;
|
||||
related: string[];
|
||||
materials: string[];
|
||||
riskTools: string[];
|
||||
flow: FlowNode[];
|
||||
overdueDays?: number;
|
||||
}
|
||||
|
||||
export const FLOW_IMPORTANT: FlowNode[] = [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "process2", "quality"],
|
||||
status: "current",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "周设备(设备)", done: false },
|
||||
{ name: "陈质量(质量)", done: false },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "todo", locked: true },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
|
||||
];
|
||||
|
||||
export const FLOW_SIMPLE: FlowNode[] = [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "current" },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
];
|
||||
|
||||
export const CHANGES: ChangeOrder[] = [
|
||||
{
|
||||
id: "MOC-2026-R01-0035",
|
||||
title: "R-201 反应釜搅拌器电机更换(55kW→75kW 防爆电机)",
|
||||
type: "设备",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "R-201 反应釜 / M-201A 搅拌电机",
|
||||
date: "2026-07-28",
|
||||
purpose: "原 55kW 电机在高粘度工况下频繁过载跳闸,影响反应均匀性与产品质量,需更换为 75kW 防爆电机。",
|
||||
effect: "消除过载跳闸(月均 3 次降至 0),搅拌电流裕量提升至 25%,产品批次合格率预计提升 1.5%。",
|
||||
before: "M-201A 搅拌电机:55kW,ExdⅡBT4,额定电流 102A,原设计工况粘度 ≤3000cP。",
|
||||
after: "更换为 75kW 防爆电机:ExdⅡCT4,额定电流 138A,适配粘度 ≤6000cP;电缆与开关容量同步校核升级。",
|
||||
related: ["更新 P&ID(图号 PID-R201-03)", "修订电气图纸与电缆清册", "校核联锁跳车电流设定", "更新设备台账与备件清单", "操作人员培训"],
|
||||
materials: ["设备数据表", "防爆合格证与材质证明", "电气负荷校核计算书", "更新后的 P&ID", "制造商安装维护手册"],
|
||||
riskTools: ["AI 分析", "JSA", "检查表法"],
|
||||
flow: FLOW_IMPORTANT,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0036",
|
||||
title: "聚合反应温度控制上限调整(78℃→82℃)",
|
||||
type: "工艺",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "孙丽",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "R-101 聚合釜 / TIC-101",
|
||||
date: "2026-07-25",
|
||||
purpose: "提高反应温度上限以缩短聚合时间、提升装置产能,满足新增订单需求。",
|
||||
effect: "单釜反应周期缩短约 40 分钟,年产能预计提升 8%。",
|
||||
before: "聚合反应温度控制范围 65–78℃,TIC-101 高报 78℃、联锁 80℃。",
|
||||
after: "控制范围调整为 65–82℃,TIC-101 高报 82℃、联锁值维持 85℃ 安全边界不变。",
|
||||
related: ["修订工艺卡片与操作规程", "DCS 参数与报警值修改", "更新 HAZOP 分析报告", "岗位人员再培训"],
|
||||
materials: ["工艺计算书", "热平衡与飞温风险分析", "HAZOP 分析报告", "更新后的工艺卡片"],
|
||||
riskTools: ["AI 分析", "HAZOP"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "quality"],
|
||||
status: "done",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "陈质量(质量)", done: true },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "current", locked: true },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R02-0038",
|
||||
title: "DCS 罐区液位报警值 LIA-301 调整",
|
||||
type: "工艺",
|
||||
level: "一般",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "待PSSR",
|
||||
applicant: "吴强",
|
||||
org: "一车间 · 罐区",
|
||||
device: "V-301 储罐 / LIA-301",
|
||||
date: "2026-07-20",
|
||||
purpose: "原高报值偏保守,频繁误报导致操作疲劳,按最新容积表校准报警值。",
|
||||
effect: "误报次数由每周 5 次降至 0,报警响应有效性提升。",
|
||||
before: "LIA-301 高报 80%(对应 7.2m)。",
|
||||
after: "高报调整为 85%(对应 7.65m),联锁值 90% 不变。",
|
||||
related: ["DCS 组态修改", "更新报警台账", "岗位告知培训"],
|
||||
materials: ["储罐容积表", "DCS 组态修改记录", "报警台账"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: FLOW_SIMPLE.map((n) => ({ ...n, status: "done" as const })),
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0039",
|
||||
title: "紧急变更:V-102 安全阀起跳压力临时调整(补办手续)",
|
||||
type: "设备",
|
||||
level: "一般",
|
||||
duration: "临时",
|
||||
urgent: true,
|
||||
status: "待关闭",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "V-102 缓冲罐 / PSV-102",
|
||||
date: "2026-07-15",
|
||||
purpose: "上游工况波动导致安全阀频繁起跳,经紧急批准临时调整起跳压力并限期恢复原设定。",
|
||||
effect: "装置维持连续运行,临时期限内风险受控。",
|
||||
before: "PSV-102 起跳压力 0.8MPa。",
|
||||
after: "临时调整为 0.88MPa(不超过设计压力 1.0MPa 的 90%),期限 30 天,到期恢复 0.8MPa。",
|
||||
related: ["恢复性检查", "更新安全阀台账", "事后风险分析补办"],
|
||||
materials: ["安全阀校验报告", "紧急变更审批单", "事后 JSA 分析"],
|
||||
riskTools: ["AI 分析", "JSA"],
|
||||
flow: FLOW_SIMPLE.map((n) => ({ ...n, status: "done" as const })),
|
||||
overdueDays: 4,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R03-0040",
|
||||
title: "原料环己烷供应商变更(新增 B 供应商)",
|
||||
type: "管理",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "实施中",
|
||||
applicant: "刘芳",
|
||||
org: "供应部",
|
||||
device: "原料罐区 / TK-201",
|
||||
date: "2026-07-18",
|
||||
purpose: "引入第二供应商保障原料供应安全,降低单一来源断供风险。",
|
||||
effect: "供应保障能力提升,采购成本预计下降 3%。",
|
||||
before: "环己烷单一供应商 A,纯度 ≥99.5%。",
|
||||
after: "新增供应商 B(纯度 ≥99.8%,已提供 MSDS 与质检报告),双源供应。",
|
||||
related: ["更新合格供应商名录", "修订进厂检验规程", "更新 MSDS 档案", "卸料操作培训"],
|
||||
materials: ["新供应商资质文件", "产品质检报告与 MSDS", "进厂检验规程"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: FLOW_IMPORTANT.map((n) => ({ ...n, status: "done" as const })),
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0029",
|
||||
title: "操作规程修订(夏季工况)",
|
||||
type: "管理",
|
||||
level: "一般",
|
||||
duration: "临时",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "全装置",
|
||||
date: "2026-06-20",
|
||||
purpose: "夏季高温工况下循环水温度升高,原操作规程冷却参数不适用,需修订夏季操作参数与巡检频次。",
|
||||
effect: "夏季工况下装置运行参数受控,避免超温降负荷,预计减少非计划降量 2 次/年。",
|
||||
before: "操作规程(2025 版)未区分冬夏季工况,冷却参数单一。",
|
||||
after: "增加夏季工况章节:循环水温度 >28℃ 时启用备用冷却塔,巡检频次由 4h 调整为 2h。",
|
||||
related: ["操作规程升版发布", "岗位人员培训"],
|
||||
materials: ["修订后操作规程", "参数核算表"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "current" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "todo", locked: true },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
],
|
||||
overdueDays: 3,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R02-0041",
|
||||
title: "公用工程氮气减压阀组改造",
|
||||
type: "设备",
|
||||
level: "一般",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "周涛",
|
||||
org: "公用工程车间",
|
||||
device: "氮气总管 / PV-208 减压阀组",
|
||||
date: "2026-07-29",
|
||||
purpose: "氮气减压阀组单路运行,检修时需停氮气,影响装置密封气供应,需改造为双路并联。",
|
||||
effect: "减压阀组可在线切换检修,氮气供应连续性提升至 100%。",
|
||||
before: "PV-208 单路减压,无备用回路。",
|
||||
after: "新增并联减压回路 PV-208B,双路一用一备,可在线切换。",
|
||||
related: ["更新 P&ID", "修订氮气系统操作规程", "更新设备台账"],
|
||||
materials: ["阀门数据表", "管道单线图", "施工方案"],
|
||||
riskTools: ["AI 分析", "JSA"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "current" },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
],
|
||||
overdueDays: 3,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R03-0042",
|
||||
title: "三车间自动化和安全隐患整改项目变更",
|
||||
type: "管理",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "刘芳",
|
||||
org: "三车间",
|
||||
device: "三车间装置区",
|
||||
date: "2026-07-31",
|
||||
purpose: "落实安全隐患整改要求,对三车间自动化控制系统升级改造,消除加料互串与超温隐患。",
|
||||
effect: "隐患整改闭环率 100%,自动化水平提升,误操作风险显著降低。",
|
||||
before: "加料系统手动控制,存在互串与超温隐患(隐患编号 YH-2026-017)。",
|
||||
after: "改为 DCS 自动控制 + 加料顺序联锁,增设独立超温切断。",
|
||||
related: ["修订操作规程", "DCS 组态修改", "更新 HAZOP 分析", "岗位再培训"],
|
||||
materials: ["隐患整改方案", "HAZOP 分析报告", "联锁因果表"],
|
||||
riskTools: ["AI 分析", "HAZOP"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "process2", "quality"],
|
||||
status: "done",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "周设备(设备)", done: true },
|
||||
{ name: "陈质量(质量)", done: true },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "done" },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "current" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ===================== PSSR / 验收 =====================
|
||||
export interface PssrItem {
|
||||
id: number;
|
||||
category: string;
|
||||
content: string;
|
||||
owner: string;
|
||||
status: "待确认" | "已确认" | "不适用" | "整改中";
|
||||
photo?: boolean;
|
||||
/** 完成时点:投用前(未完成禁止投用)/ 关闭前(未完成禁止关闭) */
|
||||
phase?: "投用前" | "关闭前";
|
||||
}
|
||||
|
||||
export const PSSR_ITEMS: PssrItem[] = [
|
||||
{ id: 1, category: "设备完整性", content: "搅拌电机安装就位,联轴器对中合格,地脚螺栓紧固", owner: "周设备", status: "已确认", photo: true, phase: "投用前" },
|
||||
{ id: 2, category: "设备完整性", content: "电机空载/负载试车合格,轴承温度、振动值在正常范围", owner: "周设备", status: "已确认", photo: true, phase: "投用前" },
|
||||
{ id: 3, category: "仪表联锁", content: "电机过载保护定值按 75kW 重新整定并传动试验合格", owner: "王仪表", status: "待确认", phase: "投用前" },
|
||||
{ id: 4, category: "图纸规程", content: "P&ID(PID-R201-03)与电气图纸已更新发布", owner: "王仪表", status: "已确认", phase: "关闭前" },
|
||||
{ id: 5, category: "人员培训", content: "相关岗位 12 人完成新设备操作培训并考核合格", owner: "李主任", status: "待确认", phase: "投用前" },
|
||||
{ id: 6, category: "应急消防", content: "现场消防器材完好,应急通道畅通", owner: "王海燕", status: "待确认", phase: "投用前" },
|
||||
{ id: 7, category: "施工确认", content: "施工遗留物清理完毕,临时用电已拆除", owner: "周设备", status: "整改中", phase: "投用前" },
|
||||
];
|
||||
|
||||
export const ACCEPT_ITEMS: PssrItem[] = [
|
||||
{ id: 1, category: "一致性", content: "实施结果与批准的变更内容一致(电机型号、参数符合批准方案)", owner: "李主任", status: "待确认" },
|
||||
{ id: 2, category: "连带变更", content: "5 项连带变更全部完成(图纸、台账、联锁、备件、培训)", owner: "李主任", status: "待确认" },
|
||||
{ id: 3, category: "资料更新", content: "P&ID、电气图纸、设备台账已归档最新版本", owner: "王仪表", status: "待确认" },
|
||||
{ id: 4, category: "运行验证", content: "连续运行 72 小时无异常,搅拌电流稳定在 118A±5%", owner: "张工艺", status: "待确认" },
|
||||
];
|
||||
|
||||
// ===================== 关闭校验 =====================
|
||||
export interface ClosureCheck {
|
||||
item: string;
|
||||
done: boolean;
|
||||
note?: string;
|
||||
}
|
||||
export const CLOSURE_CHECKS: ClosureCheck[] = [
|
||||
{ item: "变更验收完成(结论:基本达到 · 遗留项 2 项已登记跟踪)", done: true },
|
||||
{ item: "资料上传齐全(验收报告、运行记录、恢复确认记录)", done: false, note: "缺:更新后的安全阀台账" },
|
||||
{ item: "行动项全部关闭(4/4,含「关闭前完成」标记项)", done: true },
|
||||
{ item: "受控文件已更新至生效版(P&ID Rev.4、操作规程)", done: false, note: "安全阀台账待更新发布" },
|
||||
{ item: "培训考核完成(12/12 人),签到与成绩已归档", done: true },
|
||||
];
|
||||
|
||||
// ===================== 风险分析 =====================
|
||||
export const HAZOP_ROWS = [
|
||||
{ node: "R-101 聚合釜", guide: "过高(温度)", dev: "反应温度超过 82℃", cause: "冷却水中断;TIC-101 故障;进料配比失调", cons: "飞温、冲料、超压破裂", l: 2, s: 5, measures: "增设独立高温联锁;冷却水低流量报警;HAZOP 建议 2 项已落实" },
|
||||
{ node: "R-101 聚合釜", guide: "过低(温度)", dev: "温度低于 65℃", cause: "引发剂不足;夹套加热故障", cons: "反应中止、物料积压,恢复时剧烈反应", l: 3, s: 3, measures: "低温度报警;恢复升温操作程序" },
|
||||
{ node: "进料系统", guide: "无(流量)", dev: "单体进料中断", cause: "进料泵故障;过滤器堵塞", cons: "釜内配比失衡,产品质量事故", l: 2, s: 3, measures: "泵一用一备;压差报警" },
|
||||
{ node: "进料系统", guide: "反向", dev: "物料反向流动", cause: "停泵时止回阀失效", cons: "串料污染、计量错误", l: 2, s: 2, measures: "止回阀定期校验(检查表项)" },
|
||||
];
|
||||
|
||||
export const JSA_ROWS = [
|
||||
{ step: "1. 停机断电隔离", hazard: "残余电能、机械能意外释放", measure: "执行上锁挂牌(LOTO),验电确认", owner: "周设备" },
|
||||
{ step: "2. 拆卸旧电机", hazard: "吊装伤害、挤压", measure: "持证起重工指挥,吊具检查,警戒区隔离", owner: "吊装班" },
|
||||
{ step: "3. 新电机安装对中", hazard: "对中误差导致振动超标", measure: "激光对中仪,偏差 ≤0.05mm", owner: "周设备" },
|
||||
{ step: "4. 接线与送电", hazard: "触电、相序错误反转", measure: "双人复核相序,点动确认转向", owner: "电气班" },
|
||||
{ step: "5. 试车", hazard: "机械伤害、异常振动", measure: "空载→负载分级试车,振动在线监测", owner: "周设备" },
|
||||
];
|
||||
|
||||
export const SCL_ROWS = [
|
||||
{ item: "新电机防爆等级不低于区域要求(ExdⅡCT4)", std: "GB 50058", result: "符合" },
|
||||
{ item: "电缆载流量与开关容量匹配 75kW 负荷", std: "GB 50217", result: "符合" },
|
||||
{ item: "电机接地电阻 ≤4Ω", std: "GB 50169", result: "符合" },
|
||||
{ item: "防护罩、联轴器护罩安装齐全", std: "企业设备规程", result: "整改后符合" },
|
||||
{ item: "润滑油牌号与加注量符合制造商要求", std: "制造商手册", result: "符合" },
|
||||
];
|
||||
|
||||
// ===================== 资料库 =====================
|
||||
export const KNOWLEDGE_DOCS = [
|
||||
{ name: "PID-R201-03 反应釜工艺流程图(Rev C)", cat: "工艺技术资料", device: "一车间", ver: "Rev C", date: "2026-07-29" },
|
||||
{ name: "聚合岗位操作规程(2026 版)", cat: "工艺技术资料", device: "一车间", ver: "V2026", date: "2026-07-26" },
|
||||
{ name: "M-201A 搅拌电机设备台账", cat: "设备资料", device: "一车间", ver: "V3.2", date: "2026-07-28" },
|
||||
{ name: "环己烷 MSDS(供应商 B)", cat: "化学品资料", device: "原料罐区", ver: "V1.0", date: "2026-07-18" },
|
||||
{ name: "AQ/T 3034 化工企业工艺安全管理实施导则", cat: "法规标准库", device: "全厂", ver: "2013 版", date: "2025-12-01" },
|
||||
{ name: "PSSR 检查表模板(设备类)", cat: "检查表模板库", device: "全厂", ver: "V2.1", date: "2026-05-10" },
|
||||
{ name: "2025 年度典型变更案例汇编", cat: "历史变更案例库", device: "全厂", ver: "2025 版", date: "2026-01-15" },
|
||||
{ name: "HAZOP 分析报告(R-101 温度变更)", cat: "历史变更案例库", device: "一车间", ver: "V1.0", date: "2026-07-26" },
|
||||
];
|
||||
|
||||
// ===================== 报表数据 =====================
|
||||
export const MONTHLY_COUNTS = [
|
||||
{ m: "2月", count: 4 }, { m: "3月", count: 6 }, { m: "4月", count: 5 },
|
||||
{ m: "5月", count: 8 }, { m: "6月", count: 7 }, { m: "7月", count: 9 },
|
||||
];
|
||||
export const TYPE_DIST = [
|
||||
{ name: "工艺", value: 16 }, { name: "设备", value: 14 }, { name: "管理", value: 9 },
|
||||
];
|
||||
export const CYCLE_TREND = [
|
||||
{ m: "2月", days: 12.5 }, { m: "3月", days: 11.2 }, { m: "4月", days: 10.8 },
|
||||
{ m: "5月", days: 9.6 }, { m: "6月", days: 8.9 }, { m: "7月", days: 8.2 },
|
||||
];
|
||||
export const OVERDUE_LIST = [
|
||||
{ id: "MOC-2026-R01-0039", title: "V-102 安全阀起跳压力临时调整", stage: "待关闭", days: 4 },
|
||||
{ id: "MOC-2026-R02-0031", title: "冷冻盐水泵 P-103 叶轮材质变更", stage: "待验收", days: 7 },
|
||||
{ id: "MOC-2026-R01-0029", title: "操作规程修订(夏季工况)", stage: "审批中", days: 3 },
|
||||
];
|
||||
|
||||
// 状态色阶梯:全站状态标签唯一来源(工作台 / 台账 / 我的任务 / 审批中心共用),
|
||||
// 色相全部取自品牌色板——amber=进行中提醒,品牌蓝=审批通过/执行,violet=投用前检查,emerald=验收/完成(浅→深递进),品牌红=待关闭关闸提醒
|
||||
export const STATUS_COLOR: Record<ChangeStatus, string> = {
|
||||
审批中: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
已批准: "bg-[#DFE9F6] text-[#17407F] border-[#ACC6E5]",
|
||||
实施中: "bg-[#1D4E9C]/10 text-[#1D4E9C] border-[#1D4E9C]/30",
|
||||
待PSSR: "bg-violet-100 text-violet-700 border-violet-200",
|
||||
待验收: "bg-emerald-50 text-emerald-600 border-emerald-200",
|
||||
待关闭: "bg-[#E15555]/10 text-[#E15555] border-[#E15555]/30",
|
||||
已关闭: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
};
|
||||
|
||||
export const LEVEL_COLOR: Record<ChangeLevel, string> = {
|
||||
重要: "bg-red-100 text-red-700 border-red-200",
|
||||
一般: "bg-slate-100 text-slate-600 border-slate-200",
|
||||
};
|
||||
|
||||
// ===================== 变更台账 =====================
|
||||
export 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 }[]; // 接班人(当前待办/已办)
|
||||
}
|
||||
|
||||
export 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 }] },
|
||||
];
|
||||
|
||||
// 演示数据:执行阶段变更的培训签到表 / PSSR 签字版上传状态(上传即视为完成确认并随单归档,系统不做内容识别)
|
||||
export const PSSR_UPLOADS: Record<string, { train?: string; pssr?: string }> = {
|
||||
"MOC-2026-R02-0038": { train: "培训签到表-照片.jpg", pssr: "PSSR 检查确认表-签字版.pdf" },
|
||||
"MOC-2026-R02-0031": { train: "培训签到表-照片.jpg", pssr: "PSSR 检查确认表-签字版.pdf" },
|
||||
"MOC-2026-R03-0027": { train: "培训签到表-归档.pdf", pssr: "PSSR 检查确认表-归档版.pdf" },
|
||||
"MOC-2026-R01-0024": { train: "培训签到表-归档.pdf", pssr: "PSSR 检查确认表-归档版.pdf" },
|
||||
};
|
||||
|
||||
// ===================== 演示用轻量全局状态(提交 / 审批真实推进,全站联动) =====================
|
||||
type DemoListener = () => void;
|
||||
const demoListeners = new Set<DemoListener>();
|
||||
export function subscribeDemo(fn: DemoListener) {
|
||||
demoListeners.add(fn);
|
||||
return () => { demoListeners.delete(fn); };
|
||||
}
|
||||
function emitDemo() { demoListeners.forEach((f) => f()); }
|
||||
|
||||
// ===================== 催办记录(车间主任催办 → 申请人操作台实时提示,全站同源) =====================
|
||||
export interface UrgeRecord { id: string; changeId: string; title: string; applicant: string; by: string; time: string; }
|
||||
export const URGE_RECORDS: UrgeRecord[] = [];
|
||||
let urgeSeq = 1;
|
||||
/** 车间主任催办:写入同源记录并广播,申请人操作台即时出现催办提示 */
|
||||
export function urgeChange(changeId: string, byRole: RoleId) {
|
||||
const row = LEDGER_ROWS.find((r) => r.id === changeId || r.changeId === changeId);
|
||||
const c = CHANGES.find((x) => x.id === changeId);
|
||||
const by = ROLES.find((r) => r.id === byRole)?.person ?? byRole;
|
||||
URGE_RECORDS.unshift({
|
||||
id: `URGE-${urgeSeq++}`,
|
||||
changeId,
|
||||
title: row?.title ?? c?.title ?? changeId,
|
||||
applicant: row?.applicant ?? c?.applicant ?? "",
|
||||
by,
|
||||
time: nowStr(),
|
||||
});
|
||||
emitDemo();
|
||||
}
|
||||
|
||||
let demoSeq = 43;
|
||||
export function nextChangeId() {
|
||||
return `MOC-2026-R01-${String(demoSeq++).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
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())}`;
|
||||
}
|
||||
|
||||
/** 申请页提交:生成真实变更单,台账 / 审批中心 / 我的任务 / 角标联动 */
|
||||
export 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();
|
||||
}
|
||||
|
||||
/** 详情页同意:真实推进流程节点(会签按人标记,全签通过才流转;末节点后进入待PSSR) */
|
||||
export function advanceFlow(id: string, role: RoleId) {
|
||||
const c = CHANGES.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
const idx = c.flow.findIndex((n) => n.status === "current");
|
||||
if (idx < 0) return;
|
||||
const node = c.flow[idx];
|
||||
if (node.kind === "会签" && node.members) {
|
||||
const person = ROLES.find((r) => r.id === role)?.person;
|
||||
const m = node.members.find((x) => x.name.startsWith(person ?? ""));
|
||||
if (m) m.done = true;
|
||||
if (!node.members.every((x) => x.done)) { emitDemo(); return; }
|
||||
}
|
||||
node.status = "done";
|
||||
const next = c.flow[idx + 1];
|
||||
if (next) {
|
||||
next.status = "current";
|
||||
next.locked = false;
|
||||
} else {
|
||||
c.status = "待PSSR";
|
||||
}
|
||||
const row = LEDGER_ROWS.find((r) => r.changeId === id || r.id === id);
|
||||
if (row) {
|
||||
const cur = c.flow.find((n) => n.status === "current");
|
||||
row.status = c.status;
|
||||
row.statusTime = nowStr();
|
||||
row.statusDetail = cur ? `${cur.name}(待处理)` : "审批完成,进入投用前确认";
|
||||
row.handlers = cur
|
||||
? cur.roles.map((rid) => ({ name: ROLES.find((r) => r.id === rid)?.person ?? rid, done: false }))
|
||||
: [{ name: c.applicant, done: false }];
|
||||
}
|
||||
emitDemo();
|
||||
}
|
||||
|
||||
// ===================== 延期 / 转永久子单(申请人提交 → 属地 / 安全 / 公司领导审批 → 回写原单,全站联动) =====================
|
||||
export interface SubOrder {
|
||||
id: string; // 子单号:原单号-Y1(延期)/ -Z1(转永久)
|
||||
parentId: string; // 原变更单号
|
||||
parentTitle: string;
|
||||
kind: "extend" | "permanent";
|
||||
applicant: string;
|
||||
reason: string;
|
||||
newDeadline?: string; // 延期子单:申请延期至
|
||||
status: "审批中" | "已通过" | "已驳回";
|
||||
submitTime: string;
|
||||
steps: { role: RoleId; name: string; done: boolean }[]; // 会签:全员同意即通过
|
||||
rejectNote?: string;
|
||||
}
|
||||
export const SUB_ORDERS: SubOrder[] = [];
|
||||
|
||||
/** 申请人提交延期 / 转永久子单:延期走简化审批(属地负责人 + 变更主管部门);转永久按永久变更审批链(属地 → 安全 → 公司领导) */
|
||||
export function submitSubOrder(o: { parentId: string; parentTitle: string; kind: "extend" | "permanent"; applicant: string; reason: string; newDeadline?: string }) {
|
||||
const steps: SubOrder["steps"] = o.kind === "extend"
|
||||
? [{ role: "dept", name: "李主任", done: false }, { role: "safety", name: "王海燕", done: false }]
|
||||
: [{ role: "dept", name: "李主任", done: false }, { role: "safety", name: "王海燕", done: false }, { role: "vp", name: "刘副总", done: false }];
|
||||
SUB_ORDERS.unshift({
|
||||
id: `${o.parentId}-${o.kind === "extend" ? "Y1" : "Z1"}`,
|
||||
...o,
|
||||
status: "审批中",
|
||||
submitTime: nowStr(),
|
||||
steps,
|
||||
});
|
||||
emitDemo();
|
||||
return SUB_ORDERS[0];
|
||||
}
|
||||
|
||||
/** 子单审批(同意 / 驳回):全员同意 → 已通过并回写原单;任一驳回 → 已驳回,申请人可修改后重新提交 */
|
||||
export function decideSubOrder(id: string, role: RoleId, approve: boolean, note?: string) {
|
||||
const s = SUB_ORDERS.find((x) => x.id === id);
|
||||
if (!s || s.status !== "审批中") return;
|
||||
const st = s.steps.find((x) => x.role === role);
|
||||
if (!st || st.done) return;
|
||||
st.done = true;
|
||||
if (!approve) {
|
||||
s.status = "已驳回";
|
||||
s.rejectNote = note || "";
|
||||
} else if (s.steps.every((x) => x.done)) {
|
||||
s.status = "已通过";
|
||||
}
|
||||
emitDemo();
|
||||
}
|
||||
|
||||
// ===================== 工作台 / 个人统计共享数据源(唯一口径,避免双写漂移) =====================
|
||||
/** 临时变更到期监视(申请人工作台「临时变更到期提醒情况」与个人统计报表共用同一来源) */
|
||||
export const TEMP_WATCH = [
|
||||
{ label: "正常(到期 > 7 天)", n: 1, c: "#7CC242", note: "循环水加药泵冲程临时调整 · 剩 17 天" },
|
||||
{ label: "临期(到期 ≤ 7 天)", n: 1, c: "#F5A623", note: "循环水旁滤器临时绕流运行 · 剩 6 天,已触发提醒" },
|
||||
{ label: "已超期", n: 1, c: "#E15555", note: "V-102 安全阀起跳压力临时调整 · 超期 4 天(已延期 1 次)" },
|
||||
];
|
||||
|
||||
/** 培训确认待办(工作台「待确认培训」卡片与「我的任务 · 培训确认」同源) */
|
||||
export const TRAIN_TODO = [
|
||||
{ id: "MOC-2026-R01-0035", title: "搅拌电机更换操作培训", target: "R-201 岗位操作人员(4 人)、维修班(2 人)" },
|
||||
];
|
||||
|
||||
/** 资料归档待办(工作台「待上传资料」卡片与「待我处理 · 资料关闭」同源:验收 / 归档材料补交) */
|
||||
export const DOC_TODO = [
|
||||
{ id: "MOC-2026-R02-0038", title: "DCS 罐区液位报警值 LIA-301 调整", missing: "验收报告与运行数据" },
|
||||
{ id: "MOC-2026-R01-0039", title: "紧急变更:V-102 安全阀起跳压力临时调整(补办手续)", missing: "更新后的 P&ID / 操作规程(受控最新版)、设备 / 联锁台账更新记录" },
|
||||
];
|
||||
|
||||
/** 尚未关闭变更状态分布:四段口径固定,数量从 CHANGES 实时推导(0 项也保留展示,保证口径稳定)
|
||||
* 对应三大流转阶段:① 申请提交后 / 会签中 / 审批中;② 审批通过后 → 变更关闭前(含投用、验收);③ 变更关闭 */
|
||||
export const OPEN_STAGES: { name: string; match: ChangeStatus[]; c: string }[] = [
|
||||
{ name: "审批中(含会签)", match: ["审批中"], c: "#F5A623" },
|
||||
{ name: "已批准待投用", match: ["已批准", "待PSSR", "实施中"], c: "#1D4E9C" },
|
||||
{ name: "待验收", match: ["待验收"], c: "#7CC242" },
|
||||
{ name: "待关闭", match: ["待关闭"], c: "#E15555" },
|
||||
];
|
||||
export function countOpenStatus(list: { status: ChangeStatus }[]) {
|
||||
return OPEN_STAGES.map((s) => ({ name: s.name, c: s.c, n: list.filter((x) => s.match.includes(x.status)).length }));
|
||||
}
|
||||
|
||||
// ===================== 统一待办推导(全站同源:工作台卡片 / 侧边栏角标 / 我的任务 / 审批中心) =====================
|
||||
/** 判断变更是否停在当前角色的待办节点。
|
||||
* 会签节点按成员粒度判定:已签署的成员(按角色对应人员姓名匹配)不再计入其待办。 */
|
||||
export function isPendingFor(c: ChangeOrder, role: RoleId): boolean {
|
||||
if (c.status !== "审批中") return false;
|
||||
const node = c.flow.find((n) => n.status === "current");
|
||||
if (!node) return false;
|
||||
if (node.kind === "会签" && node.members && node.members.length > 0) {
|
||||
const person = ROLES.find((r) => r.id === role)?.person;
|
||||
const me = person ? node.members.find((m) => m.name.startsWith(person)) : undefined;
|
||||
if (me) return !me.done;
|
||||
}
|
||||
return node.roles.includes(role);
|
||||
}
|
||||
|
||||
/** 当前角色的实时待审批变更列表(唯一数据源) */
|
||||
export function pendingApprovals(role: RoleId): ChangeOrder[] {
|
||||
return CHANGES.filter((c) => isPendingFor(c, role));
|
||||
}
|
||||
|
||||
/** 执行阶段(审批通过后 → 关闭前)的变更:投用准备与验收跟踪 */
|
||||
export function executionStage(list: ChangeOrder[] = CHANGES): ChangeOrder[] {
|
||||
return list.filter((c) => c.status === "待PSSR" || c.status === "待验收");
|
||||
}
|
||||
|
||||
/** 超期未关闭变更(待关闭且已超期) */
|
||||
export function overdueClosing(list: ChangeOrder[] = CHANGES): ChangeOrder[] {
|
||||
return list.filter((c) => c.status === "待关闭" && (c.overdueDays ?? 0) > 0);
|
||||
}
|
||||
|
||||
|
||||
/** 专业人员「技术把关工作记录」:从 CHANGES 实时推导(与台账 / 待办同源,演示数据范围内统计)
|
||||
* - signed:已完成的会签 / 审批签署次数(会签按成员粒度、审批节点按角色判定)
|
||||
* - involved:涉及本专业的在办变更数(未关闭且流程中含本角色节点)
|
||||
* - 驳回次数暂无结构化记录字段,演示版不展示;参与风险分析次数来自分析组成员(选择式)记录 */
|
||||
export function profWorkStats(role: RoleId) {
|
||||
const person = ROLES.find((r) => r.id === role)?.person ?? "";
|
||||
let signed = 0;
|
||||
for (const c of CHANGES) {
|
||||
for (const n of c.flow) {
|
||||
if (n.kind === "会签" && n.members?.length) {
|
||||
const me = n.members.find((m) => m.name.startsWith(person));
|
||||
if (me?.done) signed += 1;
|
||||
} else if (n.status === "done" && n.roles.includes(role)) {
|
||||
signed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const involved = CHANGES.filter(
|
||||
(c) => c.status !== "已关闭" && c.flow.some((n) => n.roles.includes(role))
|
||||
).length;
|
||||
return { signed, involved };
|
||||
}
|
||||
Vendored
+4
@@ -10,8 +10,12 @@ declare namespace StorageType {
|
||||
}
|
||||
|
||||
interface Local {
|
||||
/** The user info */
|
||||
userInfo: any;
|
||||
/** The i18n language */
|
||||
lang: App.I18n.LangType;
|
||||
/** The ai token */
|
||||
aiToken: string;
|
||||
/** The token */
|
||||
token: string;
|
||||
/** Fixed sider with mix-menu */
|
||||
|
||||
+175
-1
@@ -77,7 +77,7 @@ export const getColorWithOpacity = (color: string, opacity: number) => {
|
||||
};
|
||||
|
||||
// 时间戳转换成年月日时分秒
|
||||
export function formatTimestamp(timestamp: number | string,format: string = 'yyyy-MM-dd HH:mm:ss') {
|
||||
export function formatTimestamp(timestamp: number | string | Date | null,format: string = 'yyyy-MM-dd HH:mm:ss') {
|
||||
if(!timestamp) {
|
||||
return '';
|
||||
}
|
||||
@@ -96,3 +96,177 @@ export function formatTimestamp(timestamp: number | string,format: string = 'yyy
|
||||
}
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意时间格式 → 时间戳(支持 只有小时)
|
||||
* @param {string|number|Date} input
|
||||
* @param {'ms'|'s'} unit 默认毫秒
|
||||
*/
|
||||
export function anyToTimestamp(input: string | number | Date | null | undefined, unit = 'ms') {
|
||||
if (input == null || input === '') return null;
|
||||
|
||||
const toResult = (ms: number) =>
|
||||
Number.isNaN(ms) ? null : unit === 's' ? Math.floor(ms / 1000) : ms;
|
||||
|
||||
// 1. 数字
|
||||
if (typeof input === 'number') return toResult(input);
|
||||
|
||||
// 2. Date
|
||||
if (input instanceof Date) return toResult(input.getTime());
|
||||
|
||||
let str = String(input).trim();
|
||||
|
||||
// 3. 纯数字字符串
|
||||
if (/^\d+$/.test(str)) return toResult(Number(str));
|
||||
|
||||
// 4. 统一分隔符
|
||||
str = str
|
||||
.replace(/[年月]/g, '-')
|
||||
.replace(/[日号]/g, '')
|
||||
.replace(/[时分]/g, ':')
|
||||
.replace(/秒/g, '')
|
||||
.replace(/[./]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// 5. 手动解析:小时、分钟、秒、毫秒全部可选
|
||||
const m = str.match(
|
||||
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}))?(?::(\d{1,2}))?(?::(\d{1,2}))?(?:\.(\d{1,3}))?/
|
||||
);
|
||||
if (m) {
|
||||
const [, y, mo, d, h = '0', mi = '0', s = '0', ms = '0'] = m;
|
||||
const t = new Date(
|
||||
+y,
|
||||
+mo - 1,
|
||||
+d,
|
||||
+h,
|
||||
+mi,
|
||||
+s,
|
||||
+ms.padEnd(3, '0')
|
||||
).getTime();
|
||||
if (!Number.isNaN(t)) return toResult(t);
|
||||
}
|
||||
|
||||
// 6. 兜底
|
||||
let t = new Date(str.replace(' ', 'T')).getTime();
|
||||
if (!Number.isNaN(t)) return toResult(t);
|
||||
|
||||
t = Date.parse(str);
|
||||
if (!Number.isNaN(t)) return toResult(t);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 辅助函数:Fisher-Yates 洗牌算法
|
||||
function shuffle(arr:any) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
// 辅助函数:从字符串中随机获取字符
|
||||
function getRandomChar(str:any) {
|
||||
return str[Math.floor(Math.random() * str.length)];
|
||||
}
|
||||
// 生成至少12位,包含四类字符中的至少三种的随机密码
|
||||
export function generateSecurePassword(passwordLength:number) {
|
||||
// 定义字符池
|
||||
const charSets:any = {
|
||||
uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
lowercase: 'abcdefghijklmnopqrstuvwxyz',
|
||||
numbers: '0123456789',
|
||||
symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
};
|
||||
|
||||
// 随机选择三种字符类型(保证至少三类)
|
||||
const requiredTypes = shuffle(Object.keys(charSets)).slice(0, 3);
|
||||
|
||||
// 生成必选字符(每类至少1个)
|
||||
let password = requiredTypes
|
||||
.map((type:any) => getRandomChar(charSets[type]))
|
||||
.join('');
|
||||
|
||||
// 生成剩余字符(允许包含所有四类)
|
||||
const remainingLength = passwordLength - password.length;
|
||||
const allChars = Object.values(charSets).join('');
|
||||
for (let i = 0; i < remainingLength; i++) {
|
||||
password += getRandomChar(allChars);
|
||||
}
|
||||
|
||||
// 打乱字符顺序
|
||||
return shuffle(password.split('')).join('');
|
||||
}
|
||||
|
||||
// 根据当前时间,获取指定的时间范围(返回时间戳)
|
||||
export function getTimeRangeStamp(timeRange: string){
|
||||
const endTime = anyToTimestamp(new Date());
|
||||
if(!endTime) {
|
||||
return null;
|
||||
}
|
||||
// 近7天
|
||||
if(timeRange === 'week'){
|
||||
return {
|
||||
from: endTime - (7 * 24 * 60 * 60 * 1000),
|
||||
to: endTime
|
||||
}
|
||||
}
|
||||
// 近一个月
|
||||
if(timeRange === 'month'){
|
||||
return {
|
||||
from: endTime - (30 * 24 * 60 * 60 * 1000),
|
||||
to: endTime
|
||||
}
|
||||
}
|
||||
// 近三个月
|
||||
if(timeRange === 'quarter'){
|
||||
return {
|
||||
from: endTime - (90 * 24 * 60 * 60 * 1000),
|
||||
to: endTime
|
||||
}
|
||||
}
|
||||
// 近半年
|
||||
if(timeRange === 'half_year'){
|
||||
return {
|
||||
from: endTime - (180 * 24 * 60 * 60 * 1000),
|
||||
to: endTime
|
||||
}
|
||||
}
|
||||
// 近一年
|
||||
if(timeRange === 'year'){
|
||||
return {
|
||||
from: endTime - (365 * 24 * 60 * 60 * 1000),
|
||||
to: endTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
export function downloadFileFun(url: string, name: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// 获取 token
|
||||
export const getToken = () => {
|
||||
return localStorage.getItem('token');
|
||||
};
|
||||
|
||||
// tag自定义样式颜色
|
||||
// 文字颜色
|
||||
export const tagTextColor = (color: string) => {
|
||||
return color || '#333';
|
||||
}
|
||||
// 背景颜色
|
||||
export const tagBgColor = (color: string) => {
|
||||
return getColorWithOpacity((color || '#333'), 0.08);
|
||||
}
|
||||
// 边框颜色
|
||||
export const tagBorderColor = (color: string) => {
|
||||
return getColorWithOpacity((color || '#333'), 0.3);
|
||||
}
|
||||
+60
-2
@@ -5,6 +5,7 @@ import json5 from 'json5';
|
||||
*
|
||||
* @param env The current env
|
||||
*/
|
||||
let url = '';
|
||||
export function createServiceConfig(env: Env.ImportMeta) {
|
||||
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env;
|
||||
|
||||
@@ -15,11 +16,11 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('VITE_OTHER_SERVICE_BASE_URL is not a valid json5 string');
|
||||
}
|
||||
let url = 'http://192.168.0.230:8086';
|
||||
if(env.DEV){
|
||||
url = 'http://192.168.0.230:8086';
|
||||
}else{
|
||||
url = window.location.origin;
|
||||
// url = window.location.origin;
|
||||
url = '/api';
|
||||
}
|
||||
const httpConfig: App.Service.SimpleServiceConfig = {
|
||||
baseURL: url,
|
||||
@@ -44,6 +45,49 @@ export function createServiceConfig(env: Env.ImportMeta) {
|
||||
|
||||
return config;
|
||||
}
|
||||
let aiUrl = '';
|
||||
export function createAiServiceConfig(env: Env.ImportMeta) {
|
||||
const { VITE_SERVICE_BASE_URL, VITE_OTHER_SERVICE_BASE_URL } = env;
|
||||
|
||||
let other = {} as Record<App.Service.OtherBaseURLKey, string>;
|
||||
try {
|
||||
other = json5.parse(VITE_OTHER_SERVICE_BASE_URL);
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('VITE_OTHER_SERVICE_BASE_URL is not a valid json5 string');
|
||||
}
|
||||
if(env.DEV){
|
||||
aiUrl = 'https://mocaiapi.djxinxi.net';
|
||||
}else{
|
||||
// url = window.location.origin;
|
||||
aiUrl = 'https://mocaiapi.djxinxi.net';
|
||||
}
|
||||
const httpConfig: App.Service.SimpleServiceConfig = {
|
||||
baseURL: aiUrl,
|
||||
other
|
||||
};
|
||||
|
||||
const otherHttpKeys = Object.keys(httpConfig.other) as App.Service.OtherBaseURLKey[];
|
||||
|
||||
const otherConfig: App.Service.OtherServiceConfigItem[] = otherHttpKeys.map(key => {
|
||||
return {
|
||||
key,
|
||||
baseURL: httpConfig.other[key],
|
||||
proxyPattern: createProxyPattern(key)
|
||||
};
|
||||
});
|
||||
|
||||
const config: App.Service.ServiceConfig = {
|
||||
baseURL: httpConfig.baseURL,
|
||||
proxyPattern: createProxyPattern(),
|
||||
other: otherConfig
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
export function getBaseUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* get backend service base url
|
||||
@@ -65,6 +109,20 @@ export function getServiceBaseURL(env: Env.ImportMeta, isProxy: boolean) {
|
||||
otherBaseURL
|
||||
};
|
||||
}
|
||||
export function getServiceAiURL(env: Env.ImportMeta, isProxy: boolean) {
|
||||
const { baseURL, other } = createAiServiceConfig(env);
|
||||
|
||||
const otherBaseURL = {} as Record<App.Service.OtherBaseURLKey, string>;
|
||||
|
||||
other.forEach(item => {
|
||||
otherBaseURL[item.key] = isProxy ? item.proxyPattern : item.baseURL;
|
||||
});
|
||||
|
||||
return {
|
||||
baseURL: isProxy ? createProxyPattern() : baseURL,
|
||||
otherBaseURL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proxy pattern of backend service base url
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import List from "./modules/list.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<List self="" keyValue="changeledger" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,628 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, h } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { tagTextColor, tagBgColor, tagBorderColor, getTimeRangeStamp, formatTimestamp } from '@/utils/common';
|
||||
import { systemConfigApi } from '@/service/api/system';
|
||||
import { orgListApi } from '@/service/api/user';
|
||||
import { changeLedgerListApi, exportChangeLedgerApi } from '@/service/api/changeLedger';
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['self', 'keyValue'])
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 当前选中时间范围
|
||||
const period = ref<string>("year");
|
||||
// 组织树
|
||||
const orgTree = ref<any[]>([]);
|
||||
// 筛选条件
|
||||
const formInfo = ref<any>({
|
||||
kw: '',
|
||||
type: null,
|
||||
level: null,
|
||||
duration: null,
|
||||
status: '',//英文逗号隔开
|
||||
sort: null,
|
||||
org_id: 1,
|
||||
apply_time_start: getTimeRangeStamp(period.value)?.from ?? null,
|
||||
apply_time_end: getTimeRangeStamp(period.value)?.to ?? null,
|
||||
self: props.self,
|
||||
});
|
||||
const pagination = ref<{page: number, pageSize: number, itemCount: number}>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
// 变更类型列表
|
||||
const typeList = ref<any[]>([{ label: "全部", value: null }]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any[]>([{ label: "全部", value: null }]);
|
||||
// 状态选项
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "全部", value: "", type:'all' },
|
||||
{ label: "审批中", value: "APPROVING", type:'warning' },
|
||||
{ label: "已通过", value: "APPROVED", type:'success' },
|
||||
{ label: "待验收", value: "IMPLEMENTED", type:'success' },
|
||||
{ label: "待关闭", value: "ACCEPTED", type:'error' },
|
||||
{ label: "已关闭", value: "CLOSED", type:'success' },
|
||||
{ label: "已撤回", value: "WITHDRAWN", type:'error' },
|
||||
{ label: "已驳回", value: "REJECTED", type:'error' },
|
||||
];
|
||||
// 排序选项
|
||||
const SORT_OPTS = [
|
||||
{ label: "申请时间 · 最新", value: "apply_time,desc" },
|
||||
{ label: "申请时间 · 最早", value: "apply_time,asc" },
|
||||
{ label: "计划投用 · 最新", value: "plan_use_date,desc" },
|
||||
{ label: "计划投用 · 最早", value: "plan_use_date,asc" },
|
||||
{ label: "状态更新 · 最新", value: "status_changed_at,desc" },
|
||||
{ label: "状态更新 · 最早", value: "status_changed_at,asc" },
|
||||
];
|
||||
// 时限选项
|
||||
const DUR_OPTIONS = [
|
||||
{ label: "全部", value: null },
|
||||
{ label: "永久", value: 1 },
|
||||
{ label: "临时", value: 2 }
|
||||
];
|
||||
// 时间选项
|
||||
const TIME_OPTIONS = [
|
||||
{ label: "近一年", value: 'year' },
|
||||
{ label: "近半年", value: 'half_year' },
|
||||
{ label: "近三个月", value: 'quarter' },
|
||||
{ label: "近一个月", value: 'month' },
|
||||
{ label: "自定义", value: 'custom' },
|
||||
];
|
||||
// 是否显示全部
|
||||
const isAll = ref<boolean>(true);
|
||||
// 当前选中行
|
||||
const currentRow = ref<any>(null);
|
||||
// 进度抽屉
|
||||
const progressDrawer = ref<boolean>(false);
|
||||
// 撤回/未实施情况说明弹框
|
||||
const revokeModal = ref<boolean>(false);
|
||||
const modalType = ref<string>('');
|
||||
const reason = ref<string>('');
|
||||
const revokeSaving = ref<boolean>(false);
|
||||
// 列表
|
||||
const list = ref<any[]>([]);
|
||||
|
||||
// 导出台账
|
||||
const exportChanges = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await exportChangeLedgerApi({
|
||||
kw: formInfo.value.kw,
|
||||
type: formInfo.value.type,
|
||||
level: formInfo.value.level,
|
||||
status: formInfo.value.status,
|
||||
org_id: formInfo.value.org_id,
|
||||
apply_time_start: formatTimestamp(formInfo.value.apply_time_start),
|
||||
apply_time_end: formatTimestamp(formInfo.value.apply_time_end),
|
||||
});
|
||||
if(!error){
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
// 确保 res 为 Blob 类型(若 API 返回 ArrayBuffer 或原始数据,需适配)
|
||||
let blob;
|
||||
if (data instanceof Blob) {
|
||||
blob = data;
|
||||
} else {
|
||||
// 假设返回的是二进制数据(如 ArrayBuffer),指定 MIME 类型
|
||||
blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = '变更台账.xlsx'; // 推荐使用 download 属性
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// 清理:移除链接并释放 URL 对象
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj= (list:any[],val:any) => {
|
||||
const item = list.find((item:any) => item.value == val);
|
||||
return item || null;
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const searchHandle = () => {
|
||||
pagination.value.page = 1;
|
||||
getList();
|
||||
}
|
||||
// 重置
|
||||
const reset = () => {
|
||||
period.value = 'year';
|
||||
formInfo.value.kw = '';
|
||||
formInfo.value.type = null;
|
||||
formInfo.value.level = null;
|
||||
formInfo.value.duration = null;
|
||||
formInfo.value.status = '';
|
||||
formInfo.value.org_id = 1;
|
||||
formInfo.value.sort = null;
|
||||
formInfo.value.apply_time_start = getTimeRangeStamp(period.value)?.from ?? null;
|
||||
formInfo.value.apply_time_end = getTimeRangeStamp(period.value)?.to ?? null;
|
||||
isAll.value = true;
|
||||
searchHandle();
|
||||
};
|
||||
// 组织选择改变
|
||||
const orgChange = (val:any) => {
|
||||
formInfo.value.org_id = val;
|
||||
searchHandle();
|
||||
}
|
||||
// 显示全部改变
|
||||
const showAll = () => {
|
||||
isAll.value = !isAll.value;
|
||||
if(isAll.value){
|
||||
formInfo.value.status = '';
|
||||
}else{
|
||||
formInfo.value.status = 'APPROVING,APPROVED,IMPLEMENTED,ACCEPTED,WITHDRAWN,REJECTED';
|
||||
}
|
||||
searchHandle();
|
||||
}
|
||||
// 类型选择
|
||||
const typeChoose = (val:any) => {
|
||||
formInfo.value.type = val;
|
||||
searchHandle();
|
||||
}
|
||||
// 时限选择
|
||||
const durChoose = (val:any) => {
|
||||
formInfo.value.duration = val;
|
||||
searchHandle();
|
||||
}
|
||||
// 时间范围选择
|
||||
const timeChoose = (val:string) => {
|
||||
period.value = val;
|
||||
if(val === 'custom'){
|
||||
formInfo.value.apply_time_start = null;
|
||||
formInfo.value.apply_time_end = null;
|
||||
return
|
||||
}else{
|
||||
formInfo.value.apply_time_start = getTimeRangeStamp(val)?.from ?? null;
|
||||
formInfo.value.apply_time_end = getTimeRangeStamp(val)?.to ?? null;
|
||||
}
|
||||
searchHandle();
|
||||
}
|
||||
// 自定义时间选择
|
||||
const timeChange = (type:string,val:any) => {
|
||||
if(type === 'start'){
|
||||
formInfo.value.apply_time_start = val;
|
||||
}else{
|
||||
formInfo.value.apply_time_end = val;
|
||||
}
|
||||
if(formInfo.value.apply_time_start && formInfo.value.apply_time_end){
|
||||
if(formInfo.value.apply_time_start > formInfo.value.apply_time_end){
|
||||
return window.$message?.warning('开始时间不能大于结束时间');
|
||||
}
|
||||
searchHandle();
|
||||
}
|
||||
}
|
||||
// 状态选择
|
||||
const statusChoose = (val:string) => {
|
||||
formInfo.value.status = val;
|
||||
searchHandle();
|
||||
}
|
||||
// 排序选择
|
||||
const sortChoose = (val:string) => {
|
||||
formInfo.value.sort = val;
|
||||
searchHandle();
|
||||
}
|
||||
|
||||
// 跳转详情
|
||||
const jumpTo = (r:any) => {
|
||||
routerPushByKey("details_index",{ query: { id: r?.id?.toString(),key:props.keyValue } });
|
||||
}
|
||||
// 打开进度
|
||||
const openProgress = (r: any) => {
|
||||
currentRow.value = r;
|
||||
progressDrawer.value = true;
|
||||
};
|
||||
// 撤回/未实施情况说明
|
||||
const openModal = (r: any,type:string) => {
|
||||
currentRow.value = r;
|
||||
modalType.value = type;
|
||||
revokeModal.value = true;
|
||||
}
|
||||
// 撤回/未实施情况说明提交
|
||||
const revokeSave = async () => {
|
||||
if (!reason.value) {
|
||||
return window.$message?.warning(modalType.value === "withdraw" ? "请输入撤回理由" : "请输入未实施情况说明");
|
||||
}
|
||||
revokeSaving.value = true;
|
||||
setTimeout(() => {
|
||||
revokeSaving.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFiles = (r:any,type:string) => {
|
||||
// 下载申请表
|
||||
if(type==='apply'){
|
||||
|
||||
}
|
||||
// 下载已验收待关闭
|
||||
if(type==='accepted'){
|
||||
|
||||
}
|
||||
// 下载归档包
|
||||
if(type==='closed'){
|
||||
|
||||
}
|
||||
window.$message?.info('还差接口');
|
||||
}
|
||||
|
||||
// 分页改变
|
||||
const pageChange = (page: number) => {
|
||||
pagination.value.page = page;
|
||||
getList();
|
||||
}
|
||||
// 列表查询
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const {error, data} = await changeLedgerListApi({
|
||||
kw: formInfo.value.kw,
|
||||
type: formInfo.value.type,
|
||||
level: formInfo.value.level,
|
||||
duration: formInfo.value.duration,
|
||||
status: formInfo.value.status,
|
||||
org_id: formInfo.value.org_id,
|
||||
apply_time_start: formatTimestamp(formInfo.value.apply_time_start),
|
||||
apply_time_end: formatTimestamp(formInfo.value.apply_time_end),
|
||||
self: formInfo.value.self,
|
||||
page_num: pagination.value.page,
|
||||
page_size: pagination.value.pageSize,
|
||||
sort: formInfo.value.sort,
|
||||
});
|
||||
if (!error) {
|
||||
list.value = data?.list ?? [];
|
||||
pagination.value.itemCount = data?.total ?? 0;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 获取配置
|
||||
const getConfig = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = typeList.value.concat(changeTypeGroup.items);
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = levelList.value.concat(changeLevelGroup.items);
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取组织列表
|
||||
const getOrgList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await orgListApi();
|
||||
if(!error){
|
||||
orgTree.value = data;
|
||||
if(orgTree.value.length > 0){
|
||||
getList();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getConfig();
|
||||
getOrgList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我发起的变更 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<!-- 工具条 -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border px-3 py-2"
|
||||
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" /> 全部变更
|
||||
<span class="rounded bg-slate-100 px-1.5 text-xs text-slate-500">{{ pagination.itemCount }}</span>
|
||||
</span>
|
||||
<NTreeSelect
|
||||
class="!w-200px"
|
||||
:value="formInfo.org_id"
|
||||
:options="orgTree"
|
||||
size="small"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
placeholder="请选择组织层级"
|
||||
@update:value="orgChange"
|
||||
/>
|
||||
<NRadio
|
||||
size="small"
|
||||
:checked="isAll"
|
||||
:value="true"
|
||||
name="显示全部"
|
||||
:on-update:checked="showAll"
|
||||
>
|
||||
显示全部
|
||||
</NRadio>
|
||||
<NRadio
|
||||
size="small"
|
||||
:checked="!isAll"
|
||||
:value="false"
|
||||
name="显示未关闭"
|
||||
:on-update:checked="showAll"
|
||||
>
|
||||
显示未关闭
|
||||
</NRadio>
|
||||
<div class="relative ml-auto">
|
||||
<NInput v-model:value="formInfo.kw" size="small" type="text" clearable placeholder="搜索变更名称 / 编号" @clear="formInfo.kw = ''; searchHandle()">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="searchHandle" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
<NButton size="small" class="text-sm" @click="reset">
|
||||
<Icon icon="system-uicons:reset" class="size-12px" />重置
|
||||
</NButton>
|
||||
<NButton size="small" class="text-sm" @click="exportChanges">
|
||||
<Icon icon="material-symbols:download" class="size-14px" />导出台账
|
||||
</NButton>
|
||||
</div>
|
||||
<!-- 筛选与排序行 -->
|
||||
<div
|
||||
class="flex flex-col gap-y-2 border px-3 py-2 my-3"
|
||||
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">类型</span>
|
||||
<NButton size="tiny" round v-for="t in typeList" :key="t.label" :ghost="formInfo.type === t.value ? false : true" :type="formInfo.type === t.value ? 'primary' : 'default'" @click="typeChoose(t.value)">{{ t.label }}</NButton>
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时限</span>
|
||||
<NButton size="tiny" round v-for="d in DUR_OPTIONS" :key="d.label" :ghost="formInfo.duration === d.value ? false : true" :type="formInfo.duration === d.value ? 'primary' : 'default'" @click="durChoose(d.value)">{{ d.label }}</NButton>
|
||||
</p>
|
||||
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时间</span>
|
||||
<NButton size="tiny" round v-for="p in TIME_OPTIONS" :key="p.value" :ghost="period === p.value ? false : true" :type="period === p.value ? 'primary' : 'default'" @click="timeChoose(p.value)">{{ p.label }}</NButton>
|
||||
<span v-if="period === 'custom'" class="flex items-center gap-1">
|
||||
<NDatePicker :value="formInfo.apply_time_start" type="date" size="small" class="w-130px" :on-update:value="(v) => timeChange('start',v)" />
|
||||
<span class="text-xs text-slate-400">至</span>
|
||||
<NDatePicker :value="formInfo.apply_time_end" type="date" size="small" class="w-130px" :on-update:value="(v) => timeChange('end',v)" />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">状态</span>
|
||||
<NButton size="tiny" round v-for="s in STATUS_OPTIONS" :key="s.value" :ghost="formInfo.status === s.value ? false : true" :type="formInfo.status === s.value ? 'primary' : 'default'" @click="statusChoose(s.value)">{{ s.label }}</NButton>
|
||||
</p>
|
||||
<p class="ml-auto flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">排序</span>
|
||||
<NSelect size="small" class="!w-36" v-model:value="formInfo.sort" :options="SORT_OPTS" :on-update:value="sortChoose" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 scroll">
|
||||
<!-- 列表 -->
|
||||
<div
|
||||
v-for="(r, i) in list" :key="r.id"
|
||||
class="rounded-lg border bg-white px-4 py-3"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-slate-300">{{ Number(i)+1 }}</span>
|
||||
<p class="cursor-pointer text-left text-sm font-semibold text-slate-800 hover:text-[var(--theme-color)] hover:underline" @click="jumpTo(r)">
|
||||
{{ r.title }}
|
||||
</p>
|
||||
<NTag size="small" v-if="r.urgent" type="error">紧急</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ r.change_no }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<!-- 类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,r.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,r.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,r.change_type)?.color),
|
||||
}"
|
||||
>
|
||||
{{ matchObj(typeList,r.change_type)?.label }}
|
||||
</NTag>
|
||||
<!-- 等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,r.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,r.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,r.change_level)?.color),
|
||||
}"
|
||||
>
|
||||
{{ matchObj(levelList,r.change_level)?.label }}
|
||||
</NTag>
|
||||
<NTag v-if="r.duration_type===2" size="small" type="warning">临时</NTag>
|
||||
<NTag v-if="r.duration_type===1" size="small" type="info">永久</NTag>
|
||||
<NTag v-if="r.extend_count" size="small" type="warning">延期</NTag>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-slate-500">
|
||||
<span v-if="r.apply_time">申请 {{ r.apply_time?.slice(0,16) }}</span>
|
||||
<span>{{ r.org_name }} · {{ r.applicant_name }}</span>
|
||||
<span v-if="r.plan_use_date">计划投用 {{ r.plan_use_date?.slice(0,16) }}</span>
|
||||
<span v-if="r.use_time">实际投用 {{ r.use_time?.slice(0,16) }}</span>
|
||||
<span v-if="r.restore_deadline">计划恢复 {{ r.restore_deadline?.slice(0,16) }}</span>
|
||||
<span v-if="r.close_time">关闭 {{ r.close_time?.slice(0,16) }}</span>
|
||||
<span v-if="r.status_changed_at">状态更新 {{ r.status_changed_at?.slice(0,16) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<NTag v-if="r.status==='APPROVING'" size="small" type="warning">审批中</NTag>
|
||||
<NTag v-if="r.status==='APPROVED'" size="small" type="success">已通过</NTag>
|
||||
<NTag v-if="r.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
|
||||
<NTag v-if="r.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
|
||||
<NTag v-if="r.status==='CLOSED'" size="small" type="success">已关闭</NTag>
|
||||
<NTag v-if="r.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
|
||||
<NTag v-if="r.status==='REJECTED'" size="small" type="error">已驳回</NTag>
|
||||
<!-- 超期 -->
|
||||
<span v-if="r.overdue_status===2" class="text-[11px] font-medium text-red-500">超期 {{ r.overdue_days }} 天</span>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 pt-0.5">
|
||||
<!-- 审批中 -->
|
||||
<!-- <NButton v-if="r.status==='APPROVING'" size="small" ghost type="error" class="text-xs" @click="openModal(r,'withdraw')">撤回</NButton> -->
|
||||
<!-- 已批准待实施 -->
|
||||
<!-- <NButton v-if="r.status==='APPROVED'" size="small" ghost type="primary" class="text-xs" @click="openModal(r,'unimplemented')">未实施情况说明</NButton> -->
|
||||
<!-- <NButton v-if="r.status==='APPROVED'" size="small" ghost type="success" class="text-xs" @click="downloadFiles(r,'apply')">下载申请表</NButton> -->
|
||||
<!-- 已验收待关闭 -->
|
||||
<!-- <NButton v-if="r.status==='ACCEPTED'" size="small" ghost type="primary" class="text-xs" @click="openModal(r,'unuse')">未投用情况说明</NButton> -->
|
||||
<!-- <NButton v-if="r.status==='ACCEPTED'" size="small" ghost type="success" class="text-xs" @click="downloadFiles(r,'accepted')">下载</NButton> -->
|
||||
<!-- 已关闭 -->
|
||||
<!-- <NButton v-if="r.status==='CLOSED'" size="small" ghost type="success" class="text-xs" @click="downloadFiles(r,'closed')">下载归档包</NButton> -->
|
||||
<NButton size="small" type="primary" class="text-xs" @click="jumpTo(r)">详情</NButton>
|
||||
<NButton size="small" type="default" class="text-xs" @click="openProgress(r)">进度</NButton>
|
||||
</div>
|
||||
<!-- 临时变更 -->
|
||||
<div v-if="r.duration_type === 2" class="text-right text-[10px] leading-4 text-slate-400">临时变更恢复 / 延期 / 转永久请在详情页中操作</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="list.length === 0" class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无数据"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-3">
|
||||
<NPagination
|
||||
v-model:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:item-count="pagination.itemCount"
|
||||
:on-update:page="pageChange"
|
||||
>
|
||||
<template #prefix="{ itemCount }">
|
||||
共 {{ itemCount }} 条
|
||||
</template>
|
||||
</NPagination>
|
||||
</div>
|
||||
<!-- 撤回/未实施情况说明/未投用情况说明弹框 -->
|
||||
<NModal
|
||||
v-model:show="revokeModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '450px', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-sm font-bold text-slate-800">
|
||||
<span v-if="modalType === 'withdraw'">撤回变更申请</span>
|
||||
<span v-else-if="modalType === 'unuse'">未投用情况说明</span>
|
||||
<span v-else>未实施情况说明</span>
|
||||
{{currentRow?.change_no}}
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
|
||||
{{ modalType === 'withdraw'
|
||||
? '撤回后申请退回草稿状态,已产生的审批记录保留可查;修改内容后可重新提交。仅本人发起的审批中变更可撤回。'
|
||||
: '变更审批通过 / 投用后未实施或未投用的,由申请人填写情况说明,提交后变更转入关闭流程,由变更主管部门专责确认关闭并记录关闭时间。' }}
|
||||
</div>
|
||||
<NInput
|
||||
class="mt-3"
|
||||
v-model:value="reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="modalType === 'withdraw' ? '撤回理由(必填)…' : '情况说明(必填):原因、现场当前状态、后续处置安排…'"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-[10px]">
|
||||
<NButton size="small" @click="revokeModal = false">取消</NButton>
|
||||
<NButton size="small" type="primary" :loading="revokeSaving" @click="revokeSave">提交</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
<!-- 进度弹框 -->
|
||||
<NDrawer v-model:show="progressDrawer" :width="502" placement="right" :auto-focus="false">
|
||||
<NDrawerContent title="变更进度">
|
||||
<div class="rounded-lg border bg-slate-50/60 px-3 py-2.5">
|
||||
<div class="text-sm font-semibold text-slate-800">{{ currentRow?.title }}</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">{{ currentRow?.change_no }}</span>
|
||||
<NTag v-if="currentRow.status==='APPROVING'" size="small" type="warning">审批中</NTag>
|
||||
<NTag v-if="currentRow.status==='APPROVED'" size="small" type="success">已通过</NTag>
|
||||
<NTag v-if="currentRow.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
|
||||
<NTag v-if="currentRow.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
|
||||
<NTag v-if="currentRow.status==='CLOSED'" size="small" type="success">已关闭</NTag>
|
||||
<NTag v-if="currentRow.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
|
||||
<NTag v-if="currentRow.status==='REJECTED'" size="small" type="error">已驳回</NTag>
|
||||
<span v-if="currentRow?.overdue_status===2" class="font-medium text-red-500">超期 {{ currentRow?.overdue_days }} 天</span>
|
||||
</div>
|
||||
<div v-if="currentRow?.dept || currentRow?.applicant" class="mt-1 text-[11px] text-slate-400">{{ [currentRow?.dept, currentRow?.applicant].filter(Boolean).join(' · ') }}</div>
|
||||
<div v-if="currentRow?.meta" class="mt-1 text-[11px] text-slate-400">{{ currentRow?.meta }}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div v-for="(s, i) in currentRow?.approval_records" :key="s.id" class="flex gap-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-[#7CC242] bg-[#7CC242] text-white">
|
||||
<Icon icon="ix:success" />
|
||||
</span>
|
||||
<span
|
||||
v-if="Number(i) < Number(currentRow?.approval_records?.length) - 1"
|
||||
class="w-0.5 flex-1"
|
||||
:class="Number(i) < Number(currentRow?.approval_records?.length) ? 'bg-[#7CC242]' : 'bg-slate-200'"
|
||||
></span>
|
||||
</div>
|
||||
<div class="pb-5">
|
||||
<p class="text-sm">{{ s.action }}</p>
|
||||
<span class="mt-0.5 text-[11px] text-slate-400">{{s.finished_at?.slice(0, 16)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentRow?.duration_type === 2" class="rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2 text-[11px] leading-5 text-amber-700">
|
||||
临时变更:计划恢复 {{ currentRow?.restore_deadline ?? '—' }} · 恢复 / 延期 / 转永久请在详情页中操作
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NCard>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 338px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,688 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const revokeModal = ref(false);
|
||||
const revokeSaving = ref<boolean>(false);
|
||||
const progressDrawer = ref<boolean>(false);
|
||||
const dept = ref("全部组织");
|
||||
const openOnly = ref(false);
|
||||
const kw = ref("");
|
||||
const typeFilter = ref("全部");
|
||||
const statusFilter = ref("全部");
|
||||
const durFilter = ref("全部");
|
||||
const period = ref<string>("近一年");
|
||||
const customFrom = ref<any>(null);
|
||||
const customTo = ref<any>(null);
|
||||
const sort = ref<{ k: string; dir: "asc" | "desc" }>({ k: "applyTime", dir: "desc" });
|
||||
const me = "张工艺";
|
||||
const DEPTS = ["全部组织", "一车间", "二车间", "供应部", "质量部"];
|
||||
const deptOpts = DEPTS.map((d) => ({ label: d, value: d }));
|
||||
const LEDGER_ROWS = [
|
||||
{ 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 REF_NOW = new Date("2026-08-03T12:00:00");
|
||||
const PERIOD_DAYS: Record<string, number> = { 近一年: 365, 近半年: 183, 近三个月: 92, 近一个月: 31 };
|
||||
const rows = computed(() => {
|
||||
let list = LEDGER_ROWS.slice();
|
||||
if (dept.value !== "全部组织") list = list.filter((r) => r.dept === dept.value);
|
||||
if (openOnly.value) list = list.filter((r) => r.status !== "已关闭");
|
||||
if (typeFilter.value !== "全部") list = list.filter((r) => r.type === typeFilter.value);
|
||||
if (statusFilter.value !== "全部") list = list.filter((r) => r.status === statusFilter.value);
|
||||
if (durFilter.value !== "全部") list = list.filter((r) => r.duration === durFilter.value);
|
||||
if (period.value !== "自定义") {
|
||||
const from = new Date(REF_NOW.getTime() - PERIOD_DAYS[period.value] * 86400000);
|
||||
list = list.filter((r) => new Date(r.applyTime.replace(" ", "T")) >= from);
|
||||
} else {
|
||||
if (customFrom.value) list = list.filter((r) => r.applyTime.slice(0, 10) >= customFrom.value);
|
||||
if (customTo.value) list = list.filter((r) => r.applyTime.slice(0, 10) <= customTo.value);
|
||||
}
|
||||
if (kw.value.trim()) list = list.filter((r) => r.title.includes(kw.value.trim()) || r.id.includes(kw.value.trim()));
|
||||
list.sort((a:any, b:any) => {
|
||||
const va = a[sort.value.k], vb = b[sort.value.k];
|
||||
return sort.value.dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
});
|
||||
return list;
|
||||
});
|
||||
const navPreset = ref<string>("mine");
|
||||
const finalRows = computed(() => {
|
||||
switch (navPreset.value) {
|
||||
case "mine": return rows.value.filter((r:any) => r.applicant === me);
|
||||
case "important": return rows.value.filter((r:any) => r.level === "重要");
|
||||
case "temp": return rows.value.filter((r:any) => r.duration === "临时");
|
||||
case "overdue": return rows.value.filter((r:any) => r.overdueDays);
|
||||
case "highrisk": return rows.value.filter((r:any) => r.level === "重要" && r.status !== "已关闭");
|
||||
default: return rows.value;
|
||||
}
|
||||
});
|
||||
const TYPE_FILTERS = ["全部", "工艺", "设备", "管理"];
|
||||
const DUR_FILTERS = ["全部", "永久", "临时"];
|
||||
const PERIOD_FILTERS = ["近一年", "近半年", "近三个月", "近一个月", "自定义"];
|
||||
const STATUS_FILTERS = ["全部", "审批中", "已批准", "实施中", "待PSSR", "待验收", "待关闭", "已关闭"];
|
||||
const sortVal = computed({
|
||||
get: () => `${sort.value.k}-${sort.value.dir}`,
|
||||
set: (v: string) => { const [k, dir] = v.split("-") as [string, "asc" | "desc"]; sort.value = { k, dir }; },
|
||||
});
|
||||
const SORT_OPTS = [
|
||||
{ label: "申请时间 · 最新", value: "applyTime-desc" },
|
||||
{ label: "申请时间 · 最早", value: "applyTime-asc" },
|
||||
{ label: "计划投用 · 最近", value: "planUse-asc" },
|
||||
{ label: "计划投用 · 最晚", value: "planUse-desc" },
|
||||
{ label: "状态更新 · 最新", value: "statusTime-desc" },
|
||||
{ label: "状态更新 · 最早", value: "statusTime-asc" },
|
||||
];
|
||||
interface LedgerExtra {
|
||||
special?: ("延期" | "临时转永久" | "未实施" | "未投用")[];
|
||||
actualUse?: string; planRestore?: string; extRestore?: string; closeTime?: string;
|
||||
}
|
||||
const LEDGER_EXTRA: Record<string, LedgerExtra> = {
|
||||
"MOC-2026-R01-0039": { special: ["延期"], actualUse: "2026-07-16 08:30", planRestore: "2026-07-27", extRestore: "2026-08-03" },
|
||||
"MOC-2026-R01-0029": { special: ["临时转永久"], planRestore: "2026-08-01" },
|
||||
"MOC-2026-R02-0033": { special: ["未实施"] },
|
||||
"MOC-2026-R03-0040": { special: ["未投用"] },
|
||||
"MOC-2026-R02-0031": { actualUse: "2026-07-15 09:20" },
|
||||
"MOC-2026-R03-0027": { actualUse: "2026-06-25 10:00", closeTime: "2026-07-02 10:18" },
|
||||
"MOC-2026-R01-0024": { actualUse: "2026-06-20 08:10", closeTime: "2026-06-28 16:30" },
|
||||
};
|
||||
const SPECIAL_COLOR: Record<string, string> = {
|
||||
延期: "bg-amber-50 text-amber-700 border-amber-300",
|
||||
临时转永久: "bg-[#DFE9F6] text-[#17407F] border-[#6E93C6]/50",
|
||||
未实施: "bg-red-50 text-red-600 border-red-200",
|
||||
未投用: "bg-red-50 text-red-600 border-red-200",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
审批中: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
已批准: "bg-[#DFE9F6] text-[#17407F] border-[#ACC6E5]",
|
||||
实施中: "bg-[#1D4E9C]/10 text-[#1D4E9C] border-[#1D4E9C]/30",
|
||||
待PSSR: "bg-violet-100 text-violet-700 border-violet-200",
|
||||
待验收: "bg-emerald-50 text-emerald-600 border-emerald-200",
|
||||
待关闭: "bg-[#E15555]/10 text-[#E15555] border-[#E15555]/30",
|
||||
已关闭: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
};
|
||||
function opsOf(r: any, me: string, role?: string): { label: string; act: string; danger?: boolean }[] {
|
||||
const ops: { label: string; act: string; danger?: boolean }[] = [];
|
||||
if (r.status === "审批中" && r.applicant === me) ops.push({ label: "撤回", act: "withdraw", danger: true });
|
||||
if (r.status === "已批准" || r.status === "待PSSR") ops.push({ label: "未实施情况说明", act: "unimpl" });
|
||||
if (r.status === "实施中") ops.push({ label: "未投用情况说明", act: "unuse" });
|
||||
if (role === "dept" && (r.overdueDays ?? 0) > 0) ops.push({ label: "催办", act: "urge" });
|
||||
ops.push({ label: "详情", act: "detail" }, { label: "进度", act: "progress" });
|
||||
if (r.status === "已批准") ops.push({ label: "下载申请表", act: "download" });
|
||||
if (r.status === "待关闭") ops.push({ label: "下载", act: "download" });
|
||||
if (r.status === "已关闭") ops.push({ label: "下载归档包", act: "download" });
|
||||
return ops;
|
||||
}
|
||||
const FLOW_IMPORTANT = [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "process2", "quality"],
|
||||
status: "current",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "周设备(设备)", done: false },
|
||||
{ name: "陈质量(质量)", done: false },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "todo", locked: true },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
|
||||
];
|
||||
const FLOW_SIMPLE = [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "current" },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
];
|
||||
const CHANGES = [
|
||||
{
|
||||
id: "MOC-2026-R01-0035",
|
||||
title: "R-201 反应釜搅拌器电机更换(55kW→75kW 防爆电机)",
|
||||
type: "设备",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "R-201 反应釜 / M-201A 搅拌电机",
|
||||
date: "2026-07-28",
|
||||
purpose: "原 55kW 电机在高粘度工况下频繁过载跳闸,影响反应均匀性与产品质量,需更换为 75kW 防爆电机。",
|
||||
effect: "消除过载跳闸(月均 3 次降至 0),搅拌电流裕量提升至 25%,产品批次合格率预计提升 1.5%。",
|
||||
before: "M-201A 搅拌电机:55kW,ExdⅡBT4,额定电流 102A,原设计工况粘度 ≤3000cP。",
|
||||
after: "更换为 75kW 防爆电机:ExdⅡCT4,额定电流 138A,适配粘度 ≤6000cP;电缆与开关容量同步校核升级。",
|
||||
related: ["更新 P&ID(图号 PID-R201-03)", "修订电气图纸与电缆清册", "校核联锁跳车电流设定", "更新设备台账与备件清单", "操作人员培训"],
|
||||
materials: ["设备数据表", "防爆合格证与材质证明", "电气负荷校核计算书", "更新后的 P&ID", "制造商安装维护手册"],
|
||||
riskTools: ["AI 分析", "JSA", "检查表法"],
|
||||
flow: FLOW_IMPORTANT,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0036",
|
||||
title: "聚合反应温度控制上限调整(78℃→82℃)",
|
||||
type: "工艺",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "孙丽",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "R-101 聚合釜 / TIC-101",
|
||||
date: "2026-07-25",
|
||||
purpose: "提高反应温度上限以缩短聚合时间、提升装置产能,满足新增订单需求。",
|
||||
effect: "单釜反应周期缩短约 40 分钟,年产能预计提升 8%。",
|
||||
before: "聚合反应温度控制范围 65–78℃,TIC-101 高报 78℃、联锁 80℃。",
|
||||
after: "控制范围调整为 65–82℃,TIC-101 高报 82℃、联锁值维持 85℃ 安全边界不变。",
|
||||
related: ["修订工艺卡片与操作规程", "DCS 参数与报警值修改", "更新 HAZOP 分析报告", "岗位人员再培训"],
|
||||
materials: ["工艺计算书", "热平衡与飞温风险分析", "HAZOP 分析报告", "更新后的工艺卡片"],
|
||||
riskTools: ["AI 分析", "HAZOP"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "quality"],
|
||||
status: "done",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "陈质量(质量)", done: true },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "current", locked: true },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R02-0038",
|
||||
title: "DCS 罐区液位报警值 LIA-301 调整",
|
||||
type: "工艺",
|
||||
level: "一般",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "待PSSR",
|
||||
applicant: "吴强",
|
||||
org: "一车间 · 罐区",
|
||||
device: "V-301 储罐 / LIA-301",
|
||||
date: "2026-07-20",
|
||||
purpose: "原高报值偏保守,频繁误报导致操作疲劳,按最新容积表校准报警值。",
|
||||
effect: "误报次数由每周 5 次降至 0,报警响应有效性提升。",
|
||||
before: "LIA-301 高报 80%(对应 7.2m)。",
|
||||
after: "高报调整为 85%(对应 7.65m),联锁值 90% 不变。",
|
||||
related: ["DCS 组态修改", "更新报警台账", "岗位告知培训"],
|
||||
materials: ["储罐容积表", "DCS 组态修改记录", "报警台账"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: FLOW_SIMPLE.map((n) => ({ ...n, status: "done" as const })),
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0039",
|
||||
title: "紧急变更:V-102 安全阀起跳压力临时调整(补办手续)",
|
||||
type: "设备",
|
||||
level: "一般",
|
||||
duration: "临时",
|
||||
urgent: true,
|
||||
status: "待关闭",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "V-102 缓冲罐 / PSV-102",
|
||||
date: "2026-07-15",
|
||||
purpose: "上游工况波动导致安全阀频繁起跳,经紧急批准临时调整起跳压力并限期恢复原设定。",
|
||||
effect: "装置维持连续运行,临时期限内风险受控。",
|
||||
before: "PSV-102 起跳压力 0.8MPa。",
|
||||
after: "临时调整为 0.88MPa(不超过设计压力 1.0MPa 的 90%),期限 30 天,到期恢复 0.8MPa。",
|
||||
related: ["恢复性检查", "更新安全阀台账", "事后风险分析补办"],
|
||||
materials: ["安全阀校验报告", "紧急变更审批单", "事后 JSA 分析"],
|
||||
riskTools: ["AI 分析", "JSA"],
|
||||
flow: FLOW_SIMPLE.map((n) => ({ ...n, status: "done" as const })),
|
||||
overdueDays: 4,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R03-0040",
|
||||
title: "原料环己烷供应商变更(新增 B 供应商)",
|
||||
type: "管理",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "实施中",
|
||||
applicant: "刘芳",
|
||||
org: "供应部",
|
||||
device: "原料罐区 / TK-201",
|
||||
date: "2026-07-18",
|
||||
purpose: "引入第二供应商保障原料供应安全,降低单一来源断供风险。",
|
||||
effect: "供应保障能力提升,采购成本预计下降 3%。",
|
||||
before: "环己烷单一供应商 A,纯度 ≥99.5%。",
|
||||
after: "新增供应商 B(纯度 ≥99.8%,已提供 MSDS 与质检报告),双源供应。",
|
||||
related: ["更新合格供应商名录", "修订进厂检验规程", "更新 MSDS 档案", "卸料操作培训"],
|
||||
materials: ["新供应商资质文件", "产品质检报告与 MSDS", "进厂检验规程"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: FLOW_IMPORTANT.map((n) => ({ ...n, status: "done" as const })),
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0029",
|
||||
title: "操作规程修订(夏季工况)",
|
||||
type: "管理",
|
||||
level: "一般",
|
||||
duration: "临时",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "全装置",
|
||||
date: "2026-06-20",
|
||||
purpose: "夏季高温工况下循环水温度升高,原操作规程冷却参数不适用,需修订夏季操作参数与巡检频次。",
|
||||
effect: "夏季工况下装置运行参数受控,避免超温降负荷,预计减少非计划降量 2 次/年。",
|
||||
before: "操作规程(2025 版)未区分冬夏季工况,冷却参数单一。",
|
||||
after: "增加夏季工况章节:循环水温度 >28℃ 时启用备用冷却塔,巡检频次由 4h 调整为 2h。",
|
||||
related: ["操作规程升版发布", "岗位人员培训"],
|
||||
materials: ["修订后操作规程", "参数核算表"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "current" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "todo", locked: true },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
],
|
||||
overdueDays: 3,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R02-0041",
|
||||
title: "公用工程氮气减压阀组改造",
|
||||
type: "设备",
|
||||
level: "一般",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "周涛",
|
||||
org: "公用工程车间",
|
||||
device: "氮气总管 / PV-208 减压阀组",
|
||||
date: "2026-07-29",
|
||||
purpose: "氮气减压阀组单路运行,检修时需停氮气,影响装置密封气供应,需改造为双路并联。",
|
||||
effect: "减压阀组可在线切换检修,氮气供应连续性提升至 100%。",
|
||||
before: "PV-208 单路减压,无备用回路。",
|
||||
after: "新增并联减压回路 PV-208B,双路一用一备,可在线切换。",
|
||||
related: ["更新 P&ID", "修订氮气系统操作规程", "更新设备台账"],
|
||||
materials: ["阀门数据表", "管道单线图", "施工方案"],
|
||||
riskTools: ["AI 分析", "JSA"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "current" },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
],
|
||||
overdueDays: 3,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R03-0042",
|
||||
title: "三车间自动化和安全隐患整改项目变更",
|
||||
type: "管理",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "刘芳",
|
||||
org: "三车间",
|
||||
device: "三车间装置区",
|
||||
date: "2026-07-31",
|
||||
purpose: "落实安全隐患整改要求,对三车间自动化控制系统升级改造,消除加料互串与超温隐患。",
|
||||
effect: "隐患整改闭环率 100%,自动化水平提升,误操作风险显著降低。",
|
||||
before: "加料系统手动控制,存在互串与超温隐患(隐患编号 YH-2026-017)。",
|
||||
after: "改为 DCS 自动控制 + 加料顺序联锁,增设独立超温切断。",
|
||||
related: ["修订操作规程", "DCS 组态修改", "更新 HAZOP 分析", "岗位再培训"],
|
||||
materials: ["隐患整改方案", "HAZOP 分析报告", "联锁因果表"],
|
||||
riskTools: ["AI 分析", "HAZOP"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "process2", "quality"],
|
||||
status: "done",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "周设备(设备)", done: true },
|
||||
{ name: "陈质量(质量)", done: true },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "done" },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "current" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const progressRow = ref<any>(null);
|
||||
const ledgerAct = ref<{ id: string; act: "withdraw" | "unimpl" | "unuse" } | null>(null);
|
||||
const ledgerReason = ref("");
|
||||
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()); }
|
||||
interface UrgeRecord { id: string; changeId: string; title: string; applicant: string; by: string; time: string; }
|
||||
let urgeSeq = 1;
|
||||
const URGE_RECORDS: UrgeRecord[] = [];
|
||||
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 urgeChange(changeId: string, byRole: string) {
|
||||
const row = LEDGER_ROWS.find((r) => r.id === changeId || r.changeId === changeId);
|
||||
const c = CHANGES.find((x) => x.id === changeId);
|
||||
const by = ROLES.find((r:any) => r.id === byRole)?.person ?? byRole;
|
||||
URGE_RECORDS.unshift({
|
||||
id: `URGE-${urgeSeq++}`,
|
||||
changeId,
|
||||
title: row?.title ?? c?.title ?? changeId,
|
||||
applicant: row?.applicant ?? c?.applicant ?? "",
|
||||
by,
|
||||
time: nowStr(),
|
||||
});
|
||||
emitDemo();
|
||||
}
|
||||
|
||||
const stages = ["提交申请", "审批", "实施与投用前确认", "投用", "验收", "关闭"];
|
||||
const curIdx = computed(() => ({ 审批中: 1, 已批准: 2, 实施中: 2, 待PSSR: 2, 待验收: 4, 待关闭: 5, 已关闭: 6 } as Record<string, number>)[progressRow.value?.status ?? ""] ?? 1);
|
||||
const closed = computed(() => progressRow.value?.status === "已关闭");
|
||||
const timeOf = (i: number) => (i === 0 ? progressRow.value?.applyTime : i === 3 ? progressRow.value?.actualUse : i === 5 ? progressRow.value?.closeTime : undefined);
|
||||
|
||||
const role = ref<string>("applicant");
|
||||
const doOp = (r: any, act: string) => {
|
||||
if (act === "detail") {
|
||||
const c = CHANGES.find((x) => x.id === r.changeId);
|
||||
if (c) {
|
||||
// 跳转详情页面
|
||||
// emit("openChange", c);
|
||||
}else {
|
||||
window.$message?.info(`演示:打开变更详情页 ${r.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (act === "progress") {
|
||||
progressRow.value = r;
|
||||
progressDrawer.value = true;
|
||||
return;
|
||||
}
|
||||
if (act === "withdraw" || act === "unimpl" || act === "unuse") {
|
||||
ledgerAct.value = { id: r.id, act };
|
||||
ledgerReason.value = "";
|
||||
revokeModal.value = true;
|
||||
return;
|
||||
}
|
||||
if (act === "urge") {
|
||||
urgeChange(r.id, role.value ?? "dept");
|
||||
window.$message?.success(`已催办 ${r.id} 申请人 ${r.applicant}(系统消息 + 企业微信),催办记录已留痕并抄送变更主管部门,申请人操作台将出现催办提示`);
|
||||
return;
|
||||
}
|
||||
window.$message?.info(`演示:下载 ${r.id} 变更申请表 / 归档包(PDF)`);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
dept.value = "全部组织"; openOnly.value = false; kw.value = ""; typeFilter.value = "全部"; statusFilter.value = "全部";
|
||||
durFilter.value = "全部"; period.value = "近一年"; customFrom.value = null; customTo.value = null;
|
||||
sort.value = { k: "applyTime", dir: "desc" };
|
||||
window.$message?.success("筛选条件已重置");
|
||||
};
|
||||
// 导出台账
|
||||
const exportChanges = () => {
|
||||
window.$message?.success(`演示:导出台账 Excel(当前筛选 ${finalRows.value.length} 条)`);
|
||||
}
|
||||
// 撤回弹框取消
|
||||
const revokeCancel = () => {
|
||||
ledgerAct.value = null;
|
||||
ledgerReason.value = "";
|
||||
revokeModal.value = false;
|
||||
}
|
||||
// 撤回弹框提交
|
||||
const revokeSave = () => {
|
||||
if (!ledgerReason.value) {
|
||||
window.$message?.warning("请输入撤回理由");
|
||||
return;
|
||||
}
|
||||
window.$message?.success(ledgerAct.value?.act === 'withdraw' ? `已撤回 ${ledgerAct.value?.id},申请退回草稿箱,审批记录保留可查` : `${ledgerAct.value?.id} 情况说明已提交,变更转入关闭流程(主管部门确认后记录关闭时间)`);
|
||||
revokeCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我发起的变更 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<!-- 工具条 -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border px-3 py-2"
|
||||
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" /> 全部变更
|
||||
<span class="rounded bg-slate-100 px-1.5 text-xs text-slate-500">{{ finalRows.length }}</span>
|
||||
</span>
|
||||
<NSelect size="small" class="!w-32" :value="dept" :options="deptOpts" @update:value="(v: string) => dept = v" />
|
||||
<NRadio
|
||||
size="small"
|
||||
:checked="!openOnly"
|
||||
:value="false"
|
||||
name="显示全部"
|
||||
@change="openOnly = false"
|
||||
>
|
||||
显示全部
|
||||
</NRadio>
|
||||
<NRadio
|
||||
size="small"
|
||||
:checked="openOnly"
|
||||
:value="true"
|
||||
name="显示未关闭"
|
||||
@change="openOnly = true"
|
||||
>
|
||||
显示未关闭
|
||||
</NRadio>
|
||||
<div class="relative ml-auto">
|
||||
<NInput v-model:value="kw" size="small" type="text" clearable placeholder="搜索变更名称 / 编号">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
<NButton size="small" class="text-sm" @click="reset">
|
||||
<Icon icon="system-uicons:reset" class="size-12px" />重置
|
||||
</NButton>
|
||||
<NButton size="small" class="text-sm" @click="exportChanges">
|
||||
<Icon icon="material-symbols:download" class="size-14px" />导出台账
|
||||
</NButton>
|
||||
</div>
|
||||
<!-- 筛选与排序行 -->
|
||||
<div
|
||||
class="flex flex-col gap-y-2 border px-3 py-2 my-3"
|
||||
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">类型</span>
|
||||
<NButton size="tiny" round v-for="t in TYPE_FILTERS" :key="t" :ghost="typeFilter === t ? false : true" :type="typeFilter === t ? 'primary' : 'default'" @click="typeFilter = t">{{ t }}</NButton>
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时限</span>
|
||||
<NButton size="tiny" round v-for="d in DUR_FILTERS" :key="d" :ghost="durFilter === d ? false : true" :type="durFilter === d ? 'primary' : 'default'" @click="durFilter = d">{{ d }}</NButton>
|
||||
</p>
|
||||
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时间</span>
|
||||
<NButton size="tiny" round v-for="p in PERIOD_FILTERS" :key="p" :ghost="period === p ? false : true" :type="period === p ? 'primary' : 'default'" @click="period = p">{{ p }}</NButton>
|
||||
<span v-if="period === '自定义'" class="flex items-center gap-1">
|
||||
<NDatePicker v-model:value="customFrom" type="date" size="small" class="w-130px" />
|
||||
<span class="text-xs text-slate-400">至</span>
|
||||
<NDatePicker v-model:value="customTo" type="date" size="small" class="w-130px" />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">状态</span>
|
||||
<NButton size="tiny" round v-for="s in STATUS_FILTERS" :key="s" :ghost="statusFilter === s ? false : true" :type="statusFilter === s ? 'primary' : 'default'" @click="statusFilter = s">{{ s }}</NButton>
|
||||
</p>
|
||||
<p class="ml-auto flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">排序</span>
|
||||
<NSelect size="small" class="!w-36" v-model:value="sortVal" :options="SORT_OPTS" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 scroll">
|
||||
<!-- 列表 -->
|
||||
<div v-for="(r, i) in finalRows" :key="r.id" class="rounded-lg border bg-white px-4 py-3">
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-slate-300">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<NButton text class="text-left text-sm font-semibold text-slate-800 hover:text-[#1D4E9C] hover:underline" @click="doOp(r, 'detail')">{{ r.title }}</NButton>
|
||||
<NTag size="small" v-if="r.urgent" class="animate-breathe border-[#E15555] bg-[#E15555] text-white">紧急</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ r.id }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<NTag size="small" class="border-slate-200 bg-white text-slate-600">{{ r.type }}</NTag>
|
||||
<NTag size="small" :class="r.level === '重要' ? 'border-red-200 bg-red-100 text-red-700' : 'border-slate-200 bg-slate-100 text-slate-600'">{{ r.level }}</NTag>
|
||||
<NTag size="small" :class="r.duration === '临时' ? 'border-[#FAAD14] bg-[#FAAD14] text-white' : 'border-[#1D4E9C] bg-[#1D4E9C] text-white'">{{ r.duration }}</NTag>
|
||||
<NTag size="small" v-for="s in LEDGER_EXTRA[r.id]?.special ?? []" :key="s" :class="SPECIAL_COLOR[s]">{{ s }}</NTag>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-slate-500">
|
||||
<span>申请 {{ r.applyTime }}</span>
|
||||
<span>{{ r.dept }} · {{ r.applicant }}</span>
|
||||
<span>计划投用 {{ r.planUse }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.actualUse">实际投用 {{ LEDGER_EXTRA[r.id].actualUse }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.planRestore">计划恢复 {{ LEDGER_EXTRA[r.id].planRestore }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.extRestore" class="text-amber-600">延期后恢复 {{ LEDGER_EXTRA[r.id].extRestore }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.closeTime">关闭 {{ LEDGER_EXTRA[r.id].closeTime }}</span>
|
||||
<span>状态更新 {{ r.statusTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<span class="text-[11px] text-slate-400">{{ r.statusDetail }}</span>
|
||||
<NTag size="small" :class="STATUS_COLOR[r.status]">{{ r.status }}</NTag>
|
||||
<span v-if="r.overdueDays" class="text-[11px] font-medium text-red-500">超期 {{ r.overdueDays }} 天</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 pt-0.5">
|
||||
<NButton size="small" v-for="op in opsOf(r, me, role)" :key="op.label"
|
||||
class="text-xs"
|
||||
:ghost="op.act === 'detail' ? false : true"
|
||||
:type="op.act === 'detail' ? 'primary' : op.danger ? 'error' : 'default'"
|
||||
@click="doOp(r, op.act)">
|
||||
{{ op.label }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="r.duration === '临时'" class="text-right text-[10px] leading-4 text-slate-400">临时变更恢复 / 延期 / 转永久请在详情页中操作</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="finalRows.length === 0" class="rounded-lg border border-dashed bg-white py-12 text-center text-sm text-slate-400">当前筛选条件下暂无变更记录</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-3">
|
||||
共 {{ finalRows.length }} 条 · 台账以查看、下载为主(申请表 / 归档包);与"我"相关的待办流转请前往「我的任务」处理。点击变更名称可打开详情。
|
||||
</div>
|
||||
<!-- 撤回/未实施情况说明/未投用情况说明弹框 -->
|
||||
<NModal
|
||||
v-model:show="revokeModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '450px', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-sm font-bold text-slate-800">
|
||||
<span v-if="ledgerAct?.act === 'withdraw'">撤回变更申请</span>
|
||||
<span v-else-if="ledgerAct?.act === 'unuse'">未投用情况说明</span>
|
||||
<span v-else>未实施情况说明</span>
|
||||
{{ledgerAct?.id}}
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
|
||||
{{ ledgerAct?.act === 'withdraw'
|
||||
? '撤回后申请退回草稿状态,已产生的审批记录保留可查;修改内容后可重新提交。仅本人发起的审批中变更可撤回。'
|
||||
: '变更审批通过 / 投用后未实施或未投用的,由申请人填写情况说明,提交后变更转入关闭流程,由变更主管部门专责确认关闭并记录关闭时间。' }}
|
||||
</div>
|
||||
<NInput
|
||||
class="mt-3"
|
||||
v-model:value="ledgerReason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="ledgerAct?.act === 'withdraw' ? '撤回理由(必填)…' : '情况说明(必填):原因、现场当前状态、后续处置安排…'"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-[10px]">
|
||||
<NButton size="small" @click="revokeCancel">取消</NButton>
|
||||
<NButton size="small" type="primary" :loading="revokeSaving" @click="revokeSave">提交</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
<!-- 进度抽屉 -->
|
||||
<NDrawer v-model:show="progressDrawer" :width="502" placement="right">
|
||||
<NDrawerContent title="变更进度">
|
||||
<div class="rounded-lg border bg-slate-50/60 px-3 py-2.5">
|
||||
<div class="text-sm font-semibold text-slate-800">{{ progressRow?.title }}</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">{{ progressRow?.id }}</span>
|
||||
<NTag v-if="progressRow?.status==='审批中'" size="small" type="warning">{{progressRow.status}}</NTag>
|
||||
<NTag v-if="progressRow?.status==='已批准'" size="small" type="primary">{{progressRow.status}}</NTag>
|
||||
<NTag v-if="progressRow?.status==='待验收'" size="small" type="success">{{progressRow.status}}</NTag>
|
||||
<NTag v-if="progressRow?.status==='待关闭'" size="small" type="error">{{progressRow.status}}</NTag>
|
||||
<span v-if="progressRow?.overdue" class="font-medium text-red-500">超期 {{ progressRow?.day }} 天</span>
|
||||
</div>
|
||||
<div v-if="progressRow?.dept || progressRow?.applicant" class="mt-1 text-[11px] text-slate-400">{{ [progressRow?.dept, progressRow?.applicant].filter(Boolean).join(' · ') }}</div>
|
||||
<div v-if="progressRow?.meta" class="mt-1 text-[11px] text-slate-400">{{ progressRow?.meta }}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div v-for="(s, i) in stages" :key="s" class="flex gap-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2"
|
||||
:class="i < curIdx || closed ? 'border-[#7CC242] bg-[#7CC242] text-white' : i === curIdx && !closed ? 'border-[#1D4E9C] bg-[#1D4E9C] text-white' : 'border-slate-200 bg-white text-slate-300'">
|
||||
<Icon v-if="i < curIdx || closed" icon="ix:success" />
|
||||
<Icon v-else-if="i === curIdx" icon="tabler:clock" />
|
||||
<span v-else class="text-[10px]">{{ i + 1 }}</span>
|
||||
</span>
|
||||
<span v-if="i < stages.length - 1" class="w-0.5 flex-1" :class="i < curIdx ? 'bg-[#7CC242]' : 'bg-slate-200'" />
|
||||
</div>
|
||||
<div class="pb-5">
|
||||
<div class="text-sm" :class="i === curIdx && !closed ? 'font-semibold text-[#1D4E9C]' : i < curIdx || closed ? 'font-medium text-slate-700' : 'text-slate-400'">
|
||||
{{ s }}{{ i === curIdx && !closed ? '(当前)' : '' }}
|
||||
</div>
|
||||
<div v-if="timeOf(i)" class="mt-0.5 text-[11px] text-slate-400">{{ timeOf(i) }}</div>
|
||||
<div v-if="i === curIdx && !closed && (progressRow?.detail || progressRow?.updateTime)" class="mt-0.5 text-[11px] text-slate-400">
|
||||
{{ [progressRow?.detail, progressRow?.updateTime ? `更新 ${progressRow?.updateTime}` : ''].filter(Boolean).join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="progressRow?.dur === '临时'" class="rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2 text-[11px] leading-5 text-amber-700">
|
||||
临时变更:计划恢复 {{ progressRow?.deadline ?? '—' }}{{ progressRow?.extended ? ` · 延期后恢复 ${progressRow?.deadline}` : '' }} · 恢复 / 延期 / 转永久请在详情页中操作
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 326px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,688 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const revokeModal = ref(false);
|
||||
const revokeSaving = ref<boolean>(false);
|
||||
const progressDrawer = ref<boolean>(false);
|
||||
const dept = ref("全部组织");
|
||||
const openOnly = ref(false);
|
||||
const kw = ref("");
|
||||
const typeFilter = ref("全部");
|
||||
const statusFilter = ref("全部");
|
||||
const durFilter = ref("全部");
|
||||
const period = ref<string>("近一年");
|
||||
const customFrom = ref<any>(null);
|
||||
const customTo = ref<any>(null);
|
||||
const sort = ref<{ k: string; dir: "asc" | "desc" }>({ k: "applyTime", dir: "desc" });
|
||||
const me = "张工艺";
|
||||
const DEPTS = ["全部组织", "一车间", "二车间", "供应部", "质量部"];
|
||||
const deptOpts = DEPTS.map((d) => ({ label: d, value: d }));
|
||||
const LEDGER_ROWS = [
|
||||
{ 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 REF_NOW = new Date("2026-08-03T12:00:00");
|
||||
const PERIOD_DAYS: Record<string, number> = { 近一年: 365, 近半年: 183, 近三个月: 92, 近一个月: 31 };
|
||||
const rows = computed(() => {
|
||||
let list = LEDGER_ROWS.slice();
|
||||
if (dept.value !== "全部组织") list = list.filter((r) => r.dept === dept.value);
|
||||
if (openOnly.value) list = list.filter((r) => r.status !== "已关闭");
|
||||
if (typeFilter.value !== "全部") list = list.filter((r) => r.type === typeFilter.value);
|
||||
if (statusFilter.value !== "全部") list = list.filter((r) => r.status === statusFilter.value);
|
||||
if (durFilter.value !== "全部") list = list.filter((r) => r.duration === durFilter.value);
|
||||
if (period.value !== "自定义") {
|
||||
const from = new Date(REF_NOW.getTime() - PERIOD_DAYS[period.value] * 86400000);
|
||||
list = list.filter((r) => new Date(r.applyTime.replace(" ", "T")) >= from);
|
||||
} else {
|
||||
if (customFrom.value) list = list.filter((r) => r.applyTime.slice(0, 10) >= customFrom.value);
|
||||
if (customTo.value) list = list.filter((r) => r.applyTime.slice(0, 10) <= customTo.value);
|
||||
}
|
||||
if (kw.value.trim()) list = list.filter((r) => r.title.includes(kw.value.trim()) || r.id.includes(kw.value.trim()));
|
||||
list.sort((a:any, b:any) => {
|
||||
const va = a[sort.value.k], vb = b[sort.value.k];
|
||||
return sort.value.dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
});
|
||||
return list;
|
||||
});
|
||||
const navPreset = ref<string>("");
|
||||
const finalRows = computed(() => {
|
||||
switch (navPreset.value) {
|
||||
case "mine": return rows.value.filter((r:any) => r.applicant === me);
|
||||
case "important": return rows.value.filter((r:any) => r.level === "重要");
|
||||
case "temp": return rows.value.filter((r:any) => r.duration === "临时");
|
||||
case "overdue": return rows.value.filter((r:any) => r.overdueDays);
|
||||
case "highrisk": return rows.value.filter((r:any) => r.level === "重要" && r.status !== "已关闭");
|
||||
default: return rows.value;
|
||||
}
|
||||
});
|
||||
const TYPE_FILTERS = ["全部", "工艺", "设备", "管理"];
|
||||
const DUR_FILTERS = ["全部", "永久", "临时"];
|
||||
const PERIOD_FILTERS = ["近一年", "近半年", "近三个月", "近一个月", "自定义"];
|
||||
const STATUS_FILTERS = ["全部", "审批中", "已批准", "实施中", "待PSSR", "待验收", "待关闭", "已关闭"];
|
||||
const sortVal = computed({
|
||||
get: () => `${sort.value.k}-${sort.value.dir}`,
|
||||
set: (v: string) => { const [k, dir] = v.split("-") as [string, "asc" | "desc"]; sort.value = { k, dir }; },
|
||||
});
|
||||
const SORT_OPTS = [
|
||||
{ label: "申请时间 · 最新", value: "applyTime-desc" },
|
||||
{ label: "申请时间 · 最早", value: "applyTime-asc" },
|
||||
{ label: "计划投用 · 最近", value: "planUse-asc" },
|
||||
{ label: "计划投用 · 最晚", value: "planUse-desc" },
|
||||
{ label: "状态更新 · 最新", value: "statusTime-desc" },
|
||||
{ label: "状态更新 · 最早", value: "statusTime-asc" },
|
||||
];
|
||||
interface LedgerExtra {
|
||||
special?: ("延期" | "临时转永久" | "未实施" | "未投用")[];
|
||||
actualUse?: string; planRestore?: string; extRestore?: string; closeTime?: string;
|
||||
}
|
||||
const LEDGER_EXTRA: Record<string, LedgerExtra> = {
|
||||
"MOC-2026-R01-0039": { special: ["延期"], actualUse: "2026-07-16 08:30", planRestore: "2026-07-27", extRestore: "2026-08-03" },
|
||||
"MOC-2026-R01-0029": { special: ["临时转永久"], planRestore: "2026-08-01" },
|
||||
"MOC-2026-R02-0033": { special: ["未实施"] },
|
||||
"MOC-2026-R03-0040": { special: ["未投用"] },
|
||||
"MOC-2026-R02-0031": { actualUse: "2026-07-15 09:20" },
|
||||
"MOC-2026-R03-0027": { actualUse: "2026-06-25 10:00", closeTime: "2026-07-02 10:18" },
|
||||
"MOC-2026-R01-0024": { actualUse: "2026-06-20 08:10", closeTime: "2026-06-28 16:30" },
|
||||
};
|
||||
const SPECIAL_COLOR: Record<string, string> = {
|
||||
延期: "bg-amber-50 text-amber-700 border-amber-300",
|
||||
临时转永久: "bg-[#DFE9F6] text-[#17407F] border-[#6E93C6]/50",
|
||||
未实施: "bg-red-50 text-red-600 border-red-200",
|
||||
未投用: "bg-red-50 text-red-600 border-red-200",
|
||||
};
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
审批中: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
已批准: "bg-[#DFE9F6] text-[#17407F] border-[#ACC6E5]",
|
||||
实施中: "bg-[#1D4E9C]/10 text-[#1D4E9C] border-[#1D4E9C]/30",
|
||||
待PSSR: "bg-violet-100 text-violet-700 border-violet-200",
|
||||
待验收: "bg-emerald-50 text-emerald-600 border-emerald-200",
|
||||
待关闭: "bg-[#E15555]/10 text-[#E15555] border-[#E15555]/30",
|
||||
已关闭: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
};
|
||||
function opsOf(r: any, me: string, role?: string): { label: string; act: string; danger?: boolean }[] {
|
||||
const ops: { label: string; act: string; danger?: boolean }[] = [];
|
||||
if (r.status === "审批中" && r.applicant === me) ops.push({ label: "撤回", act: "withdraw", danger: true });
|
||||
if (r.status === "已批准" || r.status === "待PSSR") ops.push({ label: "未实施情况说明", act: "unimpl" });
|
||||
if (r.status === "实施中") ops.push({ label: "未投用情况说明", act: "unuse" });
|
||||
if (role === "dept" && (r.overdueDays ?? 0) > 0) ops.push({ label: "催办", act: "urge" });
|
||||
ops.push({ label: "详情", act: "detail" }, { label: "进度", act: "progress" });
|
||||
if (r.status === "已批准") ops.push({ label: "下载申请表", act: "download" });
|
||||
if (r.status === "待关闭") ops.push({ label: "下载", act: "download" });
|
||||
if (r.status === "已关闭") ops.push({ label: "下载归档包", act: "download" });
|
||||
return ops;
|
||||
}
|
||||
const FLOW_IMPORTANT = [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "process2", "quality"],
|
||||
status: "current",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "周设备(设备)", done: false },
|
||||
{ name: "陈质量(质量)", done: false },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "todo", locked: true },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
|
||||
];
|
||||
const FLOW_SIMPLE = [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "current" },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
];
|
||||
const CHANGES = [
|
||||
{
|
||||
id: "MOC-2026-R01-0035",
|
||||
title: "R-201 反应釜搅拌器电机更换(55kW→75kW 防爆电机)",
|
||||
type: "设备",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "R-201 反应釜 / M-201A 搅拌电机",
|
||||
date: "2026-07-28",
|
||||
purpose: "原 55kW 电机在高粘度工况下频繁过载跳闸,影响反应均匀性与产品质量,需更换为 75kW 防爆电机。",
|
||||
effect: "消除过载跳闸(月均 3 次降至 0),搅拌电流裕量提升至 25%,产品批次合格率预计提升 1.5%。",
|
||||
before: "M-201A 搅拌电机:55kW,ExdⅡBT4,额定电流 102A,原设计工况粘度 ≤3000cP。",
|
||||
after: "更换为 75kW 防爆电机:ExdⅡCT4,额定电流 138A,适配粘度 ≤6000cP;电缆与开关容量同步校核升级。",
|
||||
related: ["更新 P&ID(图号 PID-R201-03)", "修订电气图纸与电缆清册", "校核联锁跳车电流设定", "更新设备台账与备件清单", "操作人员培训"],
|
||||
materials: ["设备数据表", "防爆合格证与材质证明", "电气负荷校核计算书", "更新后的 P&ID", "制造商安装维护手册"],
|
||||
riskTools: ["AI 分析", "JSA", "检查表法"],
|
||||
flow: FLOW_IMPORTANT,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0036",
|
||||
title: "聚合反应温度控制上限调整(78℃→82℃)",
|
||||
type: "工艺",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "孙丽",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "R-101 聚合釜 / TIC-101",
|
||||
date: "2026-07-25",
|
||||
purpose: "提高反应温度上限以缩短聚合时间、提升装置产能,满足新增订单需求。",
|
||||
effect: "单釜反应周期缩短约 40 分钟,年产能预计提升 8%。",
|
||||
before: "聚合反应温度控制范围 65–78℃,TIC-101 高报 78℃、联锁 80℃。",
|
||||
after: "控制范围调整为 65–82℃,TIC-101 高报 82℃、联锁值维持 85℃ 安全边界不变。",
|
||||
related: ["修订工艺卡片与操作规程", "DCS 参数与报警值修改", "更新 HAZOP 分析报告", "岗位人员再培训"],
|
||||
materials: ["工艺计算书", "热平衡与飞温风险分析", "HAZOP 分析报告", "更新后的工艺卡片"],
|
||||
riskTools: ["AI 分析", "HAZOP"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "quality"],
|
||||
status: "done",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "陈质量(质量)", done: true },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "current", locked: true },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "todo", locked: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R02-0038",
|
||||
title: "DCS 罐区液位报警值 LIA-301 调整",
|
||||
type: "工艺",
|
||||
level: "一般",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "待PSSR",
|
||||
applicant: "吴强",
|
||||
org: "一车间 · 罐区",
|
||||
device: "V-301 储罐 / LIA-301",
|
||||
date: "2026-07-20",
|
||||
purpose: "原高报值偏保守,频繁误报导致操作疲劳,按最新容积表校准报警值。",
|
||||
effect: "误报次数由每周 5 次降至 0,报警响应有效性提升。",
|
||||
before: "LIA-301 高报 80%(对应 7.2m)。",
|
||||
after: "高报调整为 85%(对应 7.65m),联锁值 90% 不变。",
|
||||
related: ["DCS 组态修改", "更新报警台账", "岗位告知培训"],
|
||||
materials: ["储罐容积表", "DCS 组态修改记录", "报警台账"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: FLOW_SIMPLE.map((n) => ({ ...n, status: "done" as const })),
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0039",
|
||||
title: "紧急变更:V-102 安全阀起跳压力临时调整(补办手续)",
|
||||
type: "设备",
|
||||
level: "一般",
|
||||
duration: "临时",
|
||||
urgent: true,
|
||||
status: "待关闭",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "V-102 缓冲罐 / PSV-102",
|
||||
date: "2026-07-15",
|
||||
purpose: "上游工况波动导致安全阀频繁起跳,经紧急批准临时调整起跳压力并限期恢复原设定。",
|
||||
effect: "装置维持连续运行,临时期限内风险受控。",
|
||||
before: "PSV-102 起跳压力 0.8MPa。",
|
||||
after: "临时调整为 0.88MPa(不超过设计压力 1.0MPa 的 90%),期限 30 天,到期恢复 0.8MPa。",
|
||||
related: ["恢复性检查", "更新安全阀台账", "事后风险分析补办"],
|
||||
materials: ["安全阀校验报告", "紧急变更审批单", "事后 JSA 分析"],
|
||||
riskTools: ["AI 分析", "JSA"],
|
||||
flow: FLOW_SIMPLE.map((n) => ({ ...n, status: "done" as const })),
|
||||
overdueDays: 4,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R03-0040",
|
||||
title: "原料环己烷供应商变更(新增 B 供应商)",
|
||||
type: "管理",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "实施中",
|
||||
applicant: "刘芳",
|
||||
org: "供应部",
|
||||
device: "原料罐区 / TK-201",
|
||||
date: "2026-07-18",
|
||||
purpose: "引入第二供应商保障原料供应安全,降低单一来源断供风险。",
|
||||
effect: "供应保障能力提升,采购成本预计下降 3%。",
|
||||
before: "环己烷单一供应商 A,纯度 ≥99.5%。",
|
||||
after: "新增供应商 B(纯度 ≥99.8%,已提供 MSDS 与质检报告),双源供应。",
|
||||
related: ["更新合格供应商名录", "修订进厂检验规程", "更新 MSDS 档案", "卸料操作培训"],
|
||||
materials: ["新供应商资质文件", "产品质检报告与 MSDS", "进厂检验规程"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: FLOW_IMPORTANT.map((n) => ({ ...n, status: "done" as const })),
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R01-0029",
|
||||
title: "操作规程修订(夏季工况)",
|
||||
type: "管理",
|
||||
level: "一般",
|
||||
duration: "临时",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "张工艺",
|
||||
org: "一车间 · 工艺组",
|
||||
device: "全装置",
|
||||
date: "2026-06-20",
|
||||
purpose: "夏季高温工况下循环水温度升高,原操作规程冷却参数不适用,需修订夏季操作参数与巡检频次。",
|
||||
effect: "夏季工况下装置运行参数受控,避免超温降负荷,预计减少非计划降量 2 次/年。",
|
||||
before: "操作规程(2025 版)未区分冬夏季工况,冷却参数单一。",
|
||||
after: "增加夏季工况章节:循环水温度 >28℃ 时启用备用冷却塔,巡检频次由 4h 调整为 2h。",
|
||||
related: ["操作规程升版发布", "岗位人员培训"],
|
||||
materials: ["修订后操作规程", "参数核算表"],
|
||||
riskTools: ["AI 分析", "检查表法"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "current" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "todo", locked: true },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
],
|
||||
overdueDays: 3,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R02-0041",
|
||||
title: "公用工程氮气减压阀组改造",
|
||||
type: "设备",
|
||||
level: "一般",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "周涛",
|
||||
org: "公用工程车间",
|
||||
device: "氮气总管 / PV-208 减压阀组",
|
||||
date: "2026-07-29",
|
||||
purpose: "氮气减压阀组单路运行,检修时需停氮气,影响装置密封气供应,需改造为双路并联。",
|
||||
effect: "减压阀组可在线切换检修,氮气供应连续性提升至 100%。",
|
||||
before: "PV-208 单路减压,无备用回路。",
|
||||
after: "新增并联减压回路 PV-208B,双路一用一备,可在线切换。",
|
||||
related: ["更新 P&ID", "修订氮气系统操作规程", "更新设备台账"],
|
||||
materials: ["阀门数据表", "管道单线图", "施工方案"],
|
||||
riskTools: ["AI 分析", "JSA"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{ name: "专业审批", kind: "审批", roles: ["process"], status: "current" },
|
||||
{ name: "部门负责人批准", kind: "最终批准", roles: ["dept"], status: "todo", locked: true },
|
||||
],
|
||||
overdueDays: 3,
|
||||
},
|
||||
{
|
||||
id: "MOC-2026-R03-0042",
|
||||
title: "三车间自动化和安全隐患整改项目变更",
|
||||
type: "管理",
|
||||
level: "重要",
|
||||
duration: "永久",
|
||||
urgent: false,
|
||||
status: "审批中",
|
||||
applicant: "刘芳",
|
||||
org: "三车间",
|
||||
device: "三车间装置区",
|
||||
date: "2026-07-31",
|
||||
purpose: "落实安全隐患整改要求,对三车间自动化控制系统升级改造,消除加料互串与超温隐患。",
|
||||
effect: "隐患整改闭环率 100%,自动化水平提升,误操作风险显著降低。",
|
||||
before: "加料系统手动控制,存在互串与超温隐患(隐患编号 YH-2026-017)。",
|
||||
after: "改为 DCS 自动控制 + 加料顺序联锁,增设独立超温切断。",
|
||||
related: ["修订操作规程", "DCS 组态修改", "更新 HAZOP 分析", "岗位再培训"],
|
||||
materials: ["隐患整改方案", "HAZOP 分析报告", "联锁因果表"],
|
||||
riskTools: ["AI 分析", "HAZOP"],
|
||||
flow: [
|
||||
{ name: "部门审核", kind: "审批", roles: ["dept"], status: "done" },
|
||||
{
|
||||
name: "专业会签",
|
||||
kind: "会签",
|
||||
roles: ["process", "process2", "quality"],
|
||||
status: "done",
|
||||
members: [
|
||||
{ name: "王仪表(仪表)", done: true },
|
||||
{ name: "周设备(设备)", done: true },
|
||||
{ name: "陈质量(质量)", done: true },
|
||||
],
|
||||
},
|
||||
{ name: "安全审查", kind: "审批", roles: ["safety"], status: "done" },
|
||||
{ name: "分管领导最终批准", kind: "最终批准", roles: ["vp"], status: "current" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const progressRow = ref<any>(null);
|
||||
const ledgerAct = ref<{ id: string; act: "withdraw" | "unimpl" | "unuse" } | null>(null);
|
||||
const ledgerReason = ref("");
|
||||
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()); }
|
||||
interface UrgeRecord { id: string; changeId: string; title: string; applicant: string; by: string; time: string; }
|
||||
let urgeSeq = 1;
|
||||
const URGE_RECORDS: UrgeRecord[] = [];
|
||||
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 urgeChange(changeId: string, byRole: string) {
|
||||
const row = LEDGER_ROWS.find((r) => r.id === changeId || r.changeId === changeId);
|
||||
const c = CHANGES.find((x) => x.id === changeId);
|
||||
const by = ROLES.find((r:any) => r.id === byRole)?.person ?? byRole;
|
||||
URGE_RECORDS.unshift({
|
||||
id: `URGE-${urgeSeq++}`,
|
||||
changeId,
|
||||
title: row?.title ?? c?.title ?? changeId,
|
||||
applicant: row?.applicant ?? c?.applicant ?? "",
|
||||
by,
|
||||
time: nowStr(),
|
||||
});
|
||||
emitDemo();
|
||||
}
|
||||
|
||||
const stages = ["提交申请", "审批", "实施与投用前确认", "投用", "验收", "关闭"];
|
||||
const curIdx = computed(() => ({ 审批中: 1, 已批准: 2, 实施中: 2, 待PSSR: 2, 待验收: 4, 待关闭: 5, 已关闭: 6 } as Record<string, number>)[progressRow.value?.status ?? ""] ?? 1);
|
||||
const closed = computed(() => progressRow.value?.status === "已关闭");
|
||||
const timeOf = (i: number) => (i === 0 ? progressRow.value?.applyTime : i === 3 ? progressRow.value?.actualUse : i === 5 ? progressRow.value?.closeTime : undefined);
|
||||
|
||||
const role = ref<string>("applicant");
|
||||
const doOp = (r: any, act: string) => {
|
||||
if (act === "detail") {
|
||||
const c = CHANGES.find((x) => x.id === r.changeId);
|
||||
if (c) {
|
||||
// 跳转详情页面
|
||||
// emit("openChange", c);
|
||||
}else {
|
||||
window.$message?.info(`演示:打开变更详情页 ${r.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (act === "progress") {
|
||||
progressRow.value = r;
|
||||
progressDrawer.value = true;
|
||||
return;
|
||||
}
|
||||
if (act === "withdraw" || act === "unimpl" || act === "unuse") {
|
||||
ledgerAct.value = { id: r.id, act };
|
||||
ledgerReason.value = "";
|
||||
revokeModal.value = true;
|
||||
return;
|
||||
}
|
||||
if (act === "urge") {
|
||||
urgeChange(r.id, role.value ?? "dept");
|
||||
window.$message?.success(`已催办 ${r.id} 申请人 ${r.applicant}(系统消息 + 企业微信),催办记录已留痕并抄送变更主管部门,申请人操作台将出现催办提示`);
|
||||
return;
|
||||
}
|
||||
window.$message?.info(`演示:下载 ${r.id} 变更申请表 / 归档包(PDF)`);
|
||||
};
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
dept.value = "全部组织"; openOnly.value = false; kw.value = ""; typeFilter.value = "全部"; statusFilter.value = "全部";
|
||||
durFilter.value = "全部"; period.value = "近一年"; customFrom.value = null; customTo.value = null;
|
||||
sort.value = { k: "applyTime", dir: "desc" };
|
||||
window.$message?.success("筛选条件已重置");
|
||||
};
|
||||
// 导出台账
|
||||
const exportChanges = () => {
|
||||
window.$message?.success(`演示:导出台账 Excel(当前筛选 ${finalRows.value.length} 条)`);
|
||||
}
|
||||
// 撤回弹框取消
|
||||
const revokeCancel = () => {
|
||||
ledgerAct.value = null;
|
||||
ledgerReason.value = "";
|
||||
revokeModal.value = false;
|
||||
}
|
||||
// 撤回弹框提交
|
||||
const revokeSave = () => {
|
||||
if (!ledgerReason.value) {
|
||||
window.$message?.warning("请输入撤回理由");
|
||||
return;
|
||||
}
|
||||
window.$message?.success(ledgerAct.value?.act === 'withdraw' ? `已撤回 ${ledgerAct.value?.id},申请退回草稿箱,审批记录保留可查` : `${ledgerAct.value?.id} 情况说明已提交,变更转入关闭流程(主管部门确认后记录关闭时间)`);
|
||||
revokeCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我发起的变更 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<!-- 工具条 -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border px-3 py-2"
|
||||
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" /> 全部变更
|
||||
<span class="rounded bg-slate-100 px-1.5 text-xs text-slate-500">{{ finalRows.length }}</span>
|
||||
</span>
|
||||
<NSelect size="small" class="!w-32" :value="dept" :options="deptOpts" @update:value="(v: string) => dept = v" />
|
||||
<NRadio
|
||||
size="small"
|
||||
:checked="!openOnly"
|
||||
:value="false"
|
||||
name="显示全部"
|
||||
@change="openOnly = false"
|
||||
>
|
||||
显示全部
|
||||
</NRadio>
|
||||
<NRadio
|
||||
size="small"
|
||||
:checked="openOnly"
|
||||
:value="true"
|
||||
name="显示未关闭"
|
||||
@change="openOnly = true"
|
||||
>
|
||||
显示未关闭
|
||||
</NRadio>
|
||||
<div class="relative ml-auto">
|
||||
<NInput v-model:value="kw" size="small" type="text" clearable placeholder="搜索变更名称 / 编号">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
<NButton size="small" class="text-sm" @click="reset">
|
||||
<Icon icon="system-uicons:reset" class="size-12px" />重置
|
||||
</NButton>
|
||||
<NButton size="small" class="text-sm" @click="exportChanges">
|
||||
<Icon icon="material-symbols:download" class="size-14px" />导出台账
|
||||
</NButton>
|
||||
</div>
|
||||
<!-- 筛选与排序行 -->
|
||||
<div
|
||||
class="flex flex-col gap-y-2 border px-3 py-2 my-3"
|
||||
:style="{ borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">类型</span>
|
||||
<NButton size="tiny" round v-for="t in TYPE_FILTERS" :key="t" :ghost="typeFilter === t ? false : true" :type="typeFilter === t ? 'primary' : 'default'" @click="typeFilter = t">{{ t }}</NButton>
|
||||
</p>
|
||||
<p class="flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时限</span>
|
||||
<NButton size="tiny" round v-for="d in DUR_FILTERS" :key="d" :ghost="durFilter === d ? false : true" :type="durFilter === d ? 'primary' : 'default'" @click="durFilter = d">{{ d }}</NButton>
|
||||
</p>
|
||||
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时间</span>
|
||||
<NButton size="tiny" round v-for="p in PERIOD_FILTERS" :key="p" :ghost="period === p ? false : true" :type="period === p ? 'primary' : 'default'" @click="period = p">{{ p }}</NButton>
|
||||
<span v-if="period === '自定义'" class="flex items-center gap-1">
|
||||
<NDatePicker v-model:value="customFrom" type="date" size="small" class="w-130px" />
|
||||
<span class="text-xs text-slate-400">至</span>
|
||||
<NDatePicker v-model:value="customTo" type="date" size="small" class="w-130px" />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<p class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">状态</span>
|
||||
<NButton size="tiny" round v-for="s in STATUS_FILTERS" :key="s" :ghost="statusFilter === s ? false : true" :type="statusFilter === s ? 'primary' : 'default'" @click="statusFilter = s">{{ s }}</NButton>
|
||||
</p>
|
||||
<p class="ml-auto flex items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">排序</span>
|
||||
<NSelect size="small" class="!w-36" v-model:value="sortVal" :options="SORT_OPTS" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 scroll">
|
||||
<!-- 列表 -->
|
||||
<div v-for="(r, i) in finalRows" :key="r.id" class="rounded-lg border bg-white px-4 py-3">
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-slate-300">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<NButton text class="text-left text-sm font-semibold text-slate-800 hover:text-[#1D4E9C] hover:underline" @click="doOp(r, 'detail')">{{ r.title }}</NButton>
|
||||
<NTag size="small" v-if="r.urgent" class="animate-breathe border-[#E15555] bg-[#E15555] text-white">紧急</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ r.id }}</span>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<NTag size="small" class="border-slate-200 bg-white text-slate-600">{{ r.type }}</NTag>
|
||||
<NTag size="small" :class="r.level === '重要' ? 'border-red-200 bg-red-100 text-red-700' : 'border-slate-200 bg-slate-100 text-slate-600'">{{ r.level }}</NTag>
|
||||
<NTag size="small" :class="r.duration === '临时' ? 'border-[#FAAD14] bg-[#FAAD14] text-white' : 'border-[#1D4E9C] bg-[#1D4E9C] text-white'">{{ r.duration }}</NTag>
|
||||
<NTag size="small" v-for="s in LEDGER_EXTRA[r.id]?.special ?? []" :key="s" :class="SPECIAL_COLOR[s]">{{ s }}</NTag>
|
||||
</div>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-slate-500">
|
||||
<span>申请 {{ r.applyTime }}</span>
|
||||
<span>{{ r.dept }} · {{ r.applicant }}</span>
|
||||
<span>计划投用 {{ r.planUse }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.actualUse">实际投用 {{ LEDGER_EXTRA[r.id].actualUse }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.planRestore">计划恢复 {{ LEDGER_EXTRA[r.id].planRestore }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.extRestore" class="text-amber-600">延期后恢复 {{ LEDGER_EXTRA[r.id].extRestore }}</span>
|
||||
<span v-if="LEDGER_EXTRA[r.id]?.closeTime">关闭 {{ LEDGER_EXTRA[r.id].closeTime }}</span>
|
||||
<span>状态更新 {{ r.statusTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<span class="text-[11px] text-slate-400">{{ r.statusDetail }}</span>
|
||||
<NTag size="small" :class="STATUS_COLOR[r.status]">{{ r.status }}</NTag>
|
||||
<span v-if="r.overdueDays" class="text-[11px] font-medium text-red-500">超期 {{ r.overdueDays }} 天</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1 pt-0.5">
|
||||
<NButton size="small" v-for="op in opsOf(r, me, role)" :key="op.label"
|
||||
class="text-xs"
|
||||
:ghost="op.act === 'detail' ? false : true"
|
||||
:type="op.act === 'detail' ? 'primary' : op.danger ? 'error' : 'default'"
|
||||
@click="doOp(r, op.act)">
|
||||
{{ op.label }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="r.duration === '临时'" class="text-right text-[10px] leading-4 text-slate-400">临时变更恢复 / 延期 / 转永久请在详情页中操作</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="finalRows.length === 0" class="rounded-lg border border-dashed bg-white py-12 text-center text-sm text-slate-400">当前筛选条件下暂无变更记录</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 mt-3">
|
||||
共 {{ finalRows.length }} 条 · 台账以查看、下载为主(申请表 / 归档包);与"我"相关的待办流转请前往「我的任务」处理。点击变更名称可打开详情。
|
||||
</div>
|
||||
<!-- 撤回/未实施情况说明/未投用情况说明弹框 -->
|
||||
<NModal
|
||||
v-model:show="revokeModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '450px', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-sm font-bold text-slate-800">
|
||||
<span v-if="ledgerAct?.act === 'withdraw'">撤回变更申请</span>
|
||||
<span v-else-if="ledgerAct?.act === 'unuse'">未投用情况说明</span>
|
||||
<span v-else>未实施情况说明</span>
|
||||
{{ledgerAct?.id}}
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-500">
|
||||
{{ ledgerAct?.act === 'withdraw'
|
||||
? '撤回后申请退回草稿状态,已产生的审批记录保留可查;修改内容后可重新提交。仅本人发起的审批中变更可撤回。'
|
||||
: '变更审批通过 / 投用后未实施或未投用的,由申请人填写情况说明,提交后变更转入关闭流程,由变更主管部门专责确认关闭并记录关闭时间。' }}
|
||||
</div>
|
||||
<NInput
|
||||
class="mt-3"
|
||||
v-model:value="ledgerReason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="ledgerAct?.act === 'withdraw' ? '撤回理由(必填)…' : '情况说明(必填):原因、现场当前状态、后续处置安排…'"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-[10px]">
|
||||
<NButton size="small" @click="revokeCancel">取消</NButton>
|
||||
<NButton size="small" type="primary" :loading="revokeSaving" @click="revokeSave">提交</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
<!-- 进度抽屉 -->
|
||||
<NDrawer v-model:show="progressDrawer" :width="502" placement="right">
|
||||
<NDrawerContent title="变更进度">
|
||||
<div class="rounded-lg border bg-slate-50/60 px-3 py-2.5">
|
||||
<div class="text-sm font-semibold text-slate-800">{{ progressRow?.title }}</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-400">
|
||||
<span class="font-mono">{{ progressRow?.id }}</span>
|
||||
<NTag v-if="progressRow?.status==='审批中'" size="small" type="warning">{{progressRow.status}}</NTag>
|
||||
<NTag v-if="progressRow?.status==='已批准'" size="small" type="primary">{{progressRow.status}}</NTag>
|
||||
<NTag v-if="progressRow?.status==='待验收'" size="small" type="success">{{progressRow.status}}</NTag>
|
||||
<NTag v-if="progressRow?.status==='待关闭'" size="small" type="error">{{progressRow.status}}</NTag>
|
||||
<span v-if="progressRow?.overdue" class="font-medium text-red-500">超期 {{ progressRow?.day }} 天</span>
|
||||
</div>
|
||||
<div v-if="progressRow?.dept || progressRow?.applicant" class="mt-1 text-[11px] text-slate-400">{{ [progressRow?.dept, progressRow?.applicant].filter(Boolean).join(' · ') }}</div>
|
||||
<div v-if="progressRow?.meta" class="mt-1 text-[11px] text-slate-400">{{ progressRow?.meta }}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div v-for="(s, i) in stages" :key="s" class="flex gap-3">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2"
|
||||
:class="i < curIdx || closed ? 'border-[#7CC242] bg-[#7CC242] text-white' : i === curIdx && !closed ? 'border-[#1D4E9C] bg-[#1D4E9C] text-white' : 'border-slate-200 bg-white text-slate-300'">
|
||||
<Icon v-if="i < curIdx || closed" icon="ix:success" />
|
||||
<Icon v-else-if="i === curIdx" icon="tabler:clock" />
|
||||
<span v-else class="text-[10px]">{{ i + 1 }}</span>
|
||||
</span>
|
||||
<span v-if="i < stages.length - 1" class="w-0.5 flex-1" :class="i < curIdx ? 'bg-[#7CC242]' : 'bg-slate-200'" />
|
||||
</div>
|
||||
<div class="pb-5">
|
||||
<div class="text-sm" :class="i === curIdx && !closed ? 'font-semibold text-[#1D4E9C]' : i < curIdx || closed ? 'font-medium text-slate-700' : 'text-slate-400'">
|
||||
{{ s }}{{ i === curIdx && !closed ? '(当前)' : '' }}
|
||||
</div>
|
||||
<div v-if="timeOf(i)" class="mt-0.5 text-[11px] text-slate-400">{{ timeOf(i) }}</div>
|
||||
<div v-if="i === curIdx && !closed && (progressRow?.detail || progressRow?.updateTime)" class="mt-0.5 text-[11px] text-slate-400">
|
||||
{{ [progressRow?.detail, progressRow?.updateTime ? `更新 ${progressRow?.updateTime}` : ''].filter(Boolean).join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="progressRow?.dur === '临时'" class="rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2 text-[11px] leading-5 text-amber-700">
|
||||
临时变更:计划恢复 {{ progressRow?.deadline ?? '—' }}{{ progressRow?.extended ? ` · 延期后恢复 ${progressRow?.deadline}` : '' }} · 恢复 / 延期 / 转永久请在详情页中操作
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 326px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { changePasswordApi } from '@/service/api/auth';
|
||||
import { clearAuthStorage } from '@/store/modules/auth/shared';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { toLogin } = useRouterPush();
|
||||
|
||||
const formModel = ref<{oldPassword: string;password: string;confirmPassword: string;}>({
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const rules = {
|
||||
oldPassword: [{ required: true, message: '请输入旧密码', trigger: ['blur'] }],
|
||||
password: [{ required: true, message: '请输入新密码', trigger: ['blur'] }],
|
||||
confirmPassword: [{ validator: (rule: any, value: string, callback: any) => {
|
||||
if (value !== formModel.value.password) {
|
||||
callback(new Error('两次输入密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}, required: true, message: '两次输入密码不一致', trigger: ['blur'] }],
|
||||
}
|
||||
|
||||
const formRef = ref<any>(null);
|
||||
const btnLoading = ref<boolean>(false);
|
||||
|
||||
const submitForm = async (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
formRef.value?.validate(async (errors: any) => {
|
||||
if (!errors) {
|
||||
btnLoading.value = true;
|
||||
const {error} = await changePasswordApi({
|
||||
old_password: formModel.value.oldPassword,
|
||||
new_password: formModel.value.password,
|
||||
confirm_password: formModel.value.confirmPassword,
|
||||
});
|
||||
if(!error){
|
||||
window.$message?.success('修改成功,请重新登录')
|
||||
clearAuthStorage();
|
||||
toLogin();
|
||||
}
|
||||
btnLoading.value = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 修改密码 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="border mb-3 py-2 px-3" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24">
|
||||
<div class="h-100% flex items-center gap-2">
|
||||
<Icon icon="boxicons:lock-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-[16px] font-600">修改密码</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="mt-5 max-w-[800px] mx-auto">
|
||||
<NForm ref="formRef" :model="formModel" :rules="rules">
|
||||
<NFormItem path="oldPassword" label="旧密码">
|
||||
<NInput v-model:value="formModel.oldPassword" type="password" clearable show-password-on="click" placeholder="请输入旧密码" />
|
||||
</NFormItem>
|
||||
<NFormItem path="password" label="新密码">
|
||||
<NInput v-model:value="formModel.password" type="password" clearable show-password-on="click" placeholder="请输入新密码" />
|
||||
</NFormItem>
|
||||
<NFormItem path="confirmPassword" label="确认密码">
|
||||
<NInput v-model:value="formModel.confirmPassword" type="password" clearable show-password-on="click" placeholder="请确认新密码" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<NButton type="primary" block :loading="btnLoading" @click="submitForm">修改密码</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 205px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,246 +1,338 @@
|
||||
<!-- 本部门变更统计 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { CHANGES, TEMP_WATCH, countOpenStatus } from "@/typings/model";
|
||||
import { formatTimestamp } from "@/utils/common";
|
||||
import { formatTimestamp, getTimeRangeStamp } from "@/utils/common";
|
||||
import { localStg } from '@/utils/storage';
|
||||
import RingChart from './modules/ringChart.vue';
|
||||
import PieChart from './modules/pieChart.vue';
|
||||
import PieChart2 from './modules/pieChart2.vue';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { departmentStatisticsApi } from "@/service/api/statistics";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
|
||||
const P_PERIODS = ["近一年", "近半年", "近三个月", "近一个月", "自定义"] as const;
|
||||
const period = ref<(typeof P_PERIODS)[number]>("近一年");
|
||||
const from = ref<any>(null);
|
||||
const to = ref<any>(null);
|
||||
const rangeText = computed(() => period.value === "自定义"
|
||||
? (from.value || to.value ? `${formatTimestamp(from.value,'yyyy-MM-dd') || "开始"} ~ ${formatTimestamp(to.value,'yyyy-MM-dd') || "至今"}` : "自定义(未选日期)")
|
||||
: period.value);
|
||||
const loading = ref<boolean>(false);
|
||||
// 组织
|
||||
const org = ref<string>(localStg.get('userInfo')?.department?.label || '');
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 本部门变更统计
|
||||
const deptStatistics = ref<any>(null);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 时间范围
|
||||
const period = ref<string>("year");
|
||||
// 开始时间
|
||||
const from = ref<number | null>(getTimeRangeStamp(period.value)?.from ?? null);
|
||||
// 结束时间
|
||||
const to = ref<number | null>(getTimeRangeStamp(period.value)?.to ?? null);
|
||||
// 时间范围选项
|
||||
const TIME_RANGE = [
|
||||
{label: "近一年", value: "year"},
|
||||
{label: "近半年", value: "half-year"},
|
||||
{label: "近三个月", value: "quarter"},
|
||||
{label: "近一个月", value: "month"},
|
||||
{label: "自定义", value: "custom"}
|
||||
]
|
||||
// 尚未关闭总数
|
||||
const openTotal = computed(() => {
|
||||
return deptStatistics.value?.open_status_counts?.reduce((sum:number, item:any) => sum + item.count, 0) || 0;
|
||||
});
|
||||
// 验收达标总数
|
||||
const acceptTotal = computed(() => {
|
||||
return deptStatistics.value?.accept_dist?.reduce((sum:number, item:any) => sum + item.count, 0) || 0;
|
||||
});
|
||||
// 临时变更到期提醒情况
|
||||
const tempTotal = computed(() => {
|
||||
return (deptStatistics.value?.kpis?.unclosed_total || 0) + (deptStatistics.value?.kpis?.near_overdue_total || 0) + (deptStatistics.value?.kpis?.overdue_total || 0);
|
||||
});
|
||||
|
||||
const deptOpenStatus = countOpenStatus(CHANGES);
|
||||
const DEPT_ACCEPT = [
|
||||
{ name: "完全达到", value: 13 },
|
||||
{ name: "基本达到", value: 4 },
|
||||
{ name: "未达到", value: 2 },
|
||||
{ name: "处置关闭(未实施 / 未投用)", value: 3 },
|
||||
];
|
||||
const acceptTotal = DEPT_ACCEPT.reduce((a, b) => a + b.value, 0);
|
||||
const ACCEPT_COLORS = ["#7CC242", "#F5A623", "#E15555", "#94A3B8", "#64748B"];
|
||||
const DEPT_TYPE = [
|
||||
{ name: "工艺", value: 11 }, { name: "设备", value: 12 }, { name: "管理", value: 5 },
|
||||
];
|
||||
const DEPT_DUR = [
|
||||
{ name: "永久", value: 21 }, { name: "临时", value: 7 },
|
||||
];
|
||||
const DUR_COLORS = ["#1D4E9C", "#FAAD14"];
|
||||
const PIE_COLORS = ["#1D4E9C", "#7CC242", "#F5A623"];
|
||||
const APPROVER_TIME = [
|
||||
{ name: "李主任(部门审核)", days: 1.2 },
|
||||
{ name: "王仪表(仪表会签)", days: 0.8 },
|
||||
{ name: "周设备(设备会签)", days: 1.5 },
|
||||
{ name: "王海燕(安全审查)", days: 2.1 },
|
||||
{ name: "刘副总(公司终批)", days: 1.9 },
|
||||
];
|
||||
const openTotal = deptOpenStatus.reduce((a, b) => a + b.n, 0);
|
||||
const tempTotal = TEMP_WATCH.reduce((a, b) => a + b.n, 0);
|
||||
// 时间范围切换
|
||||
const timeChange = (val: string) => {
|
||||
period.value = val;
|
||||
if(val === 'custom'){
|
||||
from.value = null;
|
||||
to.value = null;
|
||||
return
|
||||
}else{
|
||||
from.value = getTimeRangeStamp(val)?.from ?? null;
|
||||
to.value = getTimeRangeStamp(val)?.to ?? null;
|
||||
}
|
||||
getData();
|
||||
}
|
||||
// 自定义时间选择
|
||||
const customTimeChange = (type:string,val:any) => {
|
||||
if(type === 'start'){
|
||||
from.value = val;
|
||||
}else{
|
||||
to.value = val;
|
||||
}
|
||||
if(from.value && to.value){
|
||||
if(from.value > to.value){
|
||||
return window.$message?.warning('开始时间不能大于结束时间');
|
||||
}
|
||||
getData();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await departmentStatisticsApi({
|
||||
from: formatTimestamp(from.value,'yyyy-MM-dd') || null,
|
||||
to: formatTimestamp(to.value,'yyyy-MM-dd') || null
|
||||
});
|
||||
if(!error){
|
||||
deptStatistics.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 本部门变更统计 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<div class="flex-y-center">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="akar-icons:calendar" class="size-14px" :style="{color: themeStore.themeColor}" />
|
||||
<span>本部门变更统计</span>
|
||||
<span class="text-xs font-normal text-slate-400">一车间 · 本部门口径</span>
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时间段</span>
|
||||
<NButton size="tiny" round v-for="p in P_PERIODS" :key="p" @click="period = p"
|
||||
:ghost="period === p ? false : true"
|
||||
:type="period === p ? 'primary' : 'default'"
|
||||
>{{ p }}</NButton>
|
||||
<span v-if="period === '自定义'" class="flex items-center gap-1">
|
||||
<NDatePicker v-model:value="from" type="date" size="small" class="w-32" />
|
||||
<span class="text-xs text-slate-400">至</span>
|
||||
<NDatePicker v-model:value="to" type="date" size="small" class="w-32" />
|
||||
<NSpace vertical :size="16">
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<div class="flex-y-center gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-semibold text-slate-700">
|
||||
<Icon icon="akar-icons:calendar" class="size-14px" :style="{color: themeStore.themeColor}" />
|
||||
<span>本部门变更统计</span>
|
||||
<span class="text-xs font-normal text-slate-400">{{org?org+' · ':''}}本部门口径</span>
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5 min-h-28px">
|
||||
<span class="text-xs text-slate-400">时间段</span>
|
||||
<NButton size="tiny" round v-for="p in TIME_RANGE" :key="p.value" @click="timeChange(p.value)"
|
||||
:ghost="period === p.value ? false : true"
|
||||
:type="period === p.value ? 'primary' : 'default'"
|
||||
>{{ p.label }}</NButton>
|
||||
<span v-if="period === 'custom'" class="flex items-center gap-1">
|
||||
<NDatePicker :value="from" type="date" size="small" class="w-32" :on-update:value="(v) => customTimeChange('start',v)" />
|
||||
<span class="text-xs text-slate-400">至</span>
|
||||
<NDatePicker :value="to" type="date" size="small" class="w-32" :on-update:value="(v) => customTimeChange('end',v)" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[11px] text-slate-400">统计口径:{{ rangeText }}</span>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:5 l:5" responsive="screen" :x-gap="gap" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">申请变更数量</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="28"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/><p class="mt-10px ml-5px">项</p>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:5 l:5" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">申请变更数量</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="deptStatistics?.kpis?.apply_total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/><p class="mt-10px ml-5px">项</p>
|
||||
</div>
|
||||
<p class="text-12px">永久 {{deptStatistics?.kpis?.permanent_total || 0}} 项 · 临时 {{deptStatistics?.kpis?.temp_total || 0}} 项</p>
|
||||
</div>
|
||||
<p class="text-12px">永久 21 · 临时 7</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">重要变更数量</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="7"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/><p class="mt-10px ml-5px">项</p>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">重要变更数量</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="deptStatistics?.kpis?.important_total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/><p class="mt-10px ml-5px">项</p>
|
||||
</div>
|
||||
<p class="text-12px">占比 {{deptStatistics?.kpis?.important_pct || 0}}% · 均含公司终批</p>
|
||||
</div>
|
||||
<p class="text-12px">占比 25% · 均含公司终批</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">未关闭变更数量</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="6"
|
||||
class="text-26px font-600 text-[#E15555]"
|
||||
/><p class="mt-10px ml-5px">项</p>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">未关闭变更数量</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="deptStatistics?.kpis?.unclosed_total || 0"
|
||||
class="text-26px font-600 text-[#E15555]"
|
||||
/><p class="mt-10px ml-5px">项</p>
|
||||
</div>
|
||||
<p class="text-12px">其中超期 {{deptStatistics?.kpis?.overdue_total || 0}} 项 · 临期 {{deptStatistics?.kpis?.near_overdue_total || 0}} 项</p>
|
||||
</div>
|
||||
<p class="text-12px">其中超期 1 项 · 临期 1 项</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">平均审批通过时长</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="7.4"
|
||||
:decimals="1"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
<p class="mt-10px ml-5px">天</p>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">平均审批通过时长</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="deptStatistics?.kpis?.avg_approval_days || 0"
|
||||
:decimals="1"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
<p class="mt-10px ml-5px">天</p>
|
||||
</div>
|
||||
<p class="text-12px">优于全厂均值 {{deptStatistics?.kpis?.plant_avg_approval_days || 0}} 天</p>
|
||||
</div>
|
||||
<p class="text-12px">优于全厂均值 8.2 天</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">申请驳回率</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="6.1"
|
||||
:decimals="1"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/><p class="mt-10px ml-5px">%</p>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">申请驳回率</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="deptStatistics?.kpis?.reject_rate || 0"
|
||||
:decimals="1"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/><p class="mt-10px ml-5px">%</p>
|
||||
</div>
|
||||
<p class="text-12px">{{deptStatistics?.kpis?.reject_count || 0}} 项被驳回 · 均已重新提交</p>
|
||||
</div>
|
||||
<p class="text-12px">2 项被驳回 · 均已重新提交</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:6">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">尚未关闭的变更所处的状态数量(共 {{openTotal}} 项)</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="s in deptOpenStatus" :key="s.name">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 flex-1 text-slate-700">{{ s.name }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-600">{{ s.n }} 项</span>
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:6">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">尚未关闭的变更所处的状态数量(共 {{openTotal}} 项)</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="s in deptStatistics?.open_status_counts" :key="s.status">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 flex-1 text-slate-700">{{ s.label }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-600">{{ s.count }} 项</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<!-- 审批中 -->
|
||||
<div v-if="s.status === 'APPROVING'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.warning }"></div>
|
||||
<!-- 已驳回 -->
|
||||
<div v-if="s.status === 'REJECTED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.error }"></div>
|
||||
<!-- 已批准待投用 -->
|
||||
<div v-if="s.status === 'APPROVED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.themeColor }"></div>
|
||||
<!-- 待验收 -->
|
||||
<div v-if="s.status === 'IMPLEMENTED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.success }"></div>
|
||||
<!-- 待关闭 -->
|
||||
<div v-if="s.status === 'ACCEPTED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.error }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full" :style="{ width: `${openTotal ? (s.n / openTotal) * 100 : 0}%`, background: s.c }" />
|
||||
<div class="text-[11px] text-slate-400">阶段口径:审批中(含会签)→ 已批准待投用 → 待验收 → 待关闭 · 培训 / PSSR 线下签字后拍照上传,并入资料归档 · 明细见变更台账</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:18">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">验收达标 · 变更类型 · 变更时限分布</p>
|
||||
<div class="grid gap-4 grid-cols-3">
|
||||
<div>
|
||||
<div class="mb-1 text-center text-xs font-semibold text-slate-600">验收达标分布(共 {{ acceptTotal }} 项 · 处置关闭单列)</div>
|
||||
<RingChart v-if="deptStatistics?.accept_dist?.length" :data="deptStatistics?.accept_dist || []" :total="acceptTotal" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-center text-xs font-semibold text-slate-600">变更类型分布</div>
|
||||
<PieChart v-if="deptStatistics?.type_dist?.length && typeList?.length" :data="deptStatistics?.type_dist || []" :typeList="typeList" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-center text-xs font-semibold text-slate-600">变更时限分布</div>
|
||||
<PieChart2 v-if="deptStatistics?.duration_dist?.length" :data="deptStatistics?.duration_dist || []" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">阶段口径:审批中(含会签)→ 已批准待投用 → 待验收 → 待关闭 · 培训 / PSSR 线下签字后拍照上传,并入资料归档 · 明细见变更台账</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:18">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">验收达标 · 变更类型 · 变更时限分布</p>
|
||||
<div class="grid gap-4 grid-cols-3">
|
||||
<div>
|
||||
<div class="mb-1 text-center text-xs font-semibold text-slate-600">验收达标分布(共 {{ acceptTotal }} 项 · 处置关闭单列)</div>
|
||||
<RingChart :data="DEPT_ACCEPT" :colors="ACCEPT_COLORS" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<div class="border-b border-slate-100 pb-3 mb-4 flex justify-between items-center">
|
||||
<p class=" text-base font-semibold">各人员审批时间对比(平均处理时长)</p>
|
||||
<p class="text-[11px] text-slate-400 mt-5px">超过 2 天红色标识 · 用于会签催办与流程优化参考</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-center text-xs font-semibold text-slate-600">变更类型分布</div>
|
||||
<PieChart :data="DEPT_TYPE" :colors="PIE_COLORS" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-center text-xs font-semibold text-slate-600">变更时限分布</div>
|
||||
<PieChart2 :data="DEPT_DUR" :colors="DUR_COLORS" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-[11px] leading-5 text-slate-500">
|
||||
未达到 2 项:P-204 伴热改造温控未达预期(已退回整改)、加药泵冲程调整流量偏差(重新约定 30 天验证期);处置关闭 3 项:未实施 2 项(检修窗口调整、方案合并实施)、未投用 1 项(投用条件不具备),均经情况说明后关闭,验收结论不再含未实施 / 未投用。
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">各人员审批时间对比(平均处理时长)</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="a in APPROVER_TIME" :key="a.name">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 flex-1 text-slate-700">{{ a.name }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-600">{{ a.days }} 天</span>
|
||||
<div class="space-y-3">
|
||||
<div v-for="a in deptStatistics?.approver_time" :key="a.name">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 flex-1 text-slate-700">{{ a.user_name }}{{a.step_name?'('+a.step_name+')':''}}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-600">{{ a.avg_days }} 天</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full" :style="{ width: `${(a.avg_days / a.total) * 100}%`, background: a.avg_days > 2 ? themeStore.otherColor.error : themeStore.themeColor }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full" :style="{ width: `${(a.days / 2.5) * 100}%`, background: a.days > 2 ? '#E15555' : '#1D4E9C' }" />
|
||||
<div v-if="deptStatistics?.approver_time?.length === 0" class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">超过 2 天红色标识 · 用于会签催办与流程优化参考</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">临时变更到期提醒情况(共 {{tempTotal}} 项)</p>
|
||||
<div class="space-y-3">
|
||||
<div v-for="t in TEMP_WATCH" :key="t.label" class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: t.c }" />
|
||||
<span class="font-medium text-slate-700">{{ t.label }}</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{ t.n }} 项</span>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 pb-3 mb-4">临时变更到期提醒情况(共 {{tempTotal}} 项)</p>
|
||||
<div class="space-y-3">
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.success }"></span>
|
||||
<span class="font-medium text-slate-700">正常(到期 > {{remindDays}} 天)</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{deptStatistics?.kpis?.unclosed_total || 0}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 pl-4 text-[11px] text-slate-400">{{ t.note }}</div>
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.warning }"></span>
|
||||
<span class="font-medium text-slate-700">临期(到期 ≤ {{remindDays}} 天)</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{deptStatistics?.kpis?.near_overdue_total || 0}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.error }"></span>
|
||||
<span class="font-medium text-slate-700">已超期</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{deptStatistics?.kpis?.overdue_total || 0}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">提醒规则:到期前 {{remindDays}} 天触发(可在「我的任务 · 我的申请」中调整)· 临期 / 超期项请按期办理延期、恢复或转永久</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">提醒规则:到期前 7 天触发(可在「待我处理 · 我的申请」中调整)· 临期 / 超期项请按期办理延期、恢复或转永久</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'PieChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; colors?: string[] }>();
|
||||
const props = defineProps(['data','typeList'])
|
||||
|
||||
watch(() => props.data, (newVal:any) => {
|
||||
init();
|
||||
});
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -47,8 +51,19 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
opts.series[0].data = props.data || [];
|
||||
opts.series[0].color = props.colors || [];
|
||||
// 处理数据
|
||||
const result = props.data?.map((item: any) => ({
|
||||
...item,
|
||||
name: item.label,
|
||||
value: item.count
|
||||
}));
|
||||
opts.series[0].data = result || [];
|
||||
// 处理颜色
|
||||
// 用 Map 建立 value -> color 映射
|
||||
const colorMap = new Map(props.typeList?.map((item: any) => [item.value, item.color]));
|
||||
// 按 list2 的顺序返回 color 数组
|
||||
const colors = result.map((item: any) => colorMap.get(item.key) || null);
|
||||
opts.series[0].color = colors;
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'PieChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; colors?: string[] }>();
|
||||
const props = defineProps(['data'])
|
||||
watch(() => props.data, (newVal:any) => {
|
||||
init();
|
||||
});
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -47,8 +50,13 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
opts.series[0].data = props.data || [];
|
||||
opts.series[0].color = props.colors || [];
|
||||
const result = props.data?.map((item: any) => ({
|
||||
...item,
|
||||
name: item.label,
|
||||
value: item.count
|
||||
}));
|
||||
opts.series[0].data = result || [];
|
||||
opts.series[0].color = ["#2080f0", "#faad14"];
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { watch, nextTick } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'RingChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; colors?: string[] }>();
|
||||
const props = defineProps(['data','total'])
|
||||
watch(() => [props.data, props.total], () => {
|
||||
nextTick(() => {
|
||||
init();
|
||||
});
|
||||
},{immediate: true,deep: true});
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -24,7 +29,7 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
},
|
||||
},
|
||||
title: {
|
||||
text: `{a|22}\n{b|已评价}`,
|
||||
text: '',
|
||||
textStyle: {
|
||||
rich: {
|
||||
a: {
|
||||
@@ -72,8 +77,14 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
opts.series[0].data = props.data || [];
|
||||
opts.series[0].color = props.colors || [];
|
||||
const result = props.data?.map((item: any) => ({
|
||||
...item,
|
||||
name: item.label,
|
||||
value: item.count
|
||||
}));
|
||||
opts.title.text = `{a|${props.total}}\n{b|已评价}`;
|
||||
opts.series[0].data = result || [];
|
||||
opts.series[0].color = ["#52c41a", "#faad14", "#f5222d", "#94A3B8"];
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
@@ -82,7 +93,7 @@ init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="w-full h-220px overflow-hidden"></div>
|
||||
<div ref="domRef" class="w-full h-220px"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,812 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { formatTimestamp, downloadFileFun } from '@/utils/common';
|
||||
import { uploadFileApi, deleteFileApi, fileListApi } from "@/service/api/file";
|
||||
import { systemConfigApi, peopleListApi, templateListApi } from '@/service/api/system';
|
||||
import { getChangeDetailApi } from '@/service/api/change';
|
||||
import { myTasksCloseMaterialApi } from '@/service/api/myTasks';
|
||||
import previewComponent from '@/components/previewFile/index.vue'
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 返回键
|
||||
const backKey = ref<string>('');
|
||||
// 当前变更详情
|
||||
const currentInfo = ref<any>(null);
|
||||
// 当前变更id
|
||||
const currentId = ref<number>(0);
|
||||
// 是否开启 AI 辅助功能
|
||||
const isAiOn = ref<boolean>(false);
|
||||
// 培训签到上传文件列表
|
||||
const trainFiles = ref<any>([]);
|
||||
// pssr上传文件列表
|
||||
const pssrFiles = ref<any>([]);
|
||||
// 验收上传文件列表
|
||||
const acceptFiles = ref<any>([]);
|
||||
// 实际投用日期
|
||||
const useDate = ref<number | null>(null);
|
||||
// 变更材料和实施费(万元)
|
||||
const price = ref<number | null>(null);
|
||||
// 验收状态
|
||||
const resultStatus = ref<number>(0);
|
||||
// 验收结论
|
||||
const acceptDesc = ref<string>('');
|
||||
// 验收时间
|
||||
const acceptDate = ref<number | null>(null);
|
||||
// 附件选择列表弹框
|
||||
const filesModal = ref<boolean>(false);
|
||||
// 选中的文件列表
|
||||
const checkedFiles = ref<any>([]);
|
||||
// 附件列表
|
||||
const fileList = ref<any>([]);
|
||||
// 当前从变更附件选择的row下标
|
||||
const currentRowIndex = ref<number>(-1);
|
||||
// 当前预览文件
|
||||
const previewFile = ref<any>(null);
|
||||
// 预览文件弹框
|
||||
const previewModal = ref<boolean>(false);
|
||||
// 成员列表
|
||||
const memberList = ref<any[]>([]);
|
||||
// 模板
|
||||
const currentTemplate = ref<any[]>([]);
|
||||
// 审批流程抽屉
|
||||
const approvalDrawer = ref<boolean>(false);
|
||||
const flowList = ref<any[]>([]);
|
||||
// 审批链
|
||||
const approvalFlowText = computed(() => {
|
||||
if (!flowList.value || flowList.value.length === 0) return '暂无步骤'
|
||||
// 按 step_order 排序
|
||||
const sorted = [...flowList.value].sort((a, b) => a.step_order - b.step_order)
|
||||
return sorted.map(item => item.name).join(' → ')
|
||||
})
|
||||
// 专业
|
||||
const SPECIALTIES = [
|
||||
{ label: '生产', value: '生产' },
|
||||
{ label: '技术', value: '技术' },
|
||||
{ label: '仪表', value: '仪表' },
|
||||
{ label: '电气', value: '电气' },
|
||||
{ label: '设备', value: '设备' },
|
||||
{ label: '安全', value: '安全' },
|
||||
{ label: '质量', value: '质量' }
|
||||
];
|
||||
// 变更关闭资料归档列表
|
||||
const list = ref<any>([]);
|
||||
|
||||
// 返回
|
||||
const back = () => {
|
||||
routerPushByKey(backKey.value as any);
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
const onUploadFile = async (type:string = '',files:any,index:number = -1) => {
|
||||
const file = files?.fileList[0]?.file;
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// 3:培训签到;4:PSSR;5:验收
|
||||
if(type==='3' || type==='4' || type==='5'){
|
||||
formData.append('step', type);
|
||||
}else{
|
||||
formData.append('close_doc_id', list.value[index]?.id?.toString());
|
||||
}
|
||||
loading.value = true;
|
||||
const {data,error} = await uploadFileApi(currentId.value, formData);
|
||||
if(!error){
|
||||
window.$message?.success('上传成功');
|
||||
if(type==='3'){
|
||||
trainFiles.value.push(data);
|
||||
}else if(type==='4'){
|
||||
pssrFiles.value.push(data);
|
||||
}else if(type==='5'){
|
||||
acceptFiles.value.push(data);
|
||||
}else{
|
||||
list.value[index].attachments.push(data);
|
||||
list.value[index].status = 1;
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = (file:any,type:string) => {
|
||||
if(file){
|
||||
downloadFileFun(file?.file_url, file?.doc_name || file?.file_name);
|
||||
}else{
|
||||
window.$message?.info('还差接口,空白模板')
|
||||
}
|
||||
}
|
||||
// 删除文件
|
||||
const removeFileReal = async (type:string, fileIndex:number,index:number = -1) => {
|
||||
let id = 0;
|
||||
if(type==='3'){
|
||||
id = trainFiles.value[fileIndex].id;
|
||||
}else if(type==='4'){
|
||||
id = pssrFiles.value[fileIndex].id;
|
||||
}else if(type==='5'){
|
||||
id = acceptFiles.value[fileIndex].id;
|
||||
}else{
|
||||
id = list.value[index]?.attachments[fileIndex]?.id;
|
||||
}
|
||||
loading.value = true;
|
||||
const {error} = await deleteFileApi(currentId.value,id);
|
||||
if(!error){
|
||||
if(type==='3'){
|
||||
trainFiles.value.splice(fileIndex, 1);
|
||||
}else if(type==='4'){
|
||||
pssrFiles.value.splice(fileIndex, 1);
|
||||
}else if(type==='5'){
|
||||
acceptFiles.value.splice(fileIndex, 1);
|
||||
}else{
|
||||
list.value[index]?.attachments.splice(fileIndex, 1);
|
||||
if(list.value[index]?.attachments.length === 0){
|
||||
list.value[index].status = 2;
|
||||
}
|
||||
}
|
||||
window.$message?.success('删除成功');
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 打开预览
|
||||
const preview = (file:any) => {
|
||||
previewFile.value = file;
|
||||
previewModal.value = true;
|
||||
}
|
||||
|
||||
// 打开附件选择
|
||||
const openFileList = (index:number) => {
|
||||
currentRowIndex.value = index;
|
||||
filesModal.value = true;
|
||||
}
|
||||
// 选择文件
|
||||
const chooseFiles = () => {
|
||||
// 过滤出选中的文件
|
||||
const ids = new Set(checkedFiles.value);
|
||||
const result = fileList.value.filter((item:any) => ids.has(item.id));
|
||||
// 合并文件列表
|
||||
let files = list.value[currentRowIndex.value]?.attachments.concat(result);
|
||||
// 按id去重
|
||||
const uniqueList = [
|
||||
...new Map(files.map((item:any) => [item.id, item])).values(),
|
||||
];
|
||||
list.value[currentRowIndex.value].attachments = uniqueList;
|
||||
if(uniqueList.length > 0){
|
||||
list.value[currentRowIndex.value].status = 1;
|
||||
}
|
||||
filesModal.value = false;
|
||||
}
|
||||
|
||||
// 保存/申请关闭
|
||||
const submitCloseDoc = async (type:string) => {
|
||||
if(!useDate.value){
|
||||
return window.$message?.error('请选择实际投用日期');
|
||||
}
|
||||
if(!resultStatus.value){
|
||||
return window.$message?.error('请选择验收结论');
|
||||
}
|
||||
const result = list.value.map(({ id, status }:{id:number,status:number} ) => ({ id, status }));
|
||||
// 去掉id, step_order, user_name, status
|
||||
const steps = flowList.value.map(({ members }:any) => ({
|
||||
members: members.map(({ id, step_order, user_name, status, ...rest }:any) => rest)
|
||||
}));
|
||||
loading.value = true;
|
||||
const {error} = await myTasksCloseMaterialApi(currentId.value,{
|
||||
type:type,
|
||||
result_status:resultStatus.value,
|
||||
note:acceptDesc.value,
|
||||
use_time:formatTimestamp(useDate.value,'yyyy-MM-dd'),
|
||||
accept_time:formatTimestamp(acceptDate.value,'yyyy-MM-dd'),
|
||||
cost:price.value,
|
||||
close_docs:result,
|
||||
approval_flow: {
|
||||
steps: steps
|
||||
}
|
||||
});
|
||||
if(!error){
|
||||
if(type==='save'){
|
||||
window.$message?.success('保存成功');
|
||||
}
|
||||
if(type==='close'){
|
||||
back();
|
||||
window.$message?.success('申请关闭成功');
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取变更详情
|
||||
const getDetail = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await getChangeDetailApi(currentId.value)
|
||||
if(!error){
|
||||
currentInfo.value = data;
|
||||
list.value = data.close_docs || [];
|
||||
trainFiles.value = data?.train?.attachments || [];
|
||||
pssrFiles.value = data?.pssr?.attachments || [];
|
||||
acceptFiles.value = data?.accept_form?.attachments || [];
|
||||
// 如果详情有审批流程相关数据,直接使用,否则取默认模板
|
||||
if(data?.close_approval_flow && data?.close_approval_flow?.steps?.length){
|
||||
flowList.value = data?.close_approval_flow?.steps;
|
||||
}else{
|
||||
flowList.value = currentTemplate.value;
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 AI 辅助功能
|
||||
const aiAssistGroup = data.find((group:{group_key:string}) => group.group_key === 'ai_assist');
|
||||
if (aiAssistGroup) {
|
||||
const target = aiAssistGroup?.items?.find((item:any) => item.item_key === 'enabled');
|
||||
if (target) {
|
||||
isAiOn.value = target.value==1?true:false;
|
||||
}
|
||||
}
|
||||
getDetail();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 获取文件列表
|
||||
const getFileList = async () => {
|
||||
const {data,error} = await fileListApi(currentId.value);
|
||||
if(!error){
|
||||
fileList.value = data;
|
||||
}
|
||||
}
|
||||
// 人员数据转换
|
||||
const groupUsersByOrg = (users:any) => {
|
||||
const orgMap = new Map(); // key: org.id, value: { id, label, children }
|
||||
users.forEach((user:any) => {
|
||||
const { id: userId, realname, user_departments } = user;
|
||||
if (!user_departments || user_departments.length === 0) return;
|
||||
|
||||
user_departments.forEach((dept:any) => {
|
||||
const org = dept.org;
|
||||
if (!org || !org.id) return;
|
||||
|
||||
if (!orgMap.has(org.id)) {
|
||||
orgMap.set(org.id, {
|
||||
value: org.id,
|
||||
label: org.label,
|
||||
children: []
|
||||
});
|
||||
}
|
||||
const targetOrg = orgMap.get(org.id);
|
||||
// 避免同一个用户重复加入同一个组织(理论上不会发生,但做去重保护)
|
||||
if (!targetOrg.children.some((child:any) => child.id === userId)) {
|
||||
targetOrg.children.push({
|
||||
value: userId.toString(),
|
||||
label: realname,
|
||||
user_id: userId.toString()
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(orgMap.values());
|
||||
}
|
||||
|
||||
// 获取所有人员
|
||||
const getAllMembers = async () => {
|
||||
const {data,error} = await peopleListApi();
|
||||
if(!error){
|
||||
memberList.value = groupUsersByOrg(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在组织树中根据 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) => {
|
||||
flowList.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) => {
|
||||
flowList.value[idx].members[mIdx].specialty = v ?? null;
|
||||
}
|
||||
// 选择是否必须参与会签
|
||||
const selectSign = (idx: number, mIdx: number, v: number) => {
|
||||
flowList.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.id ? null : s.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(memberList.value, 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);
|
||||
}
|
||||
// 完成设置
|
||||
const finishApprover = async () => {
|
||||
window.$message?.success("审批流程设置完成");
|
||||
approvalDrawer.value = false;
|
||||
}
|
||||
|
||||
// 获取所有模板
|
||||
const getTemplateList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await templateListApi();
|
||||
if(!error){
|
||||
// 过滤出资料关闭模板
|
||||
const result = data.filter((item:any) => item.type === 3);
|
||||
// ******审批模板********
|
||||
// 取默认模板(资料关闭模板只有一个)
|
||||
currentTemplate.value = result[0]?.steps || [];
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
useDate.value = new Date().getTime();
|
||||
getAllMembers();
|
||||
await getTemplateList();
|
||||
if(route.query){
|
||||
if(route.query.key){
|
||||
backKey.value = route.query.key.toString();
|
||||
}
|
||||
if(route.query.id){
|
||||
currentId.value = Number(route.query.id);
|
||||
getFileList();
|
||||
getConfig();
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 资料关闭详情-->
|
||||
<NSpin :show="loading" size="small">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border border-slate-200 px-3 py-2 mb-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<NButton ghost type="primary" @click="back">
|
||||
<Icon icon="ep:back" class="size-16px mr-1" />返回
|
||||
</NButton>
|
||||
<div class="min-w-0 flex-1 truncate text-base font-bold text-slate-800">{{ currentInfo?.title }}</div>
|
||||
<NTag size="small" type="info" class="font-mono text-xs">编号:{{ currentInfo?.change_no }}</NTag>
|
||||
</div>
|
||||
<div class="space-y-3 scroll">
|
||||
<!-- 投用前确认 -->
|
||||
<div class="border rounded-md">
|
||||
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
|
||||
<p class="font-600 text-slate-800 text-sm">投用前确认(含变更培训、投用前检查)</p>
|
||||
<span class="text-slate-400 text-xs">上传线下纸质表照片 / 扫描件即视为完成确认,上传后可查阅、下载;空白模板可下载</span>
|
||||
</div>
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="border rounded-lg py-3 px-4 flex items-center gap-3">
|
||||
<Icon icon="bi:upload" class="size-18px" :style="{ color: trainFiles.length?themeStore.otherColor.success:themeStore.themeColor }" />
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-800 text-sm font-600">上传培训签到</span>
|
||||
<p v-if="trainFiles.length" class="py-0.5 px-1.5 text-11px bg-emerald-100 rounded-md text-emerald-700">已上传 · 视为完成确认</p>
|
||||
</div>
|
||||
<p class="text-slate-400 text-xs mt-1 leading-4">线下培训签到表照片或扫描件;上传后视为培训完成确认,签到记录随单归档</p>
|
||||
<div v-if="trainFiles.length" class="flex flex-wrap gap-2 mt-1">
|
||||
<div
|
||||
v-for="(file, fileIndex) in trainFiles"
|
||||
:key="fileIndex"
|
||||
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
|
||||
>
|
||||
<Icon icon="akar-icons:file" class="size-12px" />
|
||||
{{file.file_name}}
|
||||
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
|
||||
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'train')">下载</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="removeFileReal('3', Number(fileIndex))"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="text-11px">删除</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton ghost type="primary" size="small" class="text-xs" @click="downloadFile(null,'train')">
|
||||
<Icon icon="akar-icons:download" class="size-14px mr-1" />
|
||||
下载培训签到表
|
||||
</NButton>
|
||||
<NUpload
|
||||
class="w-auto"
|
||||
:key="trainFiles.length?'train_'+trainFiles.length+1:'train'"
|
||||
accept="image/*,.pdf,.docx,.xlsx,.pptx"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
@change="(files) => onUploadFile('3', files)"
|
||||
>
|
||||
<NButton type="primary" size="small" class="text-xs" :disabled="loading">上传</NButton>
|
||||
</NUpload>
|
||||
</div>
|
||||
<div class="border rounded-lg py-3 px-4 flex items-center gap-3">
|
||||
<Icon icon="bi:upload" class="size-18px" :style="{ color: pssrFiles.length?themeStore.otherColor.success:themeStore.themeColor }" />
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-800 text-sm font-600">上传 PSSR 检查</span>
|
||||
<p v-if="pssrFiles.length" class="py-0.5 px-1.5 text-11px bg-emerald-100 rounded-md text-emerald-700">已上传 · 视为完成确认</p>
|
||||
</div>
|
||||
<p class="text-slate-400 text-xs mt-1 leading-4">PSSR 纸质确认表照片或扫描件;上传后视为 PSSR 全项确认完成,具备投用条件</p>
|
||||
<div v-if="pssrFiles.length" class="flex flex-wrap gap-2 mt-1">
|
||||
<div
|
||||
v-for="(file, fileIndex) in pssrFiles"
|
||||
:key="fileIndex"
|
||||
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
|
||||
>
|
||||
<Icon icon="akar-icons:file" class="size-12px" />
|
||||
{{file.file_name}}
|
||||
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
|
||||
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'pssr')">下载</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="removeFileReal('4', Number(fileIndex))"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="text-11px">删除</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton ghost type="primary" size="small" class="text-xs" @click="downloadFile(null,'pssr')">
|
||||
<Icon icon="akar-icons:download" class="size-14px mr-1" />
|
||||
下载PSSR检查表
|
||||
</NButton>
|
||||
<NUpload
|
||||
class="w-auto"
|
||||
:key="pssrFiles.length?'pssr_'+pssrFiles.length+1:'pssr'"
|
||||
accept="image/*,.pdf,.docx,.xlsx,.pptx"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
@change="(files) => onUploadFile('4', files)"
|
||||
>
|
||||
<NButton type="primary" size="small" class="text-xs" :disabled="loading">上传</NButton>
|
||||
</NUpload>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 变更投用时间 -->
|
||||
<div class="border rounded-md">
|
||||
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
|
||||
<p class="font-600 text-slate-800 text-sm">变更投用时间</p>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-slate-600">实际投用时间</p>
|
||||
<NDatePicker v-model:value="useDate" type="date" size="small" />
|
||||
<p class="text-xs text-slate-400 mt-1px">默认为当天日期,可按实际投用日期调整</p>
|
||||
</div>
|
||||
<div class="mt-2.5 rounded-lg border px-3 py-2 text-[11px] leading-5 border-amber-200 bg-amber-50/60 text-amber-700"> 投用提示:投用前应完成 PSSR 检查。 未完成的变更不得投用。</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 变更验收 -->
|
||||
<div class="border rounded-md">
|
||||
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
|
||||
<p class="font-600 text-slate-800 text-sm">变更验收</p>
|
||||
<span class="text-slate-400 text-xs">投用后组织验收:上传验收资料并选择验收结论;上传后可查阅、下载;验收表可下载</span>
|
||||
</div>
|
||||
<div class="p-3 space-y-2.5">
|
||||
<div class="border rounded-lg py-3 px-4 flex items-center gap-3">
|
||||
<Icon icon="bi:upload" class="size-18px" :style="{ color: acceptFiles.length?themeStore.otherColor.success:themeStore.themeColor }" />
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-800 text-sm font-600">上传验收资料</span>
|
||||
<p v-if="pssrFiles.length" class="py-0.5 px-1.5 text-11px bg-emerald-100 rounded-md text-emerald-700">已上传 · 视为完成确认</p>
|
||||
</div>
|
||||
<p class="text-slate-400 text-xs mt-0.5 leading-4">验收报告、运行数据记录等验收证明材料;上传后纳入归档清单</p>
|
||||
<div v-if="acceptFiles.length" class="flex flex-wrap gap-2 mt-1">
|
||||
<div
|
||||
v-for="(file, fileIndex) in acceptFiles"
|
||||
:key="fileIndex"
|
||||
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
|
||||
>
|
||||
<Icon icon="akar-icons:file" class="size-12px" />
|
||||
{{file.file_name}}
|
||||
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
|
||||
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'accept')">下载</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="removeFileReal('5', Number(fileIndex))"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="text-11px">删除</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton ghost type="primary" size="small" class="text-xs" @click="downloadFile(null,'accept')">
|
||||
<Icon icon="akar-icons:download" class="size-14px mr-1" />
|
||||
下载验收表
|
||||
</NButton>
|
||||
<NUpload
|
||||
class="w-auto"
|
||||
:key="acceptFiles.length?'accept_'+acceptFiles.length+1:'accept'"
|
||||
accept="image/*,.pdf,.docx,.xlsx,.pptx"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
@change="(files) => onUploadFile('5', files)"
|
||||
>
|
||||
<NButton type="primary" size="small" class="text-xs" :disabled="loading">上传</NButton>
|
||||
</NUpload>
|
||||
</div>
|
||||
<div class="border rounded-lg py-3 px-4 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-xs font-600" :style="{ color: themeStore.themeColor }">验收结论(互斥选择)</p>
|
||||
<NButton size="small" :type="resultStatus === 3?'primary':'default'" class="text-xs" @click="resultStatus = 3">完全达到</NButton>
|
||||
<NButton size="small" :type="resultStatus === 2?'primary':'default'" class="text-xs" @click="resultStatus = 2">基本达到</NButton>
|
||||
<NButton size="small" :type="resultStatus === 1?'primary':'default'" class="text-xs" @click="resultStatus = 1">未达到</NButton>
|
||||
</div>
|
||||
<NInput v-model:value="acceptDesc" type="textarea" size="small" :autosize="{ minRows: 2 }" placeholder="请输入验收结论" />
|
||||
</div>
|
||||
<div class="border rounded-lg py-3 px-4 flex items-center gap-2">
|
||||
<p class="text-xs font-600" :style="{ color: themeStore.themeColor }">变更材料和实施费(万元)</p>
|
||||
<NInputNumber v-model:value="price" size="small" :min="0" :show-button="false" placeholder="如 12.6" />
|
||||
<p class="text-xs text-slate-400 mt-1px">非必填 · 填报本次变更材料与实施费用合计,用于变更投入与效果统计</p>
|
||||
</div>
|
||||
<div class="border rounded-lg py-3 px-4 flex items-center gap-2">
|
||||
<p class="text-xs font-600" :style="{ color: themeStore.themeColor }">验收时间</p>
|
||||
<NDatePicker v-model:value="acceptDate" type="date" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 变更关闭资料归档确认 -->
|
||||
<div class="border rounded-md">
|
||||
<div class="flex items-center gap-x-3 px-4 py-2.5 border-b">
|
||||
<p class="font-600 text-slate-800 text-sm">变更关闭资料归档确认</p>
|
||||
<span class="text-slate-400 text-xs">涉及(本地上传或从变更附件选择)· 不涉及(无需处理)</span>
|
||||
<NButton v-if="isAiOn" size="small" type="primary" ghost class="ml-auto text-xs">AI辅助判定</NButton>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>资料名称</th>
|
||||
<th class="!text-center">是否涉及</th>
|
||||
<th class="w-200px !text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in list" :key="index">
|
||||
<td>
|
||||
<p>{{item.doc_name}}</p>
|
||||
<div v-if="item.attachments.length" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(file, fileIndex) in item.attachments"
|
||||
:key="fileIndex"
|
||||
class="text-11px flex items-center gap-2 border py-0.5 px-1.5 rounded-md text-[#17407F] border-[#ACC6E5] bg-[#EAF0F9]"
|
||||
>
|
||||
<Icon icon="akar-icons:file" class="size-12px" />
|
||||
{{file.file_name}}
|
||||
<NButton text type="primary" class="text-11px" @click="preview(file)">预览</NButton>
|
||||
<NButton text type="primary" class="text-11px" @click="downloadFile(file,'close')">下载</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="removeFileReal(item.doc_name, Number(fileIndex), Number(index))"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="text-11px">删除</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex justify-center gap-2">
|
||||
<NButton size="small" :type="item.status === 1 ? 'primary' : 'default'" class="text-xs" @click="item.status = 1">涉及</NButton>
|
||||
<NButton size="small" :type="item.status === 2 ? 'primary' : 'default'" class="text-xs" @click="item.status = 2">不涉及</NButton>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="item.status === 1" class="flex justify-center gap-2">
|
||||
<NButton size="small" class="text-xs" @click="openFileList(Number(index))">从变更附件选择</NButton>
|
||||
<NUpload
|
||||
class="w-auto"
|
||||
:key="item.id"
|
||||
accept="image/*,.pdf,.docx,.xlsx,.pptx"
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
@change="(files) => onUploadFile(item.doc_name, files, Number(index))"
|
||||
>
|
||||
<NButton type="primary" size="small" ghost class="text-xs" :disabled="loading">
|
||||
本地上传
|
||||
</NButton>
|
||||
</NUpload>
|
||||
</div>
|
||||
<div v-if="item.status === 2" class="flex justify-end gap-2">
|
||||
<span class="text-xs text-slate-400">无需处理</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end border-t pt-3 mt-3">
|
||||
<div class="flex gap-2">
|
||||
<NButton size="small" @click="approvalDrawer = true">审批流程设置</NButton>
|
||||
<NButton ghost size="small" @click="back">取消</NButton>
|
||||
<NButton ghost type="primary" size="small" @click="submitCloseDoc('save')">保存</NButton>
|
||||
<NButton type="primary" size="small" @click="submitCloseDoc('close')">申请关闭</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 审批流程设置 -->
|
||||
<NDrawer v-model:show="approvalDrawer" placement="right" width="28rem" :auto-focus="false">
|
||||
<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 flowList" :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.id" :value="pick" :options="memberList"
|
||||
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="flowList.length" type="primary" class="w-full" @click="finishApprover">完成设置</NButton>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<!-- 附件选择弹框 -->
|
||||
<NModal
|
||||
v-model:show="filesModal"
|
||||
preset="card"
|
||||
title="从变更附件选择"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '450px', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<div v-if="fileList.length" class="max-h-60vh overflow-y-auto">
|
||||
<NCheckboxGroup v-model:value="checkedFiles">
|
||||
<div class="space-y-2">
|
||||
<div v-for="item in fileList" :key="item.id">
|
||||
<NCheckbox :value="item.id" :label="item.file_name" />
|
||||
</div>
|
||||
</div>
|
||||
</NCheckboxGroup>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NEmpty description="暂无附件"></NEmpty>
|
||||
</div>
|
||||
<template v-if="fileList.length" #footer>
|
||||
<div class="flex justify-end gap-[10px]">
|
||||
<NButton size="small" @click="filesModal = false">取消</NButton>
|
||||
<NButton size="small" type="primary" @click="chooseFiles">确定选择</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
<!-- 附件在线预览弹窗 -->
|
||||
<NModal
|
||||
v-model:show="previewModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '80%', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<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?.file_name }}
|
||||
</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>上传人:{{ previewFile?.uploader_name }}</span>
|
||||
<span>上传时间:{{ previewFile?.created_at }}</span>
|
||||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||||
</div>
|
||||
<previewComponent v-if="previewFile" :previewFile="previewFile" />
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</NCard>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 263px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,739 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { tagTextColor, tagBgColor, tagBorderColor, downloadFileFun } from '@/utils/common';
|
||||
import { AiError, aiTypeJudge } from "@/service/api/ai";
|
||||
import { systemConfigApi } from '@/service/api/system';
|
||||
import { getChangeDetailApi, urgeApi } from '@/service/api/change';
|
||||
import previewComponent from '@/components/previewFile/index.vue'
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 返回键
|
||||
const backKey = ref<string>('');
|
||||
// 判定说明
|
||||
const typeAiLoading = ref<boolean>(false);
|
||||
const typeAi = ref<{ type: string; confidence: number; conclusion: string; hit_rules: string[]; exclude_rules: string[]; knowledge_refs: string[] } | null>(null);
|
||||
// 当前变更详情
|
||||
const currentInfo = ref<any>(null);
|
||||
// 当前变更id
|
||||
const currentId = ref<number>(0);
|
||||
// 是否开启 AI 辅助功能
|
||||
const isAiOn = ref<boolean>(false);
|
||||
// 变更等级选项
|
||||
const levelList = ref<any>([]);
|
||||
// 变更类型选项
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级
|
||||
const level = computed(() => {
|
||||
return currentInfo.value?.apply_form?.change_level;
|
||||
});
|
||||
// 总得分
|
||||
const totalScore = computed(() => {
|
||||
return currentInfo.value?.apply_form?.level_score?.reduce((sum: number, item: any) => {
|
||||
const n = Number(item.score);
|
||||
return sum + (Number.isNaN(n) ? 0 : n);
|
||||
}, 0);
|
||||
});
|
||||
// 当前审批所有人员
|
||||
const allMembers = computed(() => {
|
||||
return currentInfo.value?.approval_flow?.steps?.map((item: any) => item.members).flat();
|
||||
});
|
||||
// pssr 检查内容
|
||||
const pssrlist = ref<{ g: string; type:number; na_flag:number; items: any[] }[]>([
|
||||
{ g: "一、风险分析行动项落实", type:1, na_flag:0, items: [] },
|
||||
{ g: "二、现场设备安装检查确认", type:2, na_flag:0, items: [] },
|
||||
{ g: "三、保护措施确认", type:3, na_flag:0, items: [] },
|
||||
{ g: "四、资料归档确认", type:4, na_flag:0, items: [] },
|
||||
]);
|
||||
// 验收评价
|
||||
const acceptList = ref<any>([]);
|
||||
// 当前预览文件
|
||||
const previewFile = ref<any>(null);
|
||||
// 预览文件弹框
|
||||
const previewModal = ref<boolean>(false);
|
||||
// 等级判断问题
|
||||
const questions = ref<any>([
|
||||
{
|
||||
question_id: 1,
|
||||
name: "1. 设备影响",
|
||||
note: "不合适的材质将会导致",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "对设备带来一定的损害及腐蚀", score: 1 },
|
||||
{ label: "对设备带来中等的损害及腐蚀", score: 2 },
|
||||
{ label: "对设备带来明显的损害及腐蚀", score: 3 },
|
||||
{ label: "对设备带来重大的损害及腐蚀", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 2,
|
||||
name: "2. 财务影响",
|
||||
note: "工艺设计错误将会导致",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "一定损失,a<1万人民币", score: 1 },
|
||||
{ label: "中等损失,直接损失1万≤a<10万人民币", score: 2 },
|
||||
{ label: "大量损失,直接损失10万≤a<100万人民币", score: 3 },
|
||||
{ label: "巨额损失,直接损失100万人民币≤a", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 3,
|
||||
name: "3. 质量影响",
|
||||
note: "",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "对质量带来一定的风险(非关键工席、非关键设备设施的变更)", score: 1 },
|
||||
{ label: "对质量带来中等风险(B类原料替代、生产辅助设备变更)", score: 2 },
|
||||
{ label: "对质量带来明显的风险(主要工艺参数、中间体生产工艺变更)", score: 3 },
|
||||
{ label: "对质量有很高风险(主要工艺路线、关键工艺参数变更)", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 4,
|
||||
name: "4. 设计的复杂程度",
|
||||
note: "工艺设计包含了",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "少量的简单系统(单个设备或单个部件)", score: 1 },
|
||||
{ label: "大量的简单系统(多个单类设备或部件)", score: 2 },
|
||||
{ label: "至少一个复杂系统(涉及多设备/工艺相应调整)", score: 3 },
|
||||
{ label: "大量的复杂系统(需要装置配套改造调整)", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 5,
|
||||
name: "5. 工艺设计的成熟度",
|
||||
note: "",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "被频繁使用,且被证明为专利方设计", score: 1 },
|
||||
{ label: "被少量使用,或被证明为专利方设计", score: 2 },
|
||||
{ label: "被相关工业设计方法或参考文献认可(非专利方)", score: 3 },
|
||||
{ label: "新的,未发表的", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 6,
|
||||
name: "6. 工艺危害",
|
||||
note: "物料性质和工艺条件",
|
||||
score: 0,
|
||||
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 describeDrawer = ref<boolean>(false);
|
||||
const levelDrawer = ref<boolean>(false);
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
// 打开变更类型抽屉
|
||||
const openTypeSheet = async () => {
|
||||
typeAi.value = null;
|
||||
describeDrawer.value = true;
|
||||
if (typeAiLoading.value) return;
|
||||
typeAiLoading.value = true;
|
||||
try {
|
||||
const res = await aiTypeJudge(currentInfo.value?.precheck?.description, (currentInfo.value?.precheck?.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
|
||||
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 back = () => {
|
||||
routerPushByKey(backKey.value as any);
|
||||
};
|
||||
|
||||
// 导出
|
||||
const exportFile = (type: string) => {
|
||||
window.$message?.info('差接口')
|
||||
// if(type === 'change'){
|
||||
// window.$message?.success('导出变更申请表成功');
|
||||
// }
|
||||
// if(type === 'risk'){
|
||||
// window.$message?.success('导出风险检查行动项表成功');
|
||||
// }
|
||||
};
|
||||
|
||||
// 变更附带资料下载/一键下载
|
||||
const downloadFile = (file:any) => {
|
||||
if(file){
|
||||
downloadFileFun(file?.file_url, file?.doc_name || file?.file_name);
|
||||
}else{
|
||||
window.$message?.info('差接口')
|
||||
}
|
||||
};
|
||||
// 变更附带资料预览
|
||||
const openPreview = (file:any) => {
|
||||
previewFile.value = file;
|
||||
previewModal.value = true;
|
||||
}
|
||||
|
||||
// 催办
|
||||
const handleReminder = async (m: any) => {
|
||||
if(loading.value) return;
|
||||
loading.value = true;
|
||||
const {error} = await urgeApi(currentId.value);
|
||||
if(!error){
|
||||
window.$message?.success('催办成功');
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 匹配评分依据
|
||||
const matchBasis = (items:any,score: number) => {
|
||||
return items.find((item:any) => item.score === score)?.label || '';
|
||||
}
|
||||
|
||||
// 匹配label
|
||||
const matchLabel = (list:any,v: number) => {
|
||||
return list.find((item:any) => item.value === v) || null;
|
||||
}
|
||||
|
||||
// 获取变更详情
|
||||
const getDetail = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await getChangeDetailApi(currentId.value)
|
||||
if(!error){
|
||||
currentInfo.value = data;
|
||||
|
||||
// ******解析 PSSR 检查内容********
|
||||
// 1. 按 group_type 分组
|
||||
const map = new Map();
|
||||
currentInfo.value?.pssr?.items?.forEach((item:any) => {
|
||||
const t = item.group_type;
|
||||
if (!map.has(t)) map.set(t, []);
|
||||
map.get(t).push(item);
|
||||
});
|
||||
|
||||
// 2. 生成目标数组
|
||||
const result = pssrlist.value.map(({ type, g }:any) => {
|
||||
const items = map.get(type) || [];
|
||||
// na_flag 取该组第一个元素的 na_flag,无数据时默认为 0
|
||||
const na_flag = items.length > 0 ? items[0].na_flag : 0;
|
||||
return {
|
||||
g,
|
||||
type,
|
||||
na_flag,
|
||||
items
|
||||
};
|
||||
});
|
||||
// 3. 过滤掉不需要的字段
|
||||
const omitFields = ['created_at', 'updated_at', 'change_id', 'group_name'];
|
||||
const newResult = result.map((group:any) => ({
|
||||
...group,
|
||||
items: group.items.map((item:any) =>
|
||||
Object.fromEntries(Object.entries(item).filter(([k]) => !omitFields.includes(k)))
|
||||
)
|
||||
}));
|
||||
pssrlist.value = newResult;
|
||||
|
||||
// ******解析 验收评价********
|
||||
const omit = (obj:any, fields: string[]) => Object.fromEntries(Object.entries(obj).filter(([k]) => !fields.includes(k)));
|
||||
const result2 = currentInfo.value?.accept_form?.items.map((item:any) => omit(item, [
|
||||
'created_at', 'updated_at', 'change_id'
|
||||
]));
|
||||
acceptList.value = result2 || [];
|
||||
|
||||
// ******解析 等级判断********
|
||||
// 建立映射:question_id -> score
|
||||
const scoreMap = new Map(
|
||||
currentInfo.value?.apply_form?.level_score?.map((item:any) => [item.question_id, item.score])
|
||||
);
|
||||
// 遍历第一个数组,匹配到就替换 score
|
||||
questions.value.forEach((item:any) => {
|
||||
if (scoreMap.has(item.question_id)) {
|
||||
item.score = scoreMap.get(item.question_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 AI 辅助功能
|
||||
const aiAssistGroup = data.find((group:{group_key:string}) => group.group_key === 'ai_assist');
|
||||
if (aiAssistGroup) {
|
||||
const target = aiAssistGroup?.items?.find((item:any) => item.item_key === 'enabled');
|
||||
if (target) {
|
||||
isAiOn.value = target.value==1?true:false;
|
||||
}
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items.map((item:any) => ({
|
||||
...item,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items.map((item:any) => ({
|
||||
...item,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
}
|
||||
getDetail();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if(route.query){
|
||||
if(route.query.key){
|
||||
backKey.value = route.query.key.toString();
|
||||
}
|
||||
if(route.query.id){
|
||||
currentId.value = Number(route.query.id);
|
||||
getConfig();
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 高风险 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border border-slate-200 px-3 py-2 mb-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<NButton ghost type="primary" @click="back">
|
||||
<Icon icon="ep:back" class="size-16px mr-1" />返回
|
||||
</NButton>
|
||||
<div class="min-w-0 flex-1 truncate text-base font-bold text-slate-800">{{ currentInfo?.title }}</div>
|
||||
<NTag size="small" type="info" class="font-mono text-xs">编号:{{ currentInfo?.change_no }}</NTag>
|
||||
</div>
|
||||
<div class="space-y-3 scroll">
|
||||
<!-- 变更申请表 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p
|
||||
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
|
||||
:style="{'--theme-color': themeStore.themeColor}"
|
||||
>变更申请表</p>
|
||||
<NButton ghost size="small" class="text-xs" @click="exportFile('change')"><Icon icon="material-symbols:download" class="size-16px" />导出变更申请表</NButton>
|
||||
</div>
|
||||
<div
|
||||
class="border border-slate-200"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更发起人</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.applicant_name }}</div>
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">申请单位</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.org_name }}</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">发起日期</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.created_at?.slice(0,10) }}</div>
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">计划投用日期</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.plan_use_date?.slice(0,10) }}</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更时限</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.duration_type === 1 ? '永久变更' : '临时变更' }}</div>
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">紧急</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">{{ currentInfo?.apply_form?.urgent === 1 ? '是' : '否' }}</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr_100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更等级</p>
|
||||
<div class="flex items-center flex-wrap gap-2 px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
<NRadioGroup v-model:value="level">
|
||||
<NRadio v-for="item in levelList" :key="item.value" :value="item.value" :disabled="true">{{item.label}}</NRadio>
|
||||
</NRadioGroup>
|
||||
<NTag type="error" size="small">合计 {{totalScore}} 分</NTag>
|
||||
<NButton ghost type="warning" size="small" round class="text-xs" @click="levelDrawer = true">
|
||||
<Icon icon="lucide:eye" class="size-12px mr-1" />查看等级判定
|
||||
</NButton>
|
||||
</div>
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更类别</p>
|
||||
<div class="flex items-center gap-2 px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{matchLabel(typeList,currentInfo?.apply_form?.change_type)?.label}}
|
||||
<NButton v-if="isAiOn" ghost type="primary" size="small" round class="text-xs" @click="openTypeSheet">
|
||||
<Icon icon="lucide:sparkles" class="size-12px mr-1" />判定说明
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">变更目的</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{currentInfo?.apply_form?.purposes?.join('、')}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">预期效果</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{currentInfo?.apply_form?.effect}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">主要变更</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
<p v-for="(main,mIndex) in currentInfo?.apply_form?.main_changes" :key="mIndex">{{Number(mIndex)+1}}.{{main}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">连带变更</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
<p v-for="(related,rIndex) in currentInfo?.apply_form?.related_changes" :key="rIndex">{{Number(rIndex)+1}}.{{related}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">风险评估方法</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{currentInfo?.apply_form?.risk_tools?.join('、')}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">所需材料</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{currentInfo?.apply_form?.materials}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid border-b grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">需更新资料</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{currentInfo?.apply_form?.update_docs?.join('、')}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[100px_1fr]">
|
||||
<p class="flex items-center justify-end px-3 py-1.6 text-xs text-slate-500 bg-[#fafafc]">涉及专业</p>
|
||||
<div class="px-3 py-1.6 text-sm leading-relaxed text-slate-800">
|
||||
{{currentInfo?.apply_form?.disciplines?.join('、')}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 变更附带资料 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p
|
||||
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
|
||||
:style="{'--theme-color': themeStore.themeColor}"
|
||||
>变更附带资料</p>
|
||||
<NButton ghost size="small" class="text-xs" @click="downloadFile(null)"><Icon icon="material-symbols:download" class="size-16px" />一键下载</NButton>
|
||||
</div>
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-60px !text-center">序号</th>
|
||||
<th>文件名</th>
|
||||
<th class="w-100px !text-center">类型</th>
|
||||
<th class="w-100px !text-center">文件大小</th>
|
||||
<th class="w-120px !text-center">上传时间</th>
|
||||
<th class="w-120px !text-center">上传阶段</th>
|
||||
<th class="w-90px !text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(doc,dIndex) in currentInfo?.apply_form?.attachments" :key="dIndex">
|
||||
<td class="text-center">1</td>
|
||||
<td>{{doc.file_name}}</td>
|
||||
<td class="text-center text-xs">{{doc?.file_type}}</td>
|
||||
<td class="text-center text-xs">{{doc?.file_size}}</td>
|
||||
<td class="text-center text-xs">{{doc?.created_at?.slice(0,16)}}</td>
|
||||
<td class="text-center text-xs">
|
||||
<span v-if="doc?.step_no===1">变更申请表</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<NButton text type="primary" class="text-xs" @click="openPreview(doc)">预览</NButton>
|
||||
<NButton text type="primary" class="text-xs" @click="downloadFile(doc)">下载</NButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
|
||||
<!-- 风险分析记录表 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p
|
||||
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
|
||||
:style="{'--theme-color': themeStore.themeColor}"
|
||||
>风险分析记录表</p>
|
||||
<NButton ghost size="small" class="text-xs" @click="exportFile('risk')"><Icon icon="material-symbols:download" class="size-16px" />导出风险分析记录表</NButton>
|
||||
</div>
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-60px !text-center">序号</th>
|
||||
<th>风险项</th>
|
||||
<th>场景描述</th>
|
||||
<th class="w-80px !text-center">固有风险</th>
|
||||
<th>现有措施</th>
|
||||
<th>建议措施</th>
|
||||
<th class="w-80px !text-center">剩余风险</th>
|
||||
<th class="w-100px !text-center">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(risk,index) in currentInfo?.risk_records?.records" :key="index">
|
||||
<td class="text-center">{{Number(index)+1}}</td>
|
||||
<td>{{risk.item}}</td>
|
||||
<td>{{risk.scene}}</td>
|
||||
<td class="text-center">{{risk.inherent_level}}</td>
|
||||
<td>{{risk.safeguards}}</td>
|
||||
<td>{{risk.suggestions}}</td>
|
||||
<td class="text-center">{{risk.residual_level}}</td>
|
||||
<td class="text-center">
|
||||
<NTag size="small" type="info" class="text-11px">{{ risk.source==='RISK'?'风险预分析':risk.source==='RISK_CHECK'?'风险检查': risk.source }}</NTag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
|
||||
<!-- 变更培训内容 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<Icon icon="lucide:graduation-cap" class="size-16px text-violet-500" />
|
||||
<span class="text-sm font-semibold text-slate-700">变更培训内容</span>
|
||||
</div>
|
||||
<div class="space-y-2.5 border p-3" :style="{borderRadius: themeStore.themeRadius + 'px'}">
|
||||
<p>{{currentInfo?.train?.content}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PSSR 检查内容 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<Icon icon="lucide:list-checks" class="size-16px text-violet-500" />
|
||||
<span class="text-sm font-semibold text-slate-700">PSSR 检查内容</span>
|
||||
</div>
|
||||
<div class="space-y-2.5 border p-3" :style="{borderRadius: themeStore.themeRadius + 'px'}">
|
||||
<div v-for="(pssr,index) in pssrlist" class="border" :key="index" :style="{borderRadius: themeStore.themeRadius + 'px'}">
|
||||
<div class="bg-slate-100 px-3 py-1.6 font-medium text-slate-600">{{pssr.g}}</div>
|
||||
<div v-for="item in pssr.items" :key="item.id" class="flex items-center border-t px-3 py-1.6">
|
||||
<p class="flex-1">{{item.content}}</p>
|
||||
<NTag v-if="item.phase===1" size="small" type="info">投用前完成</NTag>
|
||||
<NTag v-else size="small" type="info">关闭前完成</NTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 验收评价 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<Icon icon="ix:success" class="size-16px text-emerald-500" />
|
||||
<span class="text-sm font-semibold text-slate-700">验收评价</span>
|
||||
</div>
|
||||
<div class="border p-3" :style="{borderRadius: themeStore.themeRadius + 'px'}">
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-56">验收项目</th>
|
||||
<th>量化验收标准</th>
|
||||
<th>验收方式</th>
|
||||
<th class="w-46 !text-center">组织层级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(accept,index) in acceptList" :key="index">
|
||||
<td class="text-xs">{{accept.item}}</td>
|
||||
<td class="text-xs">{{accept.standard}}</td>
|
||||
<td class="text-xs">{{accept.method}}</td>
|
||||
<td class="text-xs text-center">{{accept.org_name}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 审批记录 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p
|
||||
class="rounded-sm bg-[var(--theme-color)] px-3 py-1 text-xs font-semibold text-white"
|
||||
:style="{'--theme-color': themeStore.themeColor}"
|
||||
>审批记录</p>
|
||||
</div>
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-120px">流程阶段</th>
|
||||
<th class="w-120px">操作人</th>
|
||||
<th class="w-140px !text-center">操作时间</th>
|
||||
<th>操作内容和意见</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(record,index) in currentInfo?.approval_records" :key="index">
|
||||
<td class="text-xs">{{record.action}}</td>
|
||||
<td class="text-xs">{{record.user_name}}</td>
|
||||
<td class="text-center text-xs">{{record.updated_at?.slice(0,16)}}</td>
|
||||
<td class="text-xs">{{record.remark}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
|
||||
<!-- 审批流程进度 -->
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold">审批流程{{ matchLabel(levelList,level)?.label? '('+matchLabel(levelList,level)?.label+'变更)':'' }}</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<template v-for="(n, i) in currentInfo?.approval_flow?.steps" :key="n.name">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon v-if="i !== 0" icon="formkit:right" class="size-14px text-slate-300" />
|
||||
<div class="rounded-lg border px-3 py-2 text-center"
|
||||
:class="n.status === 'PASSED' ? 'border-emerald-300 bg-emerald-50' : n.status === 'PENDING' ? 'border-amber-400 bg-amber-50' : n.status === 'REJECTED' ? 'border-red-300 bg-red-50' :'border-amber-400 bg-amber-50'">
|
||||
<p class="text-xs font-medium">
|
||||
{{ n.name }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<span v-for="member in n.members" :key="member.id" class="text-xs text-slate-500">{{ member.user_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="allMembers?.length" class="rounded-lg border border-amber-200 bg-amber-50/60 p-3">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2 text-xs font-semibold text-amber-700">
|
||||
会签进度(全体通过方可流转)
|
||||
<span class="font-normal text-amber-600">
|
||||
{{ allMembers.filter((m) => m.status === 'PASSED').length }}/{{ allMembers.length }} 已签
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-for="m in allMembers" :key="m.id" class="flex items-center gap-1">
|
||||
<NTag size="small" :type="m.status === 'PASSED' ? 'success' : m.status === 'REJECTED' ? 'error' : 'default'">
|
||||
<template #icon>
|
||||
<Icon v-if="m.status === 'PASSED'" icon="ix:success" class="size-14px" />
|
||||
<Icon v-if="m.status === 'PENDING'" icon="ant-design:clock-circle-outlined" class="size-12px text-slate-400" />
|
||||
<Icon v-if="m.status === 'REJECTED'" icon="ix:close" class="size-12px" />
|
||||
</template>
|
||||
{{ m.user_name }}
|
||||
</NTag>
|
||||
<div v-if="m.status === 'PENDING'"
|
||||
class="cursor-pointer inline-flex h-6 items-center gap-1 rounded-md border border-amber-300 bg-white px-2 text-[11px] text-amber-700 transition hover:bg-amber-100"
|
||||
@click="handleReminder(m)"
|
||||
>
|
||||
<Icon icon="lucide:bell" class="size-12px" /> 催办
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 变更类型判定说明(右侧抽屉) -->
|
||||
<NDrawer v-model:show="describeDrawer" :width="576" placement="right" :auto-focus="false">
|
||||
<NDrawerContent title="变更类型判定说明" closable>
|
||||
<NTag v-if="typeAiLoading" type="info" size="small">
|
||||
<template #icon>
|
||||
<Icon icon="ri:loader-4-fill" class="size-12px animate-spin" />
|
||||
</template>
|
||||
AI 判定中…
|
||||
</NTag>
|
||||
<div v-else class="flex items-center gap-2 max-h-[22px] mb-3">
|
||||
<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="typeAi?.type" type="info" size="small">判定:{{ typeAi?.type }}</NTag>
|
||||
<NTag v-if="typeAi" size="small">置信度:{{ `${Math.round(typeAi.confidence * 100)}%` }}</NTag>
|
||||
</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>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<!-- 变更等级判定表(右侧抽屉查看) -->
|
||||
<NDrawer v-model:show="levelDrawer" :width="576" placement="right" :auto-focus="false">
|
||||
<NDrawerContent title="变更等级判定表" closable>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchLabel(levelList,level)?.color),
|
||||
textColor: tagTextColor(matchLabel(levelList,level)?.color),
|
||||
borderColor: tagBorderColor(matchLabel(levelList,level)?.color),
|
||||
}"
|
||||
>{{ matchLabel(levelList,level)?.label? matchLabel(levelList,level)?.label+'变更':'' }}</NTag>
|
||||
<span class="text-xs text-slate-400">合计得分 <b class="text-[#17407F]">{{ totalScore }}</b> 分(<20 分为一般,≥20 分为重要)</span>
|
||||
</div>
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr class="border-b bg-slate-50">
|
||||
<th class="w-40">评估维度</th>
|
||||
<th class="w-20 !text-center">得分</th>
|
||||
<th>评分依据</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in questions" :key="s.dim" class="border-b last:border-b-0">
|
||||
<td class="font-medium">{{ s.name }}</td>
|
||||
<td class="font-bold text-[#17407F] text-center">{{ s.score }}</td>
|
||||
<td class="text-xs leading-relaxed text-slate-600">{{ matchBasis(s.options,s.score) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<!-- 附件在线预览弹窗 -->
|
||||
<NModal
|
||||
v-model:show="previewModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '80%', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<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?.file_name }}
|
||||
</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>上传人:{{ previewFile?.uploader_name }}</span>
|
||||
<span>上传时间:{{ previewFile?.created_at }}</span>
|
||||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||||
</div>
|
||||
<previewComponent v-if="previewFile" :previewFile="previewFile" />
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 210px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
+653
-181
@@ -1,137 +1,497 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { ref, onMounted, h } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { NButton, NTag, NDropdown, useDialog } from 'naive-ui';
|
||||
import previewComponent from '@/components/previewFile/index.vue'
|
||||
import { downloadFileFun } from '@/utils/common';
|
||||
import {
|
||||
docTreeListApi,
|
||||
addFolderApi,
|
||||
editFolderApi,
|
||||
deleteFolderApi,
|
||||
docListApi,
|
||||
uploadDocApi,
|
||||
updateDocApi,
|
||||
docHistoryListApi
|
||||
} from '@/service/api/doc';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
const dialog = useDialog()
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
const fileLoading = ref<boolean>(false);
|
||||
const uploadDocRef = ref<any>(null);
|
||||
const updateDocRef = ref<any>(null);
|
||||
// 附件在线预览弹窗
|
||||
const previewModal = ref<boolean>(false);
|
||||
// 附件在线预览文件
|
||||
const previewFile = ref<any>(null);
|
||||
// 历史版本查看抽屉
|
||||
const historyDrawer = ref<boolean>(false);
|
||||
// 历史版本查看加载中
|
||||
const historyLoading = ref<boolean>(false);
|
||||
// 历史版本查看列表
|
||||
const historyList = ref<any[]>([]);
|
||||
// 当前org_id
|
||||
const currentOrgId = ref<number>(0);
|
||||
// 当前资料id
|
||||
const currentDocId = ref<number>(0);
|
||||
// 搜索关键词
|
||||
const keyword = ref<string>('');
|
||||
const uploadFiles = () => {
|
||||
window.$message?.success('演示:上传资料(限资料管理员 / 各专业专责维护;上传后进入受控评审,发布生效并生成新版本号,全程留痕)')
|
||||
// 资料库树状列表
|
||||
const docList = ref<any[]>([]);
|
||||
// 文件版本列表
|
||||
const fileList = ref<any[]>([]);
|
||||
// 搜索结果列表
|
||||
const queryList = ref<any[]>([]);
|
||||
// 展示数还是列表
|
||||
const showTree = ref<boolean>(true);
|
||||
// 当前选中的节点(点击)
|
||||
const currentNode = ref<any>(null)
|
||||
// 当前选中的节点(右键)
|
||||
const currentNodeRightClick = ref<any>(null)
|
||||
// 当前位置
|
||||
const currentPath = ref<string[]>([]);
|
||||
// 展开节点
|
||||
const expandedKeys = ref<string[]>([])
|
||||
// 选中节点
|
||||
const selectedKeys = ref<string[]>([])
|
||||
// 新增/编辑文件夹确认加载中
|
||||
const saveLoading = ref<boolean>(false)
|
||||
// 弹框类型
|
||||
const modalType = ref<string>('add')
|
||||
// 新增/编辑文件夹弹窗
|
||||
const showModal = ref<boolean>(false)
|
||||
// 新增/编辑文件夹名称
|
||||
const name = ref<string>('')
|
||||
// 右键菜单相关
|
||||
const menuVisible = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
// 菜单选项
|
||||
const menuOptions = ref<{label:string,key:string}[]>([])
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
const KNOWLEDGE_DOCS = [
|
||||
{ name: "PID-R201-03 反应釜工艺流程图(Rev C)", cat: "工艺技术资料", device: "一车间", ver: "Rev C", date: "2026-07-29" },
|
||||
{ name: "聚合岗位操作规程(2026 版)", cat: "工艺技术资料", device: "一车间", ver: "V2026", date: "2026-07-26" },
|
||||
{ name: "M-201A 搅拌电机设备台账", cat: "设备资料", device: "一车间", ver: "V3.2", date: "2026-07-28" },
|
||||
{ name: "环己烷 MSDS(供应商 B)", cat: "化学品资料", device: "原料罐区", ver: "V1.0", date: "2026-07-18" },
|
||||
{ name: "AQ/T 3034 化工企业工艺安全管理实施导则", cat: "法规标准库", device: "全厂", ver: "2013 版", date: "2025-12-01" },
|
||||
{ name: "PSSR 检查表模板(设备类)", cat: "检查表模板库", device: "全厂", ver: "V2.1", date: "2026-05-10" },
|
||||
{ name: "2025 年度典型变更案例汇编", cat: "历史变更案例库", device: "全厂", ver: "2025 版", date: "2026-01-15" },
|
||||
{ name: "HAZOP 分析报告(R-101 温度变更)", cat: "历史变更案例库", device: "一车间", ver: "V1.0", date: "2026-07-26" },
|
||||
];
|
||||
const libHits = computed(() => (keyword.value ? KNOWLEDGE_DOCS.filter((d:any) => d.name.includes(keyword.value) || d.cat.includes(keyword.value)) : []));
|
||||
/**
|
||||
* 根据 key 找到节点,返回从自己到最顶层祖先的 label 数组(含自己)
|
||||
* @param {Array} tree 树形数据
|
||||
* @param {string} key 目标节点的 key
|
||||
* @returns {string[] | null} 例如 ["工艺", "测试", "彩色"](从顶层到自身)
|
||||
*/
|
||||
const findPathByKey = (tree:any, key:string) => {
|
||||
function dfs(nodes:any[], path:any[]) {
|
||||
for (const node of nodes) {
|
||||
const currentPath = [...path, node.name];
|
||||
|
||||
if (node.key === key) {
|
||||
return currentPath; // 找到,直接返回整条路径
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
const res:any = dfs(node.children, currentPath);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return dfs(tree, []);
|
||||
}
|
||||
/**
|
||||
* 根据 key 删除节点(原地修改原数组)
|
||||
* @param {Array} tree 树形数组
|
||||
* @param {string} key 要删除的节点 key
|
||||
* @returns {boolean} 是否删除成功
|
||||
*/
|
||||
const removeNodeByKey = (tree:any, key:string,parent:any = null) => {
|
||||
for (let i = 0; i < tree.length; i++) {
|
||||
const node = tree[i];
|
||||
|
||||
// 命中目标:从当前数组中删除
|
||||
if (node.key === key) {
|
||||
tree.splice(i, 1);
|
||||
return parent;
|
||||
}
|
||||
|
||||
// 有子节点:递归删除
|
||||
if (node.children?.length) {
|
||||
const result:any = removeNodeByKey(node.children, key, node);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 根据 key 找到其父级节点
|
||||
* @param {Array} tree 树形数组
|
||||
* @param {string} key 目标节点 key
|
||||
* @returns {Object | null} 父级节点,顶层节点返回 null,未找到返回 null
|
||||
*/
|
||||
const findParentByKey = (tree:any, key:string) => {
|
||||
for (const node of tree) {
|
||||
if (node.children?.length) {
|
||||
// 子层里有目标 key,那当前 node 就是父级
|
||||
if (node.children.some((child:any) => child.key === key)) {
|
||||
return node;
|
||||
}
|
||||
// 继续向下找
|
||||
const parent:any = findParentByKey(node.children, key);
|
||||
if (parent) return parent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 展开节点
|
||||
const handleExpandKeys = (keys: string[],option:any,meta:any) => {
|
||||
expandedKeys.value = keys
|
||||
if (!meta.node)
|
||||
return
|
||||
if(meta.action==='expand'){
|
||||
meta.node.prefix = () =>h(Icon, {
|
||||
icon: 'material-symbols:folder-open-outline',
|
||||
class: 'size-14px',
|
||||
style: {
|
||||
color: themeStore.themeColor,
|
||||
}
|
||||
})
|
||||
}
|
||||
if(meta.action==='collapse'){
|
||||
meta.node.prefix = () =>h(Icon, {
|
||||
icon: 'material-symbols:folder-outline',
|
||||
class: 'size-14px',
|
||||
})
|
||||
}
|
||||
}
|
||||
// 选中节点
|
||||
const handleSelectKeys = (keys: string[],option:any) => {
|
||||
// org层不能被选中
|
||||
if(option[0] && option[0].type === 'org'){
|
||||
return
|
||||
}
|
||||
// 当前选中的key
|
||||
selectedKeys.value = keys;
|
||||
// 当前选中的节点
|
||||
currentNode.value = option[0];
|
||||
currentPath.value = findPathByKey(docList.value, currentNode.value?.key) || [];
|
||||
getFileList(currentNode.value?.id);
|
||||
}
|
||||
// 处理右键点击树节点
|
||||
const treeProps = ({ option }: { option: any }) => {
|
||||
return {
|
||||
onContextmenu(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
if(option.type === 'org'){
|
||||
currentOrgId.value = option.org_id;
|
||||
menuOptions.value = [
|
||||
{ label: '添加文件夹', key: 'add' },
|
||||
]
|
||||
}else{
|
||||
menuOptions.value = [
|
||||
{ label: '编辑文件夹', key: 'edit' },
|
||||
{ label: '添加文件夹', key: 'add' },
|
||||
{ label: '删除文件夹', key: 'delete' },
|
||||
{ label: '上传文件', key: 'upload' },
|
||||
]
|
||||
}
|
||||
currentNodeRightClick.value = option
|
||||
menuX.value = e.clientX
|
||||
menuY.value = e.clientY
|
||||
menuVisible.value = true
|
||||
},
|
||||
}
|
||||
}
|
||||
// 关闭菜单
|
||||
const closeMenu = () => {
|
||||
menuVisible.value = false
|
||||
}
|
||||
// 点击菜单项
|
||||
const handleMenuSelect = (key: string) => {
|
||||
modalType.value = key
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
name.value = currentNodeRightClick.value.name;
|
||||
showModal.value = true;
|
||||
break
|
||||
case 'add':
|
||||
name.value = '';
|
||||
showModal.value = true;
|
||||
break
|
||||
case 'upload':
|
||||
uploadDocRef.value?.click();
|
||||
break
|
||||
case 'delete':
|
||||
const d = dialog.info({
|
||||
title: '删除',
|
||||
content: `确定删除文件夹「${currentNodeRightClick.value.name}」吗?`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
d.loading = true
|
||||
return new Promise(async (resolve) => {
|
||||
const {error} = await deleteFolderApi(currentNodeRightClick.value.id);
|
||||
if(!error){
|
||||
// 删除成功后,更新当前选中的节点
|
||||
const parent = removeNodeByKey(docList.value, currentNodeRightClick.value.key);
|
||||
if(parent){
|
||||
// 移除展开状态
|
||||
if(parent.children?.length===0){
|
||||
expandedKeys.value = expandedKeys.value.filter((k:number | string) => k !== parent.key);
|
||||
handleExpandKeys(expandedKeys.value, null, {action:'collapse',node:parent});
|
||||
}
|
||||
}
|
||||
// 移除选中状态
|
||||
selectedKeys.value = selectedKeys.value.filter((k:number | string) => k !== currentNodeRightClick.value.key);
|
||||
// 判断当前点击选中的和右键操作的节点是否相同,相同则清空
|
||||
if(currentNode.value?.key === currentNodeRightClick.value.key){
|
||||
currentNode.value = null;
|
||||
}
|
||||
currentNodeRightClick.value = null;
|
||||
// 判断当前路径是否为空,为空则清空文件列表(删除的节点是当前选中的)
|
||||
if(currentPath.value.length===0){
|
||||
fileList.value = [];
|
||||
}
|
||||
window.$message?.success('删除文件夹成功')
|
||||
}
|
||||
d.loading = false
|
||||
resolve(true)
|
||||
})
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
d.loading = false
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
// 新增/编辑文件夹确认
|
||||
const modalSave = async () => {
|
||||
if(!name.value){
|
||||
window.$message?.warning('请输入文件夹名称')
|
||||
}
|
||||
saveLoading.value = true;
|
||||
// 新增文件夹
|
||||
if(modalType.value === 'add'){
|
||||
const {error} = await addFolderApi({
|
||||
name: name.value,
|
||||
parent_id: currentNodeRightClick.value.id,
|
||||
org_id: currentOrgId.value
|
||||
});
|
||||
if(!error){
|
||||
window.$message?.success('新增文件夹成功')
|
||||
showModal.value = false;
|
||||
getDocList();
|
||||
}
|
||||
}
|
||||
// 编辑文件夹
|
||||
if(modalType.value === 'edit'){
|
||||
const {error} = await editFolderApi(currentNodeRightClick.value.id,{
|
||||
name: name.value,
|
||||
});
|
||||
if(!error){
|
||||
window.$message?.success('编辑文件夹成功')
|
||||
showModal.value = false;
|
||||
getDocList();
|
||||
}
|
||||
}
|
||||
saveLoading.value = false;
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const searchHandle = () => {
|
||||
if(!keyword.value) {
|
||||
// 搜索框为空时,显示树状列表
|
||||
showTree.value = true;
|
||||
}else{
|
||||
// 搜索框不为空时,显示搜索结果
|
||||
showTree.value = false;
|
||||
getFileList(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将组织-文件夹-文档数据转换为树组件需要的结构
|
||||
* 每层添加 key / label / children
|
||||
* @param {Array} data - 原始数组
|
||||
* @returns {Array} 转换后的树形数组
|
||||
*/
|
||||
function buildTreeData(data = []) {
|
||||
// 递归处理 folder 层(folders / children / docs)
|
||||
function transformFolder(folder: any) {
|
||||
const children = [
|
||||
...(folder.children || []).map(transformFolder),
|
||||
];
|
||||
|
||||
return {
|
||||
...folder,
|
||||
key: folder.id+'_folder',
|
||||
name: folder.name,
|
||||
label: () => h('span',{
|
||||
style: {
|
||||
color: selectedKeys.value.includes(folder.id+'_folder')?themeStore.themeColor:'',
|
||||
}
|
||||
},folder.name),
|
||||
type: 'folder',
|
||||
children,
|
||||
prefix: () =>h(Icon, {
|
||||
icon: expandedKeys.value.includes(folder.id+'_folder')?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
|
||||
class: 'size-14px',
|
||||
style: {
|
||||
color: (expandedKeys.value.includes(folder.id+'_folder') || selectedKeys.value.includes(folder.id+'_folder'))?themeStore.themeColor:'',
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// 处理顶层 org
|
||||
return data.map((org: any) => ({
|
||||
...org,
|
||||
key: org.org_id+'_org',
|
||||
name: org.org_name,
|
||||
label: org.org_name,
|
||||
type: 'org',
|
||||
children: (org.folders || []).map(transformFolder),
|
||||
prefix: () =>h(Icon, {
|
||||
icon: expandedKeys.value.includes(org.org_id+'_org')?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
|
||||
class: 'size-14px',
|
||||
style: {
|
||||
color: expandedKeys.value.includes(org.org_id+'_org')?themeStore.themeColor:'',
|
||||
}
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取资料库树状列表
|
||||
const getDocList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await docTreeListApi();
|
||||
if(!error){
|
||||
docList.value = buildTreeData(data);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 根据文件夹ID获取资料列表
|
||||
const getFileList = async (folder_id:number) => {
|
||||
fileLoading.value = true;
|
||||
const {data,error} = await docListApi({folder_id,name:keyword.value});
|
||||
if(!error){
|
||||
if(folder_id){
|
||||
// 点击左侧文件夹查询
|
||||
fileList.value = data;
|
||||
}else{
|
||||
data.forEach((item:any) => {
|
||||
item.path = findPathByKey(docList.value, item.folder_id+'_folder') || [];
|
||||
})
|
||||
// 搜索查询
|
||||
queryList.value = data;
|
||||
}
|
||||
}
|
||||
fileLoading.value = false;
|
||||
}
|
||||
|
||||
// 在线预览资料
|
||||
const preview = (d:any) => {
|
||||
window.$message?.info(`演示:在线预览 ${d.name}`)
|
||||
previewFile.value = d;
|
||||
previewModal.value = true;
|
||||
}
|
||||
const download = (d:any) => {
|
||||
window.$message?.success(`演示:下载 ${d.name}`)
|
||||
// 下载资料
|
||||
const download = (file:any) => {
|
||||
downloadFileFun(file.file_url, file.file_name);
|
||||
}
|
||||
const call = (d:any) => {
|
||||
window.$message?.success(`演示:已将「${d.name}」调用为当前变更的 AI 知识来源(调用记录留痕)`)
|
||||
// 上传资料
|
||||
const uploadFiles = async (e:any) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('folder_id', currentNodeRightClick.value.id);
|
||||
loading.value = true;
|
||||
const {error} = await uploadDocApi(formData);
|
||||
if(!error){
|
||||
window.$message?.success('上传成功');
|
||||
if(currentNodeRightClick.value?.key === currentNode.value?.key){
|
||||
getFileList(currentNode.value.id);
|
||||
}else{
|
||||
getDocList();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 更新资料
|
||||
const updateDoc = async (e:any) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
loading.value = true;
|
||||
const {error} = await updateDocApi(currentDocId.value,formData);
|
||||
if(!error){
|
||||
window.$message?.success('更新成功');
|
||||
if(showTree.value){
|
||||
getFileList(currentNode.value.id);
|
||||
}else{
|
||||
getFileList(0);
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
// 更新资料
|
||||
const updateFile = (d:any) => {
|
||||
currentDocId.value = d.id;
|
||||
updateDocRef.value?.click()
|
||||
}
|
||||
// 查看历史版本
|
||||
const viewHistory = async (d:any) => {
|
||||
currentDocId.value = d.id;
|
||||
historyDrawer.value = true;
|
||||
historyLoading.value = true;
|
||||
const {data,error} = await docHistoryListApi(d.id);
|
||||
if(!error){
|
||||
historyList.value = data;
|
||||
}
|
||||
historyLoading.value = false;
|
||||
}
|
||||
interface LibDoc { name: string; ver: string; date: string }
|
||||
interface LibFolder { label: string; docs: LibDoc[] }
|
||||
interface LibGroup { cat: string; folders: LibFolder[] }
|
||||
const LIB_ORGS: { org: string; groups: LibGroup[] }[] = [
|
||||
{ org: "一车间", groups: [
|
||||
{ cat: "工艺", folders: [
|
||||
{ label: "操作规程", docs: [
|
||||
{ name: "聚合岗位操作规程(2026 版)", ver: "V2026", date: "2026-07-26" },
|
||||
{ name: "干燥岗位操作规程(2026 版)", ver: "V2026", date: "2026-06-30" } ]},
|
||||
{ label: "PID 图纸", docs: [
|
||||
{ name: "PID-R201-03 反应釜工艺流程图(Rev C)", ver: "Rev C", date: "2026-07-29" },
|
||||
{ name: "PID-V102-01 罐区工艺流程图(Rev B)", ver: "Rev B", date: "2026-05-12" } ]},
|
||||
{ label: "工艺卡片", docs: [
|
||||
{ name: "R-201 反应釜工艺卡片(夏季工况)", ver: "V2.0", date: "2026-07-01" } ]},
|
||||
]},
|
||||
{ cat: "设备", folders: [
|
||||
{ label: "设备台账", docs: [
|
||||
{ name: "M-201A 搅拌电机设备台账", ver: "V3.2", date: "2026-07-28" },
|
||||
{ name: "P-204 输送泵设备台账", ver: "V2.8", date: "2026-06-15" } ]},
|
||||
{ label: "安全阀台账", docs: [
|
||||
{ name: "一车间安全阀台账(PSV-101 ~ PSV-118)", ver: "V5.1", date: "2026-07-10" } ]},
|
||||
]},
|
||||
{ cat: "仪表", folders: [
|
||||
{ label: "报警台账", docs: [
|
||||
{ name: "一车间 DCS 报警台账(2026H1)", ver: "V4.0", date: "2026-07-20" } ]},
|
||||
{ label: "联锁台账", docs: [
|
||||
{ name: "一车间联锁逻辑台账(SIS-01 ~ SIS-09)", ver: "V3.6", date: "2026-07-22" } ]},
|
||||
]},
|
||||
{ cat: "安全", folders: [
|
||||
{ label: "总图", docs: [{ name: "一车间装置总平面布置图", ver: "Rev D", date: "2026-04-18" } ]},
|
||||
{ label: "现场气体报警仪布置图", docs: [{ name: "一车间可燃 / 有毒气体报警仪布置图", ver: "Rev B", date: "2026-05-28" } ]},
|
||||
{ label: "防爆分区图", docs: [{ name: "一车间爆炸危险区域划分图", ver: "Rev C", date: "2026-03-15" } ]},
|
||||
]},
|
||||
]},
|
||||
{ org: "二车间", groups: [
|
||||
{ cat: "工艺", folders: [
|
||||
{ label: "操作规程", docs: [{ name: "二车间精馏岗位操作规程(2026 版)", ver: "V2026", date: "2026-06-20" } ]},
|
||||
{ label: "PID 图纸", docs: [{ name: "PID-T301-02 精馏塔工艺流程图(Rev A)", ver: "Rev A", date: "2026-03-08" } ]},
|
||||
]},
|
||||
{ cat: "设备", folders: [
|
||||
{ label: "设备台账", docs: [{ name: "P-103 冷冻盐水泵设备台账", ver: "V2.2", date: "2026-05-30" } ]},
|
||||
{ label: "安全阀台账", docs: [{ name: "二车间安全阀台账(PSV-201 ~ PSV-212)", ver: "V4.3", date: "2026-06-11" } ]},
|
||||
]},
|
||||
{ cat: "仪表", folders: [
|
||||
{ label: "报警台账", docs: [{ name: "二车间 DCS 报警台账(2026H1)", ver: "V3.5", date: "2026-07-02" } ]},
|
||||
{ label: "联锁台账", docs: [{ name: "二车间联锁逻辑台账(SIS-11 ~ SIS-16)", ver: "V2.9", date: "2026-06-25" } ]},
|
||||
]},
|
||||
{ cat: "安全", folders: [
|
||||
{ label: "总图", docs: [{ name: "二车间装置总平面布置图", ver: "Rev B", date: "2026-02-14" } ]},
|
||||
{ label: "现场气体报警仪布置图", docs: [{ name: "二车间可燃 / 有毒气体报警仪布置图", ver: "Rev A", date: "2026-04-02" } ]},
|
||||
{ label: "防爆分区图", docs: [{ name: "二车间爆炸危险区域划分图", ver: "Rev B", date: "2026-01-20" } ]},
|
||||
]},
|
||||
]},
|
||||
{ org: "全厂共用", groups: [
|
||||
{ cat: "法规", folders: [
|
||||
{ label: "法规标准库", docs: [{ name: "AQ/T 3034 化工企业工艺安全管理实施导则", ver: "2013 版", date: "2025-12-01" } ]},
|
||||
]},
|
||||
{ cat: "化学品", folders: [
|
||||
{ label: "MSDS", docs: [{ name: "环己烷 MSDS(供应商 B)", ver: "V1.0", date: "2026-07-18" } ]},
|
||||
]},
|
||||
{ cat: "模板", folders: [
|
||||
{ label: "检查表模板", docs: [{ name: "PSSR 检查表模板(设备类)", ver: "V2.1", date: "2026-05-10" } ]},
|
||||
]},
|
||||
{ cat: "案例", folders: [
|
||||
{ label: "历史变更案例", docs: [
|
||||
{ name: "2025 年度典型变更案例汇编", ver: "2025 版", date: "2026-01-15" },
|
||||
{ name: "HAZOP 分析报告(R-101 温度变更)", ver: "V1.0", date: "2026-07-26" } ]},
|
||||
]},
|
||||
]},
|
||||
];
|
||||
const openOrgs = ref<Record<string, boolean>>({ 一车间: true });
|
||||
const libSel = ref({ org: "一车间", cat: "工艺", label: "操作规程" });
|
||||
const libCur = computed(() => LIB_ORGS.find((o) => o.org === libSel.value.org)?.groups.find((g) => g.cat === libSel.value.cat)?.folders.find((f) => f.label === libSel.value.label));
|
||||
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getDocList();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我的草稿箱 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<!-- 上传资料 -->
|
||||
<input ref="uploadDocRef" type="file" accept="image/*,.pdf,.docx,.xlsx" class="hidden" @change="uploadFiles" />
|
||||
<!-- 更新资料 -->
|
||||
<input ref="updateDocRef" type="file" accept="image/*,.pdf,.docx,.xlsx" class="hidden" @change="updateDoc" />
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
|
||||
<NGi span="24 s:24 m:16">
|
||||
<div class="h-100% flex items-center gap-2">
|
||||
<Icon icon="mage:book" class="size-20px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-base font-semibold">资料档案库(按组织 / 专业类别建立文件夹 · AI 判定的知识来源)</p>
|
||||
<p class="text-base font-semibold">资料档案库(按组织 / 专业类别建立文件夹)</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
|
||||
<div class="flex items-center gap-2">
|
||||
<NInput v-model:value="keyword" size="small" type="text" class="w-40 text-xs" placeholder="全文检索…">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
|
||||
</template>
|
||||
</NInput>
|
||||
<NButton size="small" type="primary" class="text-xs" @click="uploadFiles">
|
||||
<Icon icon="material-symbols:upload" class="size-16px" /> 上传资料
|
||||
</NButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<NInput v-model:value="keyword" size="small" type="text" class="w-40 text-xs" clearable placeholder="全文检索…" @clear="keyword = ''; searchHandle();">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="searchHandle" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
@@ -139,85 +499,193 @@ const libCur = computed(() => LIB_ORGS.find((o) => o.org === libSel.value.org)?.
|
||||
按「组织 → 专业类别 → 资料文件夹」归档具体资料(操作规程 / PID 图纸 / 工艺卡片 / 设备台账 / 安全阀台账 / 报警台账 / 联锁台账 / 总图 / 现场气体报警仪布置图 / 防爆分区图等);资料由指定维护人(资料管理员 / 各专业专责)上传维护,版本受控;「调用」可将资料引用为当前变更的 AI 知识来源,调用记录留痕。
|
||||
</div>
|
||||
<!-- 全文检索:跨文件夹平铺结果 -->
|
||||
<div v-if="keyword">
|
||||
<div class="mb-1.5 text-xs text-slate-400">全文检索「{{ keyword }}」:跨全部文件夹命中 {{ libHits.length }} 份</div>
|
||||
<div class="divide-y rounded-lg border bg-white">
|
||||
<div v-for="d in libHits" :key="d.name" class="flex flex-wrap items-center gap-3 px-2 py-2.5">
|
||||
<FileText class="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">{{ d.name }}</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||
<span>{{ d.cat }} · {{ d.device }}</span>
|
||||
<span>更新 {{ d.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-3 text-xs">
|
||||
<button class="text-[#1D4E9C] hover:underline" @click="preview(d)">查看</button>
|
||||
<button class="text-[#1D4E9C] hover:underline" @click="download(d)">下载</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="libHits.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配「{{ keyword }}」的资料</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文件夹树 + 当前文件夹资料 -->
|
||||
<div v-else class="flex gap-3">
|
||||
<div class="shrink-0 space-y-2 w-56 scroll-left">
|
||||
<div v-for="o in LIB_ORGS" :key="o.org" class="overflow-hidden rounded-lg border bg-white">
|
||||
<button class="flex w-full items-center gap-1.5 px-2.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 bg-transparent"
|
||||
@click="openOrgs[o.org] = !openOrgs[o.org]">
|
||||
<Icon icon="formkit:right" class="size-14px transition-transform" :class="openOrgs[o.org] ? 'rotate-90' : ''" />
|
||||
<Icon v-if="openOrgs[o.org]" icon="quill:folder-open" class="size-14px" :style="{ color: themeStore.themeColor }" />
|
||||
<Icon v-else icon="material-symbols:folder-outline" class="size-14px text-slate-400" />
|
||||
{{ o.org }}
|
||||
</button>
|
||||
<template v-if="openOrgs[o.org]">
|
||||
<div v-for="g in o.groups" :key="g.cat" class="border-t px-2 py-1.5">
|
||||
<div class="px-1 pb-0.5 text-[10px] font-medium tracking-wide text-slate-400">{{ g.cat }}专业</div>
|
||||
<button v-for="f in g.folders" :key="f.label"
|
||||
@click="libSel = { org: o.org, cat: g.cat, label: f.label }"
|
||||
class="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-xs transition"
|
||||
:class="libSel.org === o.org && libSel.cat === g.cat && libSel.label === f.label ? 'bg-[var(--theme-color)] font-medium text-white' : 'text-slate-600 hover:bg-slate-100 bg-transparent'"
|
||||
:style="{ '--theme-color': themeStore.themeColor }"
|
||||
>
|
||||
<Icon icon="material-symbols:folder-outline" class="size-14px text-slate-400" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ f.label }}</span>
|
||||
<span class="text-[10px]" :class="libSel.label === f.label && libSel.cat === g.cat && libSel.org === o.org ? 'text-white/70' : 'text-slate-300'">{{ f.docs.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1.5 flex flex-wrap items-center gap-1 text-xs text-slate-400">
|
||||
当前位置:{{ libSel.org }}
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
{{ libSel.cat }}专业
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
<span class="font-medium text-slate-600">{{ libSel.label }}</span>
|
||||
<span class="ml-auto">共 {{ libCur?.docs.length ?? 0 }} 份 · 版本受控</span>
|
||||
</div>
|
||||
<div class="scroll-right">
|
||||
<div class="divide-y rounded-lg border bg-white">
|
||||
<div v-for="d in libCur?.docs ?? []" :key="d.name" class="flex flex-wrap items-center gap-3 px-2 py-2.5">
|
||||
<Icon icon="akar-icons:file" class="size-16px" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">{{ d.name }}</div>
|
||||
<div v-if="!showTree">
|
||||
<NSpin :show="fileLoading" size="small">
|
||||
<div class="mb-1.5 text-xs text-slate-400">全文检索「{{ keyword }}」:跨全部文件夹命中 {{ queryList.length }} 份</div>
|
||||
<div class="top-scroll">
|
||||
<div class="divide-y rounded-md border bg-white h-100%">
|
||||
<div v-for="d in queryList" :key="d.name" class="flex flex-wrap items-center gap-3 px-2 py-2.5">
|
||||
<Icon icon="akar-icons:file" class="size-16px" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">{{ d.doc_name }}</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||
<NTag size="small" type="info" class="text-[10px]">版本 {{ d.ver }}</NTag>
|
||||
<span>更新 {{ d.date }}</span>
|
||||
<NTag size="small" type="info" class="text-[10px]">版本 {{ d.version }}</NTag>
|
||||
<span>{{ d.path.join(' · ') }}</span>
|
||||
<span>更新 {{ d?.updated_at?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-3 text-xs">
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="viewHistory(d)">查看历史</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="updateFile(d)">更新</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="preview(d)">预览</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="download(d)">下载</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-3 text-xs">
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="preview(d)">查看</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="download(d)">下载</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="call(d)">调用</NButton>
|
||||
</div>
|
||||
<div v-if="queryList.length === 0" class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="未匹配到相关资料" class="text-xs"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
<!-- 文件夹树 + 当前文件夹资料 -->
|
||||
<div v-else class="flex gap-3">
|
||||
<!-- 左侧 -->
|
||||
<div class="w-300px border rounded-md p-2">
|
||||
<div class="scroll-left">
|
||||
<NTree
|
||||
class="h-100% flex flex-col"
|
||||
block-line
|
||||
:data="docList"
|
||||
label-field="label"
|
||||
key-field="key"
|
||||
:expanded-keys="expandedKeys"
|
||||
:selected-keys="selectedKeys"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
selectable
|
||||
:cancelable="false"
|
||||
:on-update:expanded-keys="handleExpandKeys"
|
||||
:on-update:selected-keys="handleSelectKeys"
|
||||
:node-props="treeProps"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无数据" class="text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</NTree>
|
||||
<NDropdown
|
||||
:show="menuVisible"
|
||||
:options="menuOptions"
|
||||
size="small"
|
||||
:x="menuX"
|
||||
:y="menuY"
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
@clickoutside="closeMenu"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1.5 flex flex-wrap items-center gap-1 text-xs text-slate-400">
|
||||
当前位置:
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="(item,index) in currentPath" :key="index">
|
||||
<span v-if="index !== (currentPath.length - 1)">{{ item }} </span>
|
||||
<span v-else class="font-medium text-slate-600">{{ item }}</span>
|
||||
<Icon v-if="index !== (currentPath.length - 1)" icon="formkit:right" class="size-12px" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<span class="ml-auto">共 {{ fileList.length }} 份 · 版本受控</span>
|
||||
</div>
|
||||
<NSpin :show="fileLoading" size="small">
|
||||
<div class="scroll-right">
|
||||
<div class="divide-y rounded-md border bg-white h-100%">
|
||||
<div v-for="d in fileList" :key="d.name" class="flex flex-wrap items-center gap-3 px-2 py-2.5">
|
||||
<Icon icon="akar-icons:file" class="size-16px" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm">{{ d.doc_name }}</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-slate-500">
|
||||
<NTag size="small" type="info" class="text-[10px]">版本 {{ d.version }}</NTag>
|
||||
<span>更新 {{ d?.updated_at?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-3 text-xs">
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="viewHistory(d)">查看历史</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="updateFile(d)">更新</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="preview(d)">预览</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="download(d)">下载</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="fileList.length===0" class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无资料数据" class="text-xs"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
:title="modalType==='add'?'新增文件夹':'编辑文件夹'"
|
||||
:auto-focus="true"
|
||||
: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="showModal = false">取消</NButton>
|
||||
<NButton size="small" type="primary" :loading="saveLoading" @click="modalSave">确定</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
<!-- 历史版本查看 -->
|
||||
<NDrawer v-model:show="historyDrawer" :width="600" placement="right" :auto-focus="false">
|
||||
<NDrawerContent title="历史版本" closable>
|
||||
<NSpin :show="historyLoading" size="small">
|
||||
<NTable size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th class="!text-center w-80px">版本</th>
|
||||
<th class="!text-center w-120px">更新时间</th>
|
||||
<th class="!text-center w-80px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="historyList.length===0">
|
||||
<td colspan="4" class="text-center !text-slate-400 !py-20px">暂无历史版本数据</td>
|
||||
</tr>
|
||||
<tr v-for="item in historyList" :key="item.id">
|
||||
<td>{{ item.doc_name }}</td>
|
||||
<td class="text-center">{{ item.version }}</td>
|
||||
<td class="text-center">{{ item.updated_at?.slice(0, 10) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="preview(item)">预览</NButton>
|
||||
<NButton type="primary" text class="hover:underline text-xs" @click="download(item)">下载</NButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
</NSpin>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
<!-- 附件在线预览弹窗 -->
|
||||
<NModal
|
||||
v-model:show="previewModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '80%', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<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?.doc_name }}
|
||||
</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>上传人:{{ previewFile?.user_name }}</span>
|
||||
<span>上传时间:{{ previewFile?.created_at?.slice(0, 16) }}</span>
|
||||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||||
</div>
|
||||
<previewComponent v-if="previewFile" :previewFile="previewFile" />
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
@@ -226,11 +694,15 @@ const libCur = computed(() => LIB_ORGS.find((o) => o.org === libSel.value.org)?.
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.scroll-left {
|
||||
height: calc(100vh - 260px);
|
||||
height: calc(100vh - 276px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.scroll-right {
|
||||
height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.top-scroll {
|
||||
height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
+480
-284
@@ -1,323 +1,511 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { ref, onMounted, h } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from "@/utils/common";
|
||||
import { formatTimestamp, getTimeRangeStamp, downloadFileFun } from "@/utils/common";
|
||||
import previewComponent from '@/components/previewFile/index.vue'
|
||||
import { systemConfigApi } from '@/service/api/system';
|
||||
import { orgListApi } from '@/service/api/user';
|
||||
import { docArchiveTreeListApi, docArchiveDetailsApi } from "@/service/api/doc";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
|
||||
const aq = ref<string>('');
|
||||
const uploadFiles = () => {
|
||||
window.$message?.success('演示:上传归档补充材料(限变更主管部门专责;材料单独存入「7-补充材料」,不改动原归档包,哈希留痕)')
|
||||
}
|
||||
interface ArchivePkg {
|
||||
id: string; name: string; closedAt: string; size: string; files: number;
|
||||
hash: string; revoked?: boolean; supplement?: boolean;
|
||||
}
|
||||
interface ArchiveUnit { unit: string; years: { year: string; pkgs: ArchivePkg[] }[] }
|
||||
|
||||
const ARCHIVE_TREE: ArchiveUnit[] = [
|
||||
const loading = ref<boolean>(false);
|
||||
const rightLoading = ref<boolean>(false);
|
||||
// 附件在线预览弹窗
|
||||
const previewModal = ref<boolean>(false);
|
||||
// 附件在线预览文件
|
||||
const previewFile = ref<any>(null);
|
||||
// 右侧展开的文件夹
|
||||
const openFolders = ref<Record<string, boolean>>({});
|
||||
// 组织树
|
||||
const orgTree = ref<any[]>([]);
|
||||
// 展开节点
|
||||
const expandedKeys = ref<string[]>([])
|
||||
// 选中节点
|
||||
const selectedKeys = ref<string[]>([])
|
||||
// 当前选中的节点
|
||||
const currentNode = ref<any>(null);
|
||||
// 变更类型
|
||||
const typeList = ref<any[]>([{label:"全部类型",value:""}]);
|
||||
// 树组件数据
|
||||
const treeList = ref<any[]>([]);
|
||||
// 右侧归档包详情
|
||||
const rightDetails = ref<any>(null);
|
||||
// 当前位置
|
||||
const currentPath = ref<string[]>([]);
|
||||
// 查询参数
|
||||
const formInfo = ref<{org_id:number,type:string,time:string,from:string | number | null,to:string | number | null,change_no:string}>({
|
||||
org_id: 1,
|
||||
type: "",
|
||||
time: "",
|
||||
from: null,
|
||||
to: null,
|
||||
change_no: "",
|
||||
});
|
||||
const timeOptions = [
|
||||
{
|
||||
unit: "01-常减压装置",
|
||||
years: [
|
||||
{ year: "2024", pkgs: [
|
||||
{ id: "MOC-2024-R01-0003", name: "减一线换热器 E-108 芯子更换", closedAt: "2024-04-18", size: "12.6MB", files: 19, hash: "3a91d7c4f0e2…5b17" },
|
||||
{ id: "MOC-2024-R01-0007", name: "常压塔顶回流泵 P-105B 叶轮更换", closedAt: "2024-09-02", size: "9.8MB", files: 16, hash: "8e02b1a6d3c9…42fa" },
|
||||
] },
|
||||
{ year: "2025", pkgs: [
|
||||
{ id: "MOC-2025-R01-0011", name: "初馏塔进料温度联锁值调整(已撤销)", closedAt: "2025-03-11", size: "7.4MB", files: 14, hash: "c5d8f2a1b7e4…9c03", revoked: true },
|
||||
{ id: "MOC-2025-R01-0019", name: "电脱盐系统注水泵型号替换", closedAt: "2025-07-25", size: "15.2MB", files: 22, hash: "1f4a9c7e2b6d…d831", supplement: true },
|
||||
{ id: "MOC-2025-R01-0023", name: "常压炉燃料气切断阀改造", closedAt: "2025-11-14", size: "21.0MB", files: 27, hash: "6b3e0d8a5f1c…77e2" },
|
||||
] },
|
||||
{ year: "2026", pkgs: [
|
||||
{ id: "MOC-2026-R01-0028", name: "减顶真空泵 P-107A 电机更换", closedAt: "2026-06-30", size: "18.4MB", files: 24, hash: "b8e3a1c5d2f7…4c60" },
|
||||
{ id: "MOC-2026-R01-0031", name: "常顶油气冷却器管束更新", closedAt: "2026-07-12", size: "16.9MB", files: 21, hash: "d2a7c4f88b1e…5a96" },
|
||||
{ id: "MOC-2026-R01-0039", name: "进料泵 P-101A 更换为高效节能泵", closedAt: "2026-08-01", size: "18.4MB", files: 24, hash: "7c4f9be2a1d3…e804" },
|
||||
] },
|
||||
],
|
||||
label: "全部时间",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
unit: "02-催化裂化装置",
|
||||
years: [
|
||||
{ year: "2025", pkgs: [
|
||||
{ id: "MOC-2025-R02-0014", name: "再生器旋风分离器更换", closedAt: "2025-05-20", size: "26.3MB", files: 31, hash: "9c1e5b3d7a2f…b450" },
|
||||
{ id: "MOC-2025-R02-0021", name: "气压机出口压力联锁逻辑修改", closedAt: "2025-10-08", size: "11.7MB", files: 18, hash: "4d8f0a2c6e9b…31cd" },
|
||||
] },
|
||||
{ year: "2026", pkgs: [
|
||||
{ id: "MOC-2026-R02-0025", name: "分馏塔顶循回流管线扩径", closedAt: "2026-06-30", size: "14.5MB", files: 20, hash: "a7b2d9e4c1f8…6e05" },
|
||||
] },
|
||||
],
|
||||
label: "近一年",
|
||||
value: "year",
|
||||
},
|
||||
{
|
||||
unit: "03-聚合装置",
|
||||
years: [
|
||||
{ year: "2026", pkgs: [
|
||||
{ id: "MOC-2026-R03-0008", name: "聚合釜搅拌器密封形式变更", closedAt: "2026-05-19", size: "13.2MB", files: 17, hash: "e6f1a8c3b5d0…92d7" },
|
||||
] },
|
||||
],
|
||||
label: "近半年",
|
||||
value: "half_year",
|
||||
},
|
||||
{
|
||||
label: "近三个月",
|
||||
value: "quarter",
|
||||
},
|
||||
];
|
||||
const ARCHIVE_FOLDERS: { dir: string; files: { name: string; size: string }[] }[] = [
|
||||
{ dir: "1-申请与审批", files: [
|
||||
{ name: "变更申请表(终版).pdf", size: "486KB" },
|
||||
{ name: "审批记录(含会签意见与留痕).pdf", size: "312KB" },
|
||||
{ name: "等级判定表.pdf", size: "154KB" },
|
||||
{ name: "原始附件(方案/会议纪要/数据表).zip", size: "2.8MB" },
|
||||
] },
|
||||
{ dir: "2-风险分析", files: [
|
||||
{ name: "AI 风险预分析报告.pdf", size: "268KB" },
|
||||
{ name: "JSA 作业安全分析表.pdf", size: "196KB" },
|
||||
{ name: "HAZOP 分析记录.pdf", size: "742KB" },
|
||||
{ name: "安全检查表(SCL).pdf", size: "188KB" },
|
||||
] },
|
||||
{ dir: "3-培训与考核", files: [
|
||||
{ name: "变更培训材料(终版).pdf", size: "1.1MB" },
|
||||
{ name: "培训签到表(照片).jpg", size: "2.4MB" },
|
||||
{ name: "考核成绩单.xlsx", size: "32KB" },
|
||||
] },
|
||||
{ dir: "4-PSSR检查", files: [
|
||||
{ name: "PSSR 检查清单(终版).pdf", size: "356KB" },
|
||||
{ name: "现场确认照片.zip", size: "6.7MB" },
|
||||
{ name: "电子签名确认记录.pdf", size: "142KB" },
|
||||
] },
|
||||
{ dir: "5-验收记录", files: [
|
||||
{ name: "变更验收单.pdf", size: "204KB" },
|
||||
{ name: "整改记录与复验结论.pdf", size: "168KB" },
|
||||
] },
|
||||
{ dir: "6-受控文件副本", files: [
|
||||
{ name: "受控文件更新清单(新旧对照).pdf", size: "96KB" },
|
||||
{ name: "PID-101_Rev.4_20260805.pdf", size: "3.2MB" },
|
||||
{ name: "PID-101_Rev.3_20250101.pdf", size: "3.1MB" },
|
||||
] },
|
||||
];
|
||||
const SUPPLEMENT_FOLDER = { dir: "7-补充材料(归档后补充)", files: [
|
||||
{ name: "补充说明(泵轴振动复测数据).pdf", size: "214KB" },
|
||||
{ name: "补充审批记录.pdf", size: "88KB" },
|
||||
] };
|
||||
const ROLE_SCOPE: Record<any, { text: string; filter: (u: any, p: any) => boolean }> = {
|
||||
applicant: { text: "本人发起或参与的变更", filter: (u, p) => u.unit.startsWith("01") && ["MOC-2026-R01-0039", "MOC-2026-R01-0028", "MOC-2025-R01-0019"].includes(p.id) },
|
||||
dept: { text: "本车间(装置)的全部变更", filter: (u) => u.unit.startsWith("01") },
|
||||
process: { text: "本人参与审批或检查的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
|
||||
process2: { text: "本人参与审批或检查的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
|
||||
safety: { text: "全厂变更(安全监管需要)", filter: () => true },
|
||||
vp: { text: "全厂变更", filter: () => true },
|
||||
admin: { text: "全厂变更(系统管理:归档内容只读锁定,不可修改删除)", filter: () => true },
|
||||
quality: { text: "本人参与会签或检验确认的变更", filter: (u, p) => u.unit.startsWith("01") && p.id >= "MOC-2025" },
|
||||
viewer: { text: "全厂变更(信息查看角色:只读查阅)", filter: () => true },
|
||||
};
|
||||
const tF = ref("全部时间");
|
||||
const oF = ref("全部组织");
|
||||
const pF = ref("全部专业");
|
||||
const FILTER_GROUPS = computed(() => [
|
||||
{ label: "时间", opts: ["全部时间", "近一年", "近半年", "近三个月"], val: tF.value, set: (v: string) => { tF.value = v; } },
|
||||
{ label: "组织", opts: ["全部组织", "一车间", "二车间", "三车间"], val: oF.value, set: (v: string) => { oF.value = v; } },
|
||||
{ label: "专业", opts: ["全部专业", "工艺", "设备", "仪表", "管理"], val: pF.value, set: (v: string) => { pF.value = v; } },
|
||||
]);
|
||||
const scope = computed(() => ROLE_SCOPE['applicant'] ?? ROLE_SCOPE.vp);
|
||||
const searching = computed(() => aq.value.trim().length > 0);
|
||||
const filtering = computed(() => tF.value !== "全部时间" || oF.value !== "全部组织" || pF.value !== "全部专业");
|
||||
const UNIT_ORG: Record<string, string> = { "01-常减压装置": "一车间", "02-催化裂化装置": "二车间", "03-聚合装置": "三车间" };
|
||||
const profOf = (name: string) => /联锁|报警/.test(name) ? "仪表" : /规程|制度|管理/.test(name) ? "管理" : /泵|阀|电机|换热器|搅拌|密封|分离器|管束|冷却器|气压机|叶轮|芯子/.test(name) ? "设备" : "工艺";
|
||||
const REF = new Date("2026-08-03");
|
||||
const selId = ref("MOC-2026-R01-0039");
|
||||
const passF = (p: (typeof allPkgs.value)[number]) => {
|
||||
const daysOf = tF.value === "近一年" ? 365 : tF.value === "近半年" ? 183 : tF.value === "近三个月" ? 92 : 0;
|
||||
return (daysOf === 0 || (REF.getTime() - new Date(p.closedAt).getTime()) / 86400000 <= daysOf) &&
|
||||
(oF.value === "全部组织" || UNIT_ORG[p.unit] === oF.value) &&
|
||||
(pF.value === "全部专业" || profOf(p.name) === pF.value);
|
||||
};
|
||||
const tree = computed(() => ARCHIVE_TREE
|
||||
.map((u:any) => ({ unit: u.unit, years: u.years.map((y) => ({ year: y.year, pkgs: y.pkgs.filter((p:any) => scope.value.filter(u, p)) })).filter((y:any) => y.pkgs.length > 0) }))
|
||||
.filter((u:any) => u.years.length > 0));
|
||||
const allPkgs = computed(() => tree.value.flatMap((u:any) => u.years.flatMap((y:any) => y.pkgs.map((p:any) => ({ ...p, unit: u.unit, year: y.year })))));
|
||||
const results = computed(() => (searching.value || filtering.value)
|
||||
? allPkgs.value.filter((p:any) => passF(p) && (!searching.value || p.id.toLowerCase().includes(aq.value.trim().toLowerCase()) || p.name.includes(aq.value.trim())))
|
||||
: []);
|
||||
const sel = computed(() => allPkgs.value.find((p:any) => p.id === selId.value) ?? allPkgs.value[0]);
|
||||
const selFolders = computed(() => (sel.value?.supplement ? [...ARCHIVE_FOLDERS, SUPPLEMENT_FOLDER] : ARCHIVE_FOLDERS));
|
||||
const openUnits = ref<Record<string, boolean>>({ "01-常减压装置": true });
|
||||
const openFolders = ref<Record<string, boolean>>({ "1-申请与审批": true });
|
||||
const downloadZip = (sel:any) => {
|
||||
window.$message?.success(`演示:下载 ${sel.id} 归档包 ZIP(含归档索引.txt)`)
|
||||
|
||||
/**
|
||||
* 根据 key 找到节点,返回从自己到最顶层祖先的 label 数组(含自己)
|
||||
* @param {Array} tree 树形数据
|
||||
* @param {string} key 目标节点的 key
|
||||
* @returns {string[] | null} 例如 ["工艺", "测试", "彩色"](从顶层到自身)
|
||||
*/
|
||||
const findPathByKey = (tree:any, key:string) => {
|
||||
function dfs(nodes:any[], path:any[]) {
|
||||
for (const node of nodes) {
|
||||
const currentPath = [...path, node.name];
|
||||
|
||||
if (node.key === key) {
|
||||
return currentPath; // 找到,直接返回整条路径
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
const res:any = dfs(node.children, currentPath);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return dfs(tree, []);
|
||||
}
|
||||
const exportEvidenceChain = () => {
|
||||
window.$message?.success('演示:导出证据链(哈希清单 + 操作日志)')
|
||||
|
||||
/**
|
||||
* 统一提取并统计文件信息
|
||||
* @param {Array} data 原始数据(含 step_no / files,file_size 单位为 KB)
|
||||
* @returns {{
|
||||
* allFiles: Array,
|
||||
* total: number,
|
||||
* totalSize: number,
|
||||
* formattedSize: string,
|
||||
* groups: Array,
|
||||
* countByStep: Array
|
||||
* }}
|
||||
*/
|
||||
function extractFiles(data:any[]) {
|
||||
const empty = {
|
||||
allFiles: [],
|
||||
total: 0,
|
||||
totalSize: 0,
|
||||
formattedSize: '0.00 KB',
|
||||
groups: [],
|
||||
countByStep: []
|
||||
};
|
||||
if (!Array.isArray(data)) return empty;
|
||||
// 扁平化所有文件
|
||||
const allFiles = data.flatMap(item => item.files || []);
|
||||
|
||||
// 文件总数
|
||||
const total = allFiles.length;
|
||||
|
||||
// 总大小(KB)
|
||||
const totalSize = allFiles.reduce(
|
||||
(sum, file) => sum + (Number(file.file_size) || 0),
|
||||
0
|
||||
);
|
||||
// 格式化总大小
|
||||
const formattedSize = formatSize(totalSize);
|
||||
return {
|
||||
allFiles,
|
||||
total,
|
||||
totalSize,
|
||||
formattedSize,
|
||||
};
|
||||
}
|
||||
const applySupplement = () => {
|
||||
window.$message?.info('演示:发起归档补充申请(补充材料单独存入 7-补充材料,不改动原归档)')
|
||||
|
||||
/**
|
||||
* 格式化文件大小(输入单位:KB)
|
||||
* < 1024 KB → KB
|
||||
* ≥ 1024 KB 且 < 1024 MB → MB
|
||||
* ≥ 1024 MB → GB
|
||||
* @param {number} kb
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatSize(kb:number) {
|
||||
if (kb < 1024) {
|
||||
return kb.toFixed(2) + ' KB';
|
||||
}
|
||||
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1024) {
|
||||
return mb.toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
const gb = mb / 1024;
|
||||
return gb.toFixed(2) + ' GB';
|
||||
}
|
||||
const viewIndex = () => {
|
||||
window.$message?.info('演示:查看归档索引(文件清单 + 逐项 SHA-256)')
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
const previewFile = (file:any) => {
|
||||
window.$message?.info(`演示:在线预览 ${file.name}`)
|
||||
|
||||
// 组织选择改变
|
||||
const orgChange = (val:any) => {
|
||||
formInfo.value.org_id = val;
|
||||
getList();
|
||||
}
|
||||
// 时间范围切换
|
||||
const timeChange = (val:string) => {
|
||||
formInfo.value.time = val;
|
||||
if(val===""){
|
||||
formInfo.value.from = null;
|
||||
formInfo.value.to = null;
|
||||
}else{
|
||||
formInfo.value.from = getTimeRangeStamp(val)?.from ?? null;
|
||||
formInfo.value.to = getTimeRangeStamp(val)?.to ?? null;
|
||||
}
|
||||
getList();
|
||||
}
|
||||
// 类型切换
|
||||
const typeChange = (val:string) => {
|
||||
formInfo.value.type = val;
|
||||
getList();
|
||||
}
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
formInfo.value.change_no = '';
|
||||
formInfo.value.org_id = 1;
|
||||
formInfo.value.type = '';
|
||||
formInfo.value.from = null;
|
||||
formInfo.value.to = null;
|
||||
getList();
|
||||
}
|
||||
|
||||
// 展开节点
|
||||
const handleExpandKeys = (keys: string[],option:any,meta:any) => {
|
||||
expandedKeys.value = keys
|
||||
if (!meta.node)
|
||||
return
|
||||
if(meta.action==='expand'){
|
||||
meta.node.prefix = () =>h(Icon, {
|
||||
icon: 'material-symbols:folder-open-outline',
|
||||
class: 'size-14px',
|
||||
style: {
|
||||
color: themeStore.themeColor,
|
||||
}
|
||||
})
|
||||
}
|
||||
if(meta.action==='collapse'){
|
||||
meta.node.prefix = () =>h(Icon, {
|
||||
icon: 'material-symbols:folder-outline',
|
||||
class: 'size-14px',
|
||||
})
|
||||
}
|
||||
}
|
||||
// 选中节点
|
||||
const handleSelectKeys = (keys: string[],option:any) => {
|
||||
// org层和year层不能被选中
|
||||
if(option[0] && (option[0].type === 'org' || option[0].type === 'year')){
|
||||
return
|
||||
}
|
||||
// 当前选中的key
|
||||
selectedKeys.value = keys;
|
||||
// 当前选中的节点
|
||||
currentNode.value = option[0];
|
||||
// 当前选中的节点路径
|
||||
currentPath.value = findPathByKey(treeList.value, currentNode.value?.key) || [];
|
||||
getDetails();
|
||||
}
|
||||
|
||||
// 下载
|
||||
const downloadFile = (file:any) => {
|
||||
window.$message?.success(`演示:下载 ${file.name}`)
|
||||
downloadFileFun(file.file_url, file.file_name);
|
||||
}
|
||||
|
||||
// 预览
|
||||
const preview = (file:any) => {
|
||||
previewFile.value = file;
|
||||
previewModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将组织-文件夹-文档数据转换为树组件需要的结构
|
||||
* 每层添加 key / label / children
|
||||
*/
|
||||
function buildTreeData(data:any[]) {
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.map(org => ({
|
||||
label: org.org_name,
|
||||
name: org.org_name,
|
||||
key: org.org_id+'_org',
|
||||
type: 'org',
|
||||
prefix: () =>h(Icon, {
|
||||
icon: expandedKeys.value.includes(org.org_id+'_org')?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
|
||||
class: 'size-14px',
|
||||
style: {
|
||||
color: expandedKeys.value.includes(org.org_id+'_org')?themeStore.themeColor:'',
|
||||
}
|
||||
}),
|
||||
children: (org.years || []).map((year:any) => ({
|
||||
label: year.year+'年'+'('+(year?.changes?.length || 0)+')',
|
||||
name: year.year,
|
||||
key: year.year+'_year'+org.org_id,
|
||||
type: 'year',
|
||||
prefix: () =>h(Icon, {
|
||||
icon: expandedKeys.value.includes(year.year+'_year'+org.org_id)?'material-symbols:folder-open-outline':'material-symbols:folder-outline',
|
||||
class: 'size-14px',
|
||||
style: {
|
||||
color: expandedKeys.value.includes(year.year+'_year'+org.org_id)?themeStore.themeColor:'',
|
||||
}
|
||||
}),
|
||||
children: (year.changes || []).map((change:any) => ({
|
||||
label: () => h('span',{
|
||||
style: {
|
||||
color: selectedKeys.value.includes(change.change_id+'_change')?themeStore.themeColor:'',
|
||||
}
|
||||
},change.change_no),
|
||||
key: change.change_id+'_change',
|
||||
name: change.change_no,
|
||||
id: change.change_id,
|
||||
type: 'change',
|
||||
prefix: () =>h(Icon, {
|
||||
icon: 'lucide:folder-archive',
|
||||
class: 'size-13px',
|
||||
style: {
|
||||
color: selectedKeys.value.includes(change.change_id+'_change')?themeStore.themeColor:'',
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
// 获取树状列表
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await docArchiveTreeListApi({
|
||||
from: formatTimestamp(formInfo.value.from),
|
||||
to: formatTimestamp(formInfo.value.to),
|
||||
org_id: formInfo.value.org_id,
|
||||
type: formInfo.value.type,
|
||||
change_no: formInfo.value.change_no,
|
||||
});
|
||||
if(!error){
|
||||
treeList.value = buildTreeData(data);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 右侧归档包详情
|
||||
const getDetails = async () => {
|
||||
rightLoading.value = true;
|
||||
const {data,error} = await docArchiveDetailsApi(currentNode.value?.id);
|
||||
if(!error){
|
||||
rightDetails.value = data;
|
||||
}
|
||||
rightLoading.value = false;
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = typeList.value.concat(changeTypeGroup.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取组织列表
|
||||
const getOrgList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await orgListApi();
|
||||
if(!error){
|
||||
orgTree.value = data;
|
||||
if(orgTree.value.length > 0){
|
||||
getList();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
onMounted(async () => {
|
||||
getConfig();
|
||||
getOrgList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我的草稿箱 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
|
||||
<NGi span="24 s:24 m:16">
|
||||
<div class="h-100% flex items-center gap-2">
|
||||
<Icon icon="mage:book" class="size-20px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-base font-semibold">变更归档(一次变更一个归档包 · 关闭即自动归档 · 只读锁定)</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton size="small" type="primary" class="text-xs" @click="uploadFiles">
|
||||
<Icon icon="material-symbols:upload" class="size-16px" /> 上传归档补充
|
||||
</NButton>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
<div
|
||||
class="mt-4 flex items-center gap-2 rounded-lg bg-[var(--bg-color)] px-3 py-2 text-xs text-[var(--theme-color)]"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
|
||||
}"
|
||||
>
|
||||
<Icon icon="uil:lock" class="size-16px" />
|
||||
当前角色调阅范围:{{ scope.text }} · 归档包关闭时自动生成,内容只读锁定并记录 SHA-256 哈希,任何修改留痕可追责
|
||||
</div>
|
||||
<NInput v-model:value="aq" type="text" class="w-40 text-xs mt-3" placeholder="按变更单号精确查找,或按名称关键词检索(如 MOC-2026-R01-0039)">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
|
||||
</template>
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="pb-[12px] border-b">
|
||||
<NGi span="24 s:24 m:24">
|
||||
<div class="h-100% flex items-center gap-2">
|
||||
<Icon icon="mage:book" class="size-20px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-base font-semibold">变更归档(一次变更一个归档包 · 关闭即自动归档 · 只读锁定)</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<NInput v-model:value="formInfo.change_no" type="text" class="w-full text-xs" clearable placeholder="按变更单号精确查找,或按名称关键词检索(如 MOC-2026-R01-0039)" @clear="formInfo.change_no = ''; getList()">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="getList" />
|
||||
</template>
|
||||
</NInput>
|
||||
<!-- 筛选:时间 / 组织 / 专业 -->
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 mt-3">
|
||||
<div v-for="g in FILTER_GROUPS" :key="g.label" class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[11px] text-slate-400">{{ g.label }}</span>
|
||||
<NButton size="tiny" round v-for="o in g.opts" :key="o" @click="g.set(o)" class="text-[11px]" :type="g.val === o ? 'primary' : 'default'">{{ o }}</NButton>
|
||||
<NButton class="text-sm" @click="reset">
|
||||
<Icon icon="system-uicons:reset" class="size-14px" />重置
|
||||
</NButton>
|
||||
</div>
|
||||
<!-- 筛选:时间 / 组织 / 专业 -->
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 mt-3">
|
||||
<NTreeSelect
|
||||
class="!w-200px"
|
||||
:value="formInfo.org_id"
|
||||
:options="orgTree"
|
||||
size="small"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
placeholder="请选择组织层级"
|
||||
@update:value="orgChange"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[11px] text-slate-400">时间</span>
|
||||
<NButton v-for="time in timeOptions" :key="time.value" size="tiny" round class="text-xs" :type="formInfo.time === time.value ? 'primary' : 'default'" @click="timeChange(time.value)">{{ time.label }}</NButton>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[11px] text-slate-400">类型</span>
|
||||
<NButton v-for="type in typeList" :key="type.value" size="tiny" round class="text-xs" :type="formInfo.type === type.value ? 'primary' : 'default'" @click="typeChange(type.value)">{{ type.label }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-3">
|
||||
<!-- 左侧 -->
|
||||
<div class="w-300px border rounded-md p-2">
|
||||
<div class="scroll-left">
|
||||
<NTree
|
||||
class="h-100% flex flex-col"
|
||||
block-line
|
||||
:data="treeList"
|
||||
label-field="label"
|
||||
key-field="key"
|
||||
:expanded-keys="expandedKeys"
|
||||
:selected-keys="selectedKeys"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
selectable
|
||||
:cancelable="false"
|
||||
:on-update:expanded-keys="handleExpandKeys"
|
||||
:on-update:selected-keys="handleSelectKeys"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无数据" class="text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</NTree>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 grid-cols-3 mt-3">
|
||||
<!-- 左:三层目录树 / 筛选检索结果 -->
|
||||
<div class="rounded-lg border p-2">
|
||||
<div class="px-2 pb-1 text-xs font-medium text-slate-500">{{ (searching || filtering) ? `筛选 / 检索结果(${results.length})` : '归档区目录(装置 / 年份 / 变更单号)' }}</div>
|
||||
<template v-if="searching || filtering">
|
||||
<div v-if="results.length === 0" class="px-2 py-4 text-center text-xs text-slate-400">未找到匹配的归档包</div>
|
||||
<button v-for="p in results" :key="p.id" @click="selId = p.id"
|
||||
class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs hover:bg-[#DFE9F6]/60"
|
||||
:class="sel?.id === p.id ? 'bg-[#DFE9F6] font-medium text-[#1D4E9C]' : ''">
|
||||
<FolderArchive class="h-3.5 w-3.5 shrink-0 text-[#6E93C6]" />
|
||||
<span class="truncate">{{ p.id }}</span>
|
||||
<Tag v-if="p.revoked" class="border-red-200 bg-red-100 px-1 py-0 text-[10px] text-red-600">已撤销</Tag>
|
||||
<Tag v-if="p.supplement" class="border-amber-200 bg-amber-100 px-1 py-0 text-[10px] text-amber-700">补充</Tag>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="u in tree" :key="u.unit">
|
||||
<button @click="openUnits[u.unit] = !openUnits[u.unit]"
|
||||
class="flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
|
||||
<Icon icon="formkit:right" class="size-14px transition-transform" :class="openUnits[u.unit] ? 'rotate-90' : ''" />
|
||||
<Icon v-if="openUnits[u.unit]" icon="quill:folder-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
|
||||
{{ u.unit }}
|
||||
</button>
|
||||
<template v-if="openUnits[u.unit]">
|
||||
<div v-for="y in u.years" :key="y.year">
|
||||
<div class="pl-7 pt-1 text-[11px] font-medium text-slate-400">{{ y.year }} 年({{ y.pkgs.length }})</div>
|
||||
<button v-for="p in y.pkgs" :key="p.id" @click="selId = p.id"
|
||||
class="flex w-full items-center gap-2 rounded-lg py-1.5 pl-8 pr-2 text-left text-xs hover:bg-[#DFE9F6]/60"
|
||||
:class="sel?.id === p.id ? 'bg-[var(--bg-color)] font-medium text-[var(--theme-color)]' : 'bg-transparent'"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--bg-color': getColorWithOpacity(themeStore.themeColor, 0.1)
|
||||
}"
|
||||
>
|
||||
<Icon icon="lucide:folder-archive" class="size-14px" :style="{ color: themeStore.themeColor }" />
|
||||
|
||||
<span class="truncate">{{ p.id }}</span>
|
||||
<NTag v-if="p.revoked" size="small" type="error" class="text-[10px]">已撤销</NTag>
|
||||
<NTag v-if="p.supplement" size="small" type="warning" class="text-[10px]">补充</NTag>
|
||||
<!-- 右:归档包详情 -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<NSpin :show="rightLoading" size="small">
|
||||
<template v-if="rightDetails">
|
||||
<div class="rounded-md border col-span-2">
|
||||
<div class="border-b p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold">{{ rightDetails.change_no }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600">{{ rightDetails.title }}</div>
|
||||
<div class="mt-2 flex flex-wrap justify-between gap-x-4 gap-y-1 text-[11px] text-slate-500">
|
||||
<span>归档路径:{{ currentPath.join('/') }}</span>
|
||||
<span>关闭时间:{{ rightDetails?.close_time?.slice(0,10) }}</span>
|
||||
<span>共 {{ extractFiles(rightDetails.folders)?.total }} 个文件 · {{ extractFiles(rightDetails.folders)?.formattedSize }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y scroll-right">
|
||||
<div v-for="f in rightDetails?.folders" :key="f.step_no">
|
||||
<button @click="openFolders[f.step_no] = !openFolders[f.step_no]"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
|
||||
<Icon icon="formkit:right" class="size-14px text-slate-400 transition-transform" :class="openFolders[f.step_no] ? 'rotate-90' : ''" />
|
||||
<Icon icon="quill:folder-open" v-if="openFolders[f.step_no]" class="size-16px" :style="{color:themeStore.themeColor}" />
|
||||
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
|
||||
<span v-if="f.step_no===1">1-申请与审批</span>
|
||||
<span v-else-if="f.step_no===2">2-风险分析</span>
|
||||
<span v-else-if="f.step_no===3">3-培训与考核</span>
|
||||
<span v-else-if="f.step_no===4">4-PSSR检查</span>
|
||||
<span v-else-if="f.step_no===5">5-验收记录</span>
|
||||
<span v-else>6-关闭资料</span>
|
||||
<span class="ml-auto text-[11px] font-normal text-slate-400">{{ f.files.length }} 个文件</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右:归档包详情 -->
|
||||
<div v-if="sel" class="rounded-lg border col-span-2">
|
||||
<div class="border-b p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold">{{ sel.id }}</span>
|
||||
<NTag v-if="sel.revoked" size="small" type="error">已撤销(保留归档备查)</NTag>
|
||||
<NTag v-else size="small" type="info">
|
||||
<template #icon>
|
||||
<Icon icon="uil:lock" class="size-14px" />
|
||||
<template v-if="openFolders[f.step_no]">
|
||||
<div v-for="file in f.files" :key="file.name" class="flex items-center gap-2 py-1.5 pl-10 pr-3 text-xs hover:bg-slate-50">
|
||||
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ file.file_name }}</span>
|
||||
<span class="text-[11px] text-slate-400">{{ formatSize(file.file_size) }}</span>
|
||||
<NButton text type="primary" title="预览" @click="preview(file)">
|
||||
<Icon icon="lucide:eye" class="size-14px" />
|
||||
</NButton>
|
||||
<NButton text type="primary" title="下载" @click="downloadFile(file)">
|
||||
<Icon icon="material-symbols:download" class="size-16px" />
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
只读锁定
|
||||
</NTag>
|
||||
<NTag v-if="sel.supplement" size="small" type="warning">含归档补充</NTag>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600">{{ sel.name }}</div>
|
||||
<div class="mt-2 flex flex-wrap justify-between gap-x-4 gap-y-1 text-[11px] text-slate-500">
|
||||
<span>归档路径:归档区/{{ sel.unit }}/{{ sel.year }}/{{ sel.id }}/</span>
|
||||
<span>关闭时间:{{ sel.closedAt }}</span>
|
||||
<span>共 {{ sel.files }} 个文件 · {{ sel.size }}</span>
|
||||
<span class="flex items-center gap-1 font-mono">
|
||||
<Icon icon="lucide:hash" class="size-12px" />
|
||||
{{ sel.hash }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<NButton size="small" class="text-xs" @click="downloadZip(sel)">
|
||||
<Icon icon="material-symbols:download" class="size-14px" />
|
||||
下载归档包 ZIP
|
||||
</NButton>
|
||||
<NButton size="small" class="text-xs" @click="exportEvidenceChain">导出证据链</NButton>
|
||||
<NButton v-if="!sel.revoked" size="small" class="text-xs" @click="applySupplement">发起归档补充申请</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y scroll-right">
|
||||
<div v-for="f in selFolders" :key="f.dir">
|
||||
<button @click="openFolders[f.dir] = !openFolders[f.dir]"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-slate-50 bg-transparent">
|
||||
<Icon icon="formkit:right" class="size-14px text-slate-400 transition-transform" :class="openFolders[f.dir] ? 'rotate-90' : ''" />
|
||||
<Icon icon="quill:folder-open" v-if="openFolders[f.dir]" class="size-16px text-[#6E93C6]" />
|
||||
<Icon v-else icon="material-symbols:folder-outline" class="size-16px text-slate-400" />
|
||||
{{ f.dir }}
|
||||
<span class="ml-auto text-[11px] font-normal text-slate-400">{{ f.files.length }} 个文件</span>
|
||||
</button>
|
||||
<template v-if="openFolders[f.dir]">
|
||||
<div v-for="file in f.files" :key="file.name" class="flex items-center gap-2 py-1.5 pl-10 pr-3 text-xs hover:bg-slate-50">
|
||||
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
|
||||
<span class="text-[11px] text-slate-400">{{ file.size }}</span>
|
||||
<NButton text type="primary" @click="previewFile(file)">
|
||||
<Icon icon="lucide:eye" class="size-14px" />
|
||||
</NButton>
|
||||
<NButton text type="primary" @click="downloadFile(file)">
|
||||
<Icon icon="material-symbols:download" class="size-16px" />
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs hover:bg-slate-50">
|
||||
<Icon icon="akar-icons:file" class="size-14px text-slate-400" />
|
||||
<span class="flex-1 font-mono">归档索引.txt</span>
|
||||
<span class="text-[11px] text-slate-400">清单与哈希</span>
|
||||
<NButton text type="primary" @click="viewIndex">
|
||||
<Icon icon="lucide:eye" class="size-14px" />
|
||||
</NButton>
|
||||
</template>
|
||||
<div v-else class="h-100% rounded-md border scroll-empty">
|
||||
<div class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无归档包详情数据" class="text-xs"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 附件在线预览弹窗 -->
|
||||
<NModal
|
||||
v-model:show="previewModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '80%', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<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?.file_name }}
|
||||
</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>上传人:{{ previewFile?.uploader_name }}</span>
|
||||
<span>上传时间:{{ previewFile?.created_at?.slice(0, 16) }}</span>
|
||||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||||
</div>
|
||||
<previewComponent v-if="previewFile" :previewFile="previewFile" />
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
@@ -325,8 +513,16 @@ const downloadFile = (file:any) => {
|
||||
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.scroll-left {
|
||||
height: calc(100vh - 303px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.scroll-right {
|
||||
height: calc(100vh - 460px);
|
||||
height: calc(100vh - 416px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.scroll-empty {
|
||||
height: calc(100vh - 281px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from '@/utils/common';
|
||||
import RingChart from './modules/ringChart.vue';
|
||||
import ColumnChart from './modules/columnChart.vue';
|
||||
import ColumnChart2 from './modules/columnChart2.vue';
|
||||
import { factoryStatisticsApi } from '@/service/api/statistics';
|
||||
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
|
||||
const DEPT_ACCEPT = [
|
||||
{ name: "工艺", value: 52 },
|
||||
{ name: "设备", value: 49 },
|
||||
{ name: "管理", value: 25 },
|
||||
];
|
||||
const ACCEPT_COLORS = ["#2080f0", "#7CC242", "#F5A623"];
|
||||
|
||||
const changeData = ref([
|
||||
{
|
||||
name: '2月',
|
||||
value: 42,
|
||||
},
|
||||
{
|
||||
name: '3月',
|
||||
value: 35,
|
||||
},
|
||||
{
|
||||
name: '4月',
|
||||
value: 28,
|
||||
},
|
||||
{
|
||||
name: '5月',
|
||||
value: 12,
|
||||
},
|
||||
{
|
||||
name: '6月',
|
||||
value: 9,
|
||||
},
|
||||
{
|
||||
name: '7月',
|
||||
value: 33,
|
||||
},
|
||||
]);
|
||||
|
||||
const DEFAULT_EFF = [
|
||||
{ pct: 85, label: "按期投用率", c: "#1D4E9C" },
|
||||
{ pct: 64, label: "培训按期完成率", c: "#F5A623" },
|
||||
{ pct: 25, label: "超期变更占比", c: "#E15555" },
|
||||
];
|
||||
const DEFAULT_EXPORTS = ["变更数量统计", "变更周期分析", "超期预警报表", "风险分析执行", "PSSR 与验收", "关闭及时率"];
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 导出数据
|
||||
const exportData = (type: string) => {
|
||||
window.$message?.info('还差接口')
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await factoryStatisticsApi({
|
||||
from: null,
|
||||
to: null,
|
||||
});
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:4 l:4" responsive="screen" :x-gap="gap" :y-gap="16" :style="{'--theme-color': themeStore.themeColor,'--theme-bg': getColorWithOpacity(themeStore.themeColor, 0.1)}">
|
||||
<NGrid cols="s:1 m:4 l:4" responsive="screen" :x-gap="16" :y-gap="16" :style="{'--theme-color': themeStore.themeColor,'--theme-bg': getColorWithOpacity(themeStore.themeColor, 0.1)}">
|
||||
<NGi>
|
||||
<div
|
||||
class="bg-white px-16px py-12px text-[#94a3b8]"
|
||||
@@ -65,12 +50,11 @@ const DEFAULT_EXPORTS = ["变更数量统计", "变更周期分析", "超期预
|
||||
>
|
||||
<p class="text-12px font-600 text-center">按期关闭率</p>
|
||||
<div class="flex justify-center py-2.5">
|
||||
<NProgress type="circle" :percentage="93.8" :offset-degree="180">
|
||||
<NProgress type="circle" :percentage="homeData?.gauges?.ontime_close_rate || 0" :offset-degree="180">
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<p class="flex items-end text-2xl font-bold leading-none text-[#1D2129]">
|
||||
93.8<span class="text-sm font-semibold">%</span>
|
||||
{{homeData?.gauges?.ontime_close_rate || 0}}<span class="text-sm font-semibold">%</span>
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-slate-400">Good</p>
|
||||
</div>
|
||||
</NProgress>
|
||||
</div>
|
||||
@@ -84,68 +68,57 @@ const DEFAULT_EXPORTS = ["变更数量统计", "变更周期分析", "超期预
|
||||
>
|
||||
<p class="text-12px font-600 text-center">PSSR 完成率</p>
|
||||
<div class="flex justify-center py-2.5">
|
||||
<NProgress type="circle" :percentage="96" :offset-degree="180" status="success">
|
||||
<NProgress type="circle" :percentage="homeData?.gauges?.pssr_rate || 0" :offset-degree="180" status="success">
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<p class="flex items-end text-2xl font-bold leading-none text-[#1D2129]">
|
||||
96<span class="text-sm font-semibold">%</span>
|
||||
{{homeData?.gauges?.pssr_rate || 0}}<span class="text-sm font-semibold">%</span>
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-slate-400">Excellent</p>
|
||||
</div>
|
||||
</NProgress>
|
||||
</div>
|
||||
<p class="text-12px text-center">近一年 · 全厂</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="bg-white px-16px py-12px text-[#94a3b8]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }"
|
||||
>
|
||||
<p class="text-12px font-600 text-center">验收一次通过率</p>
|
||||
<div class="flex justify-center py-2.5">
|
||||
<NProgress type="circle" :percentage="88" :offset-degree="180" status="warning">
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<p class="flex items-end text-2xl font-bold leading-none text-[#1D2129]">
|
||||
88<span class="text-sm font-semibold">%</span>
|
||||
</p>
|
||||
<p class="mt-1 text-[11px] text-slate-400">Fair</p>
|
||||
</div>
|
||||
</NProgress>
|
||||
</div>
|
||||
<p class="text-12px text-center">近一年 · 全厂</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="bg-white px-16px py-12px text-[#94a3b8] flex flex-col justify-center items-center"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }"
|
||||
>
|
||||
<p class="text-12px font-600 text-center">当月临时变更数量</p>
|
||||
<p class="mt-2 text-[40px] font-bold leading-none text-[#1D2129] text-center">{{homeData?.gauges?.month_created || 0}}</p>
|
||||
<p class="text-12px text-center mt-1">近一年 · 全厂</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="bg-white px-16px py-12px text-[#94a3b8] flex flex-col justify-center items-center"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }"
|
||||
>
|
||||
<p class="text-12px font-600 text-center">平均关闭时长</p>
|
||||
<p class="mt-2 text-[40px] font-bold leading-none text-[#1D2129] text-center">8.2</p>
|
||||
<p class="mt-2 text-[40px] font-bold leading-none text-[#1D2129] text-center">{{homeData?.gauges?.avg_close_days || 0}}</p>
|
||||
<p class="text-12px text-center mt-1">天 · 优于行业均值</p>
|
||||
<span class="mt-2 rounded-full bg-[var(--theme-bg)] px-2 py-0.5 text-[11px] font-medium text-[var(--theme-color)]">环比 ↓ 0.6 天</span>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:12">
|
||||
<NCard :bordered="true" size="small" :content-style="{padding: 0}" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 p-3">月度变更数量</p>
|
||||
<div class="py-3 px-4">
|
||||
<ColumnChart :data="changeData" style="height: 220px" />
|
||||
<ColumnChart v-if="homeData" :data="homeData?.monthly_counts || []" />
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<NGi span="24 s:24 m:24 l:6">
|
||||
<NCard :bordered="true" size="small" :content-style="{padding: 0}" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 p-3">变更类型分布</p>
|
||||
<div class="py-3 px-4">
|
||||
<RingChart :data="DEPT_ACCEPT" :colors="ACCEPT_COLORS" />
|
||||
<RingChart v-if="homeData" :data="homeData?.type_dist || []" />
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<NGi span="24 s:24 m:24 l:6">
|
||||
<NCard :bordered="true" size="small" :content-style="{padding: 0}"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
@@ -158,15 +131,31 @@ const DEFAULT_EXPORTS = ["变更数量统计", "变更周期分析", "超期预
|
||||
<p class=" text-base font-semibold border-b border-slate-100 p-3">变更效率指标</p>
|
||||
<div class="py-3 px-4">
|
||||
<div class="space-y-5">
|
||||
<div v-for="r in DEFAULT_EFF" :key="r.label">
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-bold text-[#1D2129]">{{ r.pct }}<span class="text-sm font-semibold">%</span></span>
|
||||
<span class="text-xs text-slate-400">{{ r.label }}</span>
|
||||
<span class="text-2xl font-bold text-[#1D2129]">{{homeData?.eff?.ontime_use_rate || 0}}<span class="text-sm font-semibold">%</span></span>
|
||||
<span class="text-xs text-slate-400">按期投用率</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div v-if="r.label==='按期投用率'" class="h-full rounded-full bg-[var(--theme-color)]" :style="{ width: `${r.pct}%` }"></div>
|
||||
<div v-if="r.label==='培训按期完成率'" class="h-full rounded-full bg-[var(--warning-color)]" :style="{ width: `${r.pct}%` }"></div>
|
||||
<div v-if="r.label==='超期变更占比'" class="h-full rounded-full bg-[var(--error-color)]" :style="{ width: `${r.pct}%` }"></div>
|
||||
<div class="h-full rounded-full bg-[var(--theme-color)]" :style="{ width: `${homeData?.eff?.ontime_use_rate || 0}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-bold text-[#1D2129]">{{homeData?.eff?.train_upload_rate || 0}}<span class="text-sm font-semibold">%</span></span>
|
||||
<span class="text-xs text-slate-400">培训按期完成率</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-[var(--warning-color)]" :style="{ width: `${homeData?.eff?.train_upload_rate || 0}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-2xl font-bold text-[#1D2129]">{{homeData?.eff?.overdue_rate || 0}}<span class="text-sm font-semibold">%</span></span>
|
||||
<span class="text-xs text-slate-400">超期变更占比</span>
|
||||
</div>
|
||||
<div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-[var(--error-color)]" :style="{ width: `${homeData?.eff?.overdue_rate || 0}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">近一年 · 全厂口径 · 超期占比越低越好</div>
|
||||
@@ -176,12 +165,12 @@ const DEFAULT_EXPORTS = ["变更数量统计", "变更周期分析", "超期预
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<NCard :bordered="true" size="small" :content-style="{padding: 0}" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<p class=" text-base font-semibold border-b border-slate-100 p-3">平均审批周期趋势(天)</p>
|
||||
<div class="py-3 px-4">
|
||||
<ColumnChart :data="changeData" style="height: 220px" />
|
||||
<ColumnChart2 v-if="homeData" :data="homeData?.cycle_trend || []" />
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
@@ -197,7 +186,7 @@ const DEFAULT_EXPORTS = ["变更数量统计", "变更周期分析", "超期预
|
||||
<p class=" text-base font-semibold border-b border-slate-100 p-3">报表导出</p>
|
||||
<div class="py-3 px-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div v-for="r in DEFAULT_EXPORTS" :key="r" @click="message.success(`演示:${r}已导出 Excel`)"
|
||||
<div v-for="r in DEFAULT_EXPORTS" :key="r" @click="exportData(r)"
|
||||
class="cursor-pointer flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm hover:border-[var(--theme-color)] hover:bg-[var(--theme-bg)]">
|
||||
<Icon icon="akar-icons:file" class="size-16px text-slate-400" />
|
||||
{{ r }}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ref } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'RingChart'
|
||||
name: 'ColumnChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data: { name: string; value: number; }[]; }>();
|
||||
const props = defineProps<{ data: { month: string; count: number; }[]; }>();
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -31,8 +31,8 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
},
|
||||
grid: {
|
||||
top: "5%",
|
||||
left: "5%",
|
||||
right: "5%",
|
||||
left: "0",
|
||||
right: "0",
|
||||
bottom: "11%",
|
||||
},
|
||||
calculable: true,
|
||||
@@ -44,7 +44,8 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
interval: 'auto',
|
||||
hideOverlap: true
|
||||
},
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -61,7 +62,7 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
barWidth: 45,
|
||||
barWidth: 30,
|
||||
barGap: "10%",
|
||||
color: "#2080f0",
|
||||
label: { show: true, position: 'top',color: '#333',fontWeight: 'bold' },
|
||||
@@ -72,8 +73,8 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
let allMonth = props.data.map((item:any) => item.name);
|
||||
let values = props.data.map((item:any) => item.value);
|
||||
let allMonth = props.data.map((item:any) => item.month);
|
||||
let values = props.data.map((item:any) => item.count);
|
||||
opts.xAxis[0].data = allMonth;
|
||||
opts.series[0].data = values;
|
||||
return opts;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'ColumnChart2'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data: { month: string; days: number; }[]; }>();
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: {
|
||||
type: "shadow",
|
||||
textStyle: {
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show:false,
|
||||
bottom: '20%',
|
||||
left: 'center',
|
||||
icon: 'circle',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemStyle: {
|
||||
borderWidth: 0
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: "10%",
|
||||
left: "0",
|
||||
right: "0",
|
||||
bottom: "11%",
|
||||
},
|
||||
calculable: true,
|
||||
xAxis: [
|
||||
{
|
||||
type: "category",
|
||||
boundaryGap: true, // 新增,固定柱子位置
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
interval: 'auto',
|
||||
hideOverlap: true
|
||||
},
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitArea: { show: false },
|
||||
data: [] as string[],
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: "value",
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
barWidth: 30,
|
||||
barGap: "10%",
|
||||
color: "#2080f0",
|
||||
label: { show: true, position: 'top',color: '#333',fontWeight: 'bold' },
|
||||
data: [] as number[],
|
||||
},
|
||||
]
|
||||
}));
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
let allMonth = props.data.map((item:any) => item.month);
|
||||
let values = props.data.map((item:any) => item.days);
|
||||
opts.xAxis[0].data = allMonth;
|
||||
opts.series[0].data = values;
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="w-full h-220px overflow-hidden"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -6,7 +6,8 @@ defineOptions({
|
||||
name: 'RingChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; colors?: string[] }>();
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; }>();
|
||||
const chartData = ref<{ name: string; value: number; }[]>([]);
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -22,9 +23,28 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
itemStyle: {
|
||||
borderWidth: 0
|
||||
},
|
||||
textStyle: {
|
||||
rich: {
|
||||
name: {
|
||||
padding: [0, 0, 0, 0],
|
||||
},
|
||||
count: {
|
||||
padding: [0, 0, 0, 5],
|
||||
fontWeight: 600,
|
||||
},
|
||||
}
|
||||
},
|
||||
formatter: function (name: string) {
|
||||
var info = chartData.value?.find((item: { name: string }) => item.name === name);
|
||||
if(info){
|
||||
return '{name|'+name+'} {count|'+(info.value)+'}';
|
||||
}else{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: `{a|126}\n{b|总数}`,
|
||||
text: '',
|
||||
textStyle: {
|
||||
rich: {
|
||||
a: {
|
||||
@@ -50,7 +70,7 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
|
||||
color: ["#2080f0", "#7CC242", "#F5A623"],
|
||||
radius: ['45%', '65%'],
|
||||
center: ['50%', '45%'],
|
||||
avoidLabelOverlap: false,
|
||||
@@ -72,8 +92,14 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
opts.series[0].data = props.data || [];
|
||||
opts.series[0].color = props.colors || [];
|
||||
const total = props.data?.reduce((sum:number, item:any) => sum + item.count, 0);
|
||||
const result:any = props.data?.map((item:any) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}));
|
||||
chartData.value = result || [];
|
||||
opts.title.text = `{a|${total}}\n{b|总数}`;
|
||||
opts.series[0].data = result || [];
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
|
||||
+41
-352
@@ -1,361 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import {
|
||||
CHANGES, DOC_TODO, LEVEL_COLOR, STATUS_COLOR, TEMP_WATCH, URGE_RECORDS,
|
||||
countOpenStatus, type ChangeLevel, type ChangeOrder, type ChangeStatus, type ChangeType,
|
||||
} from "@/typings/model";
|
||||
import { getColorWithOpacity } from "@/utils/common";
|
||||
import FlowRing from "./modules/FlowRing.vue";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
|
||||
const emit = defineEmits<{ go: [v: string, preset?: string]; openChange: [c: ChangeOrder] }>();
|
||||
|
||||
// 我的变更进度
|
||||
const MY_CLOSED = [
|
||||
{ id: "MOC-2026-R01-0021", title: "T-101 塔顶冷凝器清洗周期调整", type: "管理", level: "一般", status: "已关闭", date: "2026-05-18", duration: "永久" },
|
||||
{ id: "MOC-2026-R01-0017", title: "夏季循环水旁滤临时停用(已恢复)", type: "设备", level: "一般", status: "已关闭", date: "2026-04-02", duration: "临时" },
|
||||
];
|
||||
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: "以信息查看为主,不参与变更流程" },
|
||||
];
|
||||
const mine = computed(() => CHANGES.filter((c) => c.applicant === ROLES[0].person));
|
||||
const myRows = computed(() => [
|
||||
...mine.value.map((c:ChangeOrder) => ({ c, id: c.id, title: c.title, type: c.type, level: c.level, status: c.status, date: c.date, urgent: c.urgent, duration: c.duration })),
|
||||
...MY_CLOSED.map((m) => ({ c: null, ...m, urgent: false })),
|
||||
]);
|
||||
const BIG_STAGES = ["申请审批", "实施与关闭", "变更关闭"];
|
||||
const bigStageOf = (s: any) => (s === "审批中" ? 0 : s === "已关闭" ? 2 : 1);
|
||||
const openRow = (row: any) => {
|
||||
if (row.c){
|
||||
emit("openChange", row.c);
|
||||
} else {
|
||||
window.$message?.info(`演示:查看 ${row.id} 归档详情`);
|
||||
}
|
||||
};
|
||||
// 临时变更到期提醒情况
|
||||
const tempRemind = TEMP_WATCH;
|
||||
const tempTotal = tempRemind.reduce((a, b) => a + b.n, 0);
|
||||
// 尚未关闭变更所处状态
|
||||
const openStatus = computed(() => countOpenStatus(mine.value));
|
||||
const openTotal = computed(() => openStatus.value.reduce((a, b) => a + b.n, 0));
|
||||
// 被驳回信息处理
|
||||
const REJECTED_ITEMS = [
|
||||
{ id: "MOC-2026-R01-0033", title: "P-301 机封冲洗方案调整", reason: "变更前后对比描述不完整,缺少冲洗流量核算数据", by: "李主任 · 车间主任", date: "2026-07-29" },
|
||||
{ id: "MOC-2026-R01-0029", title: "活性炭更换周期延长(3→6 个月)", reason: "风险分析未覆盖吸附饱和穿透情景,请补充 JSA 分析", by: "王海燕 · 安全工程师", date: "2026-07-25" },
|
||||
];
|
||||
// 资料管理
|
||||
const DOC_LINKS = [{ name: '受控文件库', desc: '现行版规程、图纸、数据表' }, { name: '变更档案查询', desc: '历史变更档案调取' }]
|
||||
|
||||
import { localStg } from '@/utils/storage';
|
||||
import Template1 from './modules/template1.vue'
|
||||
import Template3 from './modules/template3.vue'
|
||||
import Template4 from './modules/template4.vue'
|
||||
import Template6 from './modules/template6.vue'
|
||||
import Template7 from './modules/template7.vue'
|
||||
import Template8 from './modules/template8.vue'
|
||||
import Template9 from './modules/template9.vue'
|
||||
import Template10 from './modules/template10.vue'
|
||||
|
||||
// 1:超级管理员
|
||||
// 2:系统管理员
|
||||
// 3:申请人
|
||||
// 4:专业会签
|
||||
// 5:
|
||||
// 6:安全工程师
|
||||
// 7:车间主任
|
||||
// 8:分管领导
|
||||
// 9:质量工程师
|
||||
// 10:非审批人员
|
||||
// 权限
|
||||
const permission = computed(() => {
|
||||
return localStg.get('userInfo')?.role_id || 1;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我的工作台 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:16">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="size-32px flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<div class="pl-12px">
|
||||
<p class="text-14px font-semibold">我的工作台</p>
|
||||
<p class="text-#999 text-11px">车间工程师操作台 · 发起变更、推进实施、完成闭环</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">2026年8月10日 星期一 10:21</p>
|
||||
<NButton type="primary" size="small">
|
||||
<Icon icon="ic:round-plus" class="size-18px" />
|
||||
发起变更
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="gap" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
>
|
||||
<p class="text-12px font-600">待修改驳回</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="2"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">修改后重新提交</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
>
|
||||
<p class="text-12px font-600">待上传资料</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="2"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">培训 / PSSR / 验收材料补交</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
>
|
||||
<p class="text-12px font-600">临时变更到期</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="1"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">到期 ≤ 7 天 · 及时处置</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
>
|
||||
<p class="text-12px font-600">超期未处置</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="1"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">超期临时变更 · 需恢复 / 延期 / 转永久</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 我的变更进度 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">我的变更进度</p>
|
||||
<p class="text-11px text-#94a3b8">阶段进度与台账实时联动</p>
|
||||
</div>
|
||||
<NButton ghost size="small" class="text-xs" @click="emit('go', 'ledger')">
|
||||
全部台账
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="row in myRows.slice(0, 3)" :key="row.id" @click="openRow(row)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.02)
|
||||
}"
|
||||
>
|
||||
<FlowRing :status="row.status" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ row.id }}</span>
|
||||
<NTag class="text-[11px]" v-if="row.urgent" type="error" size="small">紧急</NTag>
|
||||
<NTag class="text-[11px]" v-if="row.level==='重要'" type="error" size="small">{{ row.level }}</NTag>
|
||||
<NTag class="text-[11px]" v-else size="small">{{ row.level }}</NTag>
|
||||
<NTag class="text-[11px]" size="small">{{ row.type }}</NTag>
|
||||
<NTag class="text-[11px]" v-if="row.duration==='临时'" type="warning" size="small">{{ row.duration }}</NTag>
|
||||
<NTag class="text-[11px]" v-else type="info" size="small">{{ row.duration }}</NTag>
|
||||
<span class="ml-auto text-[11px] text-slate-400">申请时间 {{ row.date }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<span class="shrink-0 text-[11px] text-slate-400">当前状态</span>
|
||||
<NTag class="text-[11px]" v-if="row.status==='审批中' || row.status==='实施中' || row.status==='待PSSR' || row.status==='待验收'" type="warning" size="small">{{ row.status }}</NTag>
|
||||
<NTag class="text-[11px]" v-if="row.status==='待关闭'" type="error" size="small">{{ row.status }}</NTag>
|
||||
<NTag class="text-[11px]" v-if="row.status==='已批准'" type="success" size="small">已批准</NTag>
|
||||
<NTag class="text-[11px]" v-if="row.status==='已关闭'" type="info" size="small">已关闭</NTag>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400">
|
||||
<template v-if="row.status === '已关闭'">3/3 阶段全部完成,流程闭环已归档</template>
|
||||
<template v-else>当前阶段:{{ BIG_STAGES[bigStageOf(row.status)] }}({{ bigStageOf(row.status) + 1 }}/3){{ row.c?.flow.find((n) => n.status === 'current') ? ` · 当前节点:${row.c.flow.find((n) => n.status === 'current')!.name}` : "" }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="emit('go', 'ledger')">
|
||||
还有2条,前往台账查看全部 →
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 临时变更到期提醒情况 -->
|
||||
<NGi span="12 s:12 m:12 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">临时变更到期提醒情况</p>
|
||||
<p class="text-11px text-#94a3b8">共 {{tempTotal}} 项</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="t in tempRemind" :key="t.label" class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: t.c }" />
|
||||
<span class="font-medium text-slate-700">{{ t.label }}</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{ t.n }} 项</span>
|
||||
</div>
|
||||
<div class="mt-1 pl-4 text-[11px] text-slate-400">{{ t.note }}</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">提醒规则:到期前 7 天触发(可在「待我处理 · 我的申请」中调整)· 临期 / 超期项请按期办理延期、恢复或转永久</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="emit('go', 'tasks')">
|
||||
前往「待我处理」办理延期 / 恢复 / 转永久 →
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<!-- 尚未关闭的变更所处状态 -->
|
||||
<NGi span="12 s:12 m:12 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',height:'100%' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">尚未关闭的变更所处状态</p>
|
||||
<p class="text-11px text-#94a3b8">共 {{openTotal}} 项</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="s in openStatus" :key="s.name">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 flex-1 text-slate-700">{{ s.name }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-600">{{ s.n }} 项</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full" :style="{ width: (openTotal ? (s.n / openTotal) * 100 : 0) + '%', background: s.c }" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">阶段口径:审批中(含会签)→ 已批准待投用 → 待验收 → 待关闭 · 培训 / PSSR 线下签字后拍照上传,并入资料归档</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="emit('go', 'ledger')">
|
||||
明细见变更台账 →
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 被驳回信息处理 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="system-uicons:reset" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">被驳回信息处理</p>
|
||||
<p class="text-11px text-#94a3b8">修改后可重新提交</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="r in REJECTED_ITEMS.slice(0, 1)"
|
||||
:key="r.id"
|
||||
class="border border-red-100 bg-red-50/50 px-3.5 py-2.5"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag type="error" size="small" class="text-11px">已驳回</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ r.id }}</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">{{ r.date }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm font-medium text-slate-800">{{ r.title }}</div>
|
||||
<div class="mt-0.5 line-clamp-1 text-[11px] leading-4 text-red-600/80">驳回原因:{{ r.reason }}</div>
|
||||
<div class="mt-0.5 text-[11px] text-slate-400">驳回人:{{ r.by }}</div>
|
||||
<NButton ghost type="error" size="small" class="text-xs mt-1.5" @click="emit('go', 'apply')">修改重报 →</NButton>
|
||||
</div>
|
||||
<NButton v-if="REJECTED_ITEMS.length > 1" dashed type="error" size="small" class="text-xs" @click="emit('go', 'tasks')">
|
||||
还有 {{ REJECTED_ITEMS.length - 1 }} 条驳回,前往我的任务处理 →
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 资料归档待办 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="quill:folder-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">资料归档待办</p>
|
||||
<p class="text-11px text-#94a3b8">{{DOC_TODO.length}} 项待补交</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="d in DOC_TODO" :key="d.id" class="rounded-lg border border-[#ACC6E5] bg-[#EAF0F9]/40 px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag type="warning" size="small" class="text-11px">待归档</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ d.id }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm font-medium text-slate-800">{{ d.title }}</div>
|
||||
<div class="mt-0.5 line-clamp-1 text-[11px] leading-4 text-slate-400">缺项:{{ d.missing }}</div>
|
||||
<NButton type="primary" size="small" class="text-xs mt-1.5" @click="emit('go', 'tasks')">
|
||||
去补交(资料关闭页上传)→
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="text-[11px] leading-4 text-slate-400">上传纸质扫描件 / 照片即视为完成确认并联动归档清单;支持传错删除重传、阶段性保存</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 资料管理 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="quill:folder-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">资料管理</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="d in DOC_LINKS" :key="d.name" @click="emit('go', 'knowledge')"
|
||||
class="cursor-pointer flex w-full items-center justify-between border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.02)
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center flex-1 gap-3">
|
||||
<Icon icon="quill:folder-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-slate-700">{{ d.name }}</p>
|
||||
<p class="truncate text-[11px] text-slate-400">{{ d.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Icon icon="formkit:right" class="size-14px text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NSpace>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 超级管理员、系统管理员 -->
|
||||
<Template1 v-if="permission===1 || permission===2" />
|
||||
<!-- 申请人 -->
|
||||
<Template3 v-if="permission===3" />
|
||||
<!-- 专业会签 -->
|
||||
<Template4 v-if="permission===4" />
|
||||
<!-- 安全工程师 -->
|
||||
<Template6 v-if="permission===6" />
|
||||
<!-- 车间主任 -->
|
||||
<Template7 v-if="permission===7" />
|
||||
<!-- 分管领导 -->
|
||||
<Template8 v-if="permission===8" />
|
||||
<!-- 质量工程师 -->
|
||||
<Template9 v-if="permission===9" />
|
||||
<!-- 非审批人员 -->
|
||||
<Template10 v-if="permission===10" />
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ status: any }>();
|
||||
const BIG_STAGES = ["申请审批", "实施与关闭", "变更关闭"];
|
||||
const bigStageOf = (s: any) => (s === "审批中" ? 0 : s === "已关闭" ? 2 : 1);
|
||||
const cur = bigStageOf(props.status);
|
||||
const pct = ((cur + 1) / BIG_STAGES.length) * 100;
|
||||
const size = 34, stroke = 4, r = (size - stroke) / 2, c = 2 * Math.PI * r;
|
||||
const title = `阶段进度 ${cur + 1}/${BIG_STAGES.length}:${BIG_STAGES.slice(0, cur + 1).join(" → ")}${cur < BIG_STAGES.length - 1 ? " → …" : ""}`;
|
||||
</script>
|
||||
<template>
|
||||
<span class="relative inline-flex shrink-0 items-center justify-center" :style="{ width: size + 'px', height: size + 'px' }" :title="title">
|
||||
<svg :width="size" :height="size" class="-rotate-90">
|
||||
<circle :cx="size / 2" :cy="size / 2" :r="r" stroke="#E2E8F0" :stroke-width="stroke" fill="none" />
|
||||
<circle :cx="size / 2" :cy="size / 2" :r="r" stroke="#2080f0" :stroke-width="stroke" fill="none"
|
||||
:stroke-dasharray="c" :stroke-dashoffset="c * (1 - pct / 100)" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[9px] font-bold text-[#2080f0] dark:text-white">{{ cur + 1 }}/3</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'RingChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number; fee: number }[]; total?: number }>();
|
||||
|
||||
const chartData = ref<{ name: string; value: number; fee: number; rate: number }[]>([]);
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
icon: 'circle',
|
||||
left: 'center',
|
||||
bottom: 10,
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
itemStyle: {
|
||||
borderWidth: 0
|
||||
},
|
||||
textStyle: {
|
||||
rich: {
|
||||
name: {
|
||||
width: 50,
|
||||
},
|
||||
count: {
|
||||
width: 80,
|
||||
align: 'right',
|
||||
fontWeight: 600,
|
||||
},
|
||||
rate: {
|
||||
align: 'left',
|
||||
},
|
||||
fee: {
|
||||
width: 80,
|
||||
align: 'right',
|
||||
fontWeight: 600,
|
||||
},
|
||||
}
|
||||
},
|
||||
formatter: function (name: string) {
|
||||
var info = chartData.value?.find((item: { name: string }) => item.name === name);
|
||||
if(info){
|
||||
return '{name|'+name+'} {count|'+(info.value)+' 起}{rate|'+(info.rate)+' %} {money|'+(info.fee)+' 万元}';
|
||||
}else{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: `{a|${(props.total || 0)}}\n{b|已验收}`,
|
||||
textStyle: {
|
||||
rich: {
|
||||
a: {
|
||||
fontSize: 24,
|
||||
color: '#333',
|
||||
fontWeight: 800,
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
b: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
fontWeight: 500,
|
||||
padding: [5, 0, 0, 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
left: '43%',
|
||||
top: '21%',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
|
||||
radius: ['40%', '55%'],
|
||||
center: ['50%', '32%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false,
|
||||
position: 'outside',
|
||||
formatter: '{b}\n{c} ({d}%)',
|
||||
fontSize: 12,
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 10,
|
||||
length2: 10
|
||||
},
|
||||
data: [] as { name: string; value: number }[]
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
const data:any = props.data?.map((item:any) => {
|
||||
const rate = item.count === 0 ? 0 : (Math.round((item.count / (props.total || 0)) * 100) / 100)*100;
|
||||
return {
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
fee: item.fee,
|
||||
rate: rate
|
||||
}
|
||||
});
|
||||
chartData.value = data;
|
||||
opts.series[0].data = chartData.value;
|
||||
opts.series[0].color = ["#52c41a", "#faad14", "#ff4d4f"];
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="w-full h-220px overflow-hidden"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity } from '@/utils/common';
|
||||
import { homeAdminApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 跳转变更申请页面、变更台账、我的任务、发起变更
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='details_approval' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeAdminApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:16">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('systemmanage_setting')">
|
||||
系统设置
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:2" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555]"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<p class="text-12px font-600">待关闭确认</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.close_pending?.total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">验收完成 · 资料齐备 · 专责确认</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<p class="text-12px font-600">在途变更</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.open_changes?.total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">全流程跟踪</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 待关闭确认 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="ix:success" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">待关闭确认</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">变更主管部门(归口部门)专责确认 · 系统记录关闭时间</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.close_pending?.total > 0">
|
||||
<div v-for="row in homeData?.close_pending?.items || []" :key="row.change_id"
|
||||
class="flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition"
|
||||
:style="{borderRadius: themeStore.themeRadius + 'px'}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<NTag size="small" type="error">待关闭</NTag>
|
||||
<p class="flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</p>
|
||||
<span v-if="row.status==='CLOSED'" class="text-[11px]" :style="{color: themeStore.otherColor.success}">关闭时间 2026/9/28 14:29:57</span>
|
||||
<NButton size="small" type="success" class="text-xs" @click="jumpTo('details_approval', row.change_id)">确认关闭</NButton>
|
||||
<NButton size="small" ghost class="text-xs" @click="jumpTo('details_index', row.change_id)">打开变更单</NButton>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<span>{{ row.change_no }}</span>
|
||||
<span>{{row.org_name}}</span>
|
||||
<span>{{row.applicant_name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 在途变更跟踪 -->
|
||||
<NCard :bordered="false" size="small" class="mt-16px" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">在途变更跟踪</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">全流程在途 · 点击打开变更单</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.open_changes?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
变更台账
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag v-for="item in homeData?.open_changes?.items || []" :key="item.status" size="small" type="primary">{{item.label}}{{item.count}}</NTag>
|
||||
</div>
|
||||
<template v-if="homeData?.open_changes?.total > 0">
|
||||
<div v-for="row in homeData?.open_changes?.rows || []" :key="row.change_id" @click="jumpTo('details_index', row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<NTag v-if="row.status==='APPROVING'" size="small" type="warning">审批中</NTag>
|
||||
<NTag v-if="row.status==='APPROVED'" size="small" type="success">已通过</NTag>
|
||||
<NTag v-if="row.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
|
||||
<NTag v-if="row.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
|
||||
<NTag v-if="row.status==='CLOSED'" size="small" type="success">已关闭</NTag>
|
||||
<NTag v-if="row.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
|
||||
<NTag v-if="row.status==='REJECTED'" size="small" type="error">已驳回</NTag>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<NTag v-if="row.overdue_days" size="small" type="error">超期{{row.overdue_days}}天</NTag>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="mt-1 truncate flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<span>{{ row.change_no }}</span>
|
||||
<span>{{row.org_name}}</span>
|
||||
<span>{{row.applicant_name}}</span>
|
||||
<span v-if="row.pending_handlers">当前待办:{{row.pending_handlers}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 系统设置 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<Icon icon="keyline-icons:sliders-horizontal" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">系统设置</p>
|
||||
<p class="text-11px text-#94a3b8">点击进入对应设置项</p>
|
||||
</div>
|
||||
<div class="space-y-2.5 mt-3">
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_template')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">审批流程设置</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">按等级路由 · 会签 · 最终批准</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_setting')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">变更类型管理</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">工艺 / 设备 / 管理 · 支持自定义</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_setting')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">变更等级规则</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">等级评分表 · 重要 / 一般判定</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_user')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">账号设定</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">用户账号 · 组织 · 启停用</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_role')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">角色和权限分配</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">角色 · 菜单 · 数据权限</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_setting')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">通知提醒设置</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">超期 · 临期 · 关闭提醒</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('systemmanage_setting')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">AI 开关与维护</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">AI 辅助开关 · 模型与提示词</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--warning-bg': getColorWithOpacity(themeStore.otherColor.warning, 0.1),
|
||||
'--warning-color': themeStore.otherColor.warning,
|
||||
'--success-color': themeStore.otherColor.success,
|
||||
'--success-bg': getColorWithOpacity(themeStore.otherColor.success, 0.1),
|
||||
}"
|
||||
>
|
||||
<div v-if="homeData?.ai_assist_enabled" class="mt-3 flex items-center gap-2 rounded-lg bg-[var(--success-bg)] px-3 py-2 text-[11px] text-[var(--success-color)]">
|
||||
<Icon icon="lucide:sparkles" class="size-12px" />
|
||||
AI 辅助当前已启用 · 可在「AI 开关与维护」调整
|
||||
</div>
|
||||
<div v-else class="mt-3 flex items-center gap-2 rounded-lg bg-[var(--warning-bg)] px-3 py-2 text-[11px] text-[var(--warning-color)]">
|
||||
<Icon icon="lucide:sparkles" class="size-12px" />
|
||||
AI 辅助当前未启用 · 可在「AI 开关与维护」调整
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { homeNonApproverApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 跳转页面
|
||||
const jumpTo = (key: string) => {
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeNonApproverApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:12">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:12" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('changeledger')">
|
||||
变更台账查询
|
||||
</NButton>
|
||||
<NButton ghost size="small" @click="jumpTo('docs_archives')">
|
||||
资料库查阅
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:3 l:3" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.summary?.open_total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">在办变更</p>
|
||||
<p class="text-12px text-slate-400">全厂 · 实时台账口径</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.summary?.important_open_total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">其中重要变更</p>
|
||||
<p class="text-12px text-slate-400">重点关注对象</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.summary?.month_closed || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">本月已关闭</p>
|
||||
<p class="text-12px text-slate-400">验收闭环完成</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 变更动态 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">变更动态</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">全厂 · 按状态更新时间倒序 · 只读</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.recent?.items?.length > 0" ghost size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
变更台账
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.recent?.items?.length > 0">
|
||||
<div v-for="row in homeData?.recent?.items || []" :key="row.change_id" @click="jumpTo('changeledger')"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<NTag v-if="row.status==='APPROVING'" size="small" type="warning">{{row.status_label}}</NTag>
|
||||
<NTag v-if="row.status==='APPROVED'" size="small" type="success">{{row.status_label}}</NTag>
|
||||
<NTag v-if="row.status==='IMPLEMENTED'" size="small" type="success">{{row.status_label}}</NTag>
|
||||
<NTag v-if="row.status==='ACCEPTED'" size="small" type="error">{{row.status_label}}</NTag>
|
||||
<NTag v-if="row.status==='CLOSED'" size="small" type="success">{{row.status_label}}</NTag>
|
||||
<NTag v-if="row.status==='WITHDRAWN'" size="small" type="error">{{row.status_label}}</NTag>
|
||||
<NTag v-if="row.status==='REJECTED'" size="small" type="error">{{row.status_label}}</NTag>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<span v-if="row.status_changed_at" class="ml-auto text-[11px] text-slate-400">{{row.status_changed_at?.slice(0, 16)}}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 font-mono text-[11px] text-slate-400">
|
||||
<span>{{ row.change_no }}</span>
|
||||
<span>{{row.org_name}}</span>
|
||||
<span>{{row.applicant_name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 全厂变更阶段分布 -->
|
||||
<NGi span="24 s:24 m:24 l:24">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">全厂变更阶段分布</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">点击进入台账按阶段查看</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NButton v-for="item in homeData?.stage_dist || []" :key="item.key" size="small" ghost type="primary" class="text-xs" @click="jumpTo('changeledger')">{{item.label}} {{item.count}}</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 资料快捷查阅 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lucide:shield-alert" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">资料快捷查阅</p>
|
||||
<p class="text-11px text-#94a3b8">现行有效文件 · 只读</p>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('docs_archives')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">操作规程查阅</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">现行有效版本 · 只读</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('docs_archives')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">PID 图纸查阅</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">现行图纸 · 只读</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
<div class="rounded-md border px-3.5 py-2.5 flex items-center cursor-pointer" @click="jumpTo('docs_query')">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">变更档案查阅</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">已关闭变更归档包 · 只读</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 全厂统计概览 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">全厂统计概览</p>
|
||||
<p class="text-11px text-#94a3b8">变更趋势与分布 · 只读</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 w-16">本月新增</span>
|
||||
<NProgress
|
||||
type="line"
|
||||
status="info"
|
||||
:percentage="((homeData?.stats?.month_created || 0) / 3) * 100"
|
||||
:height="10"
|
||||
border-radius="6px"
|
||||
>
|
||||
<span class="text-xs text-slate-700 font-semibold">{{homeData?.stats?.month_created || 0}}天</span>
|
||||
</NProgress>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 w-16">本月关闭</span>
|
||||
<NProgress
|
||||
type="line"
|
||||
status="info"
|
||||
:percentage="((homeData?.stats?.month_closed || 0) / 3) * 100"
|
||||
:height="10"
|
||||
border-radius="6px"
|
||||
>
|
||||
<span class="text-xs text-slate-700 font-semibold" :style="{ color: themeStore.otherColor.error }">{{homeData?.stats?.month_closed || 0}}天</span>
|
||||
</NProgress>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span>验收完全达到率 <b :style="{ color: themeStore.otherColor.success }">{{homeData?.stats?.accept_full_rate || 0}}%</b></span>
|
||||
<span>近一年口径</span>
|
||||
</div>
|
||||
<NButton size="small" ghost block class="text-xs" @click="jumpTo('factoryoverview')">查看统计报表<Icon icon="formkit:right" class="size-12px" /></NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,447 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { homeApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
// 尚未关闭总数
|
||||
const openTotal = computed(() => {
|
||||
return homeData.value?.unclosed_status?.items?.reduce((sum:number, item:any) => sum + item.count, 0) || 0;
|
||||
});
|
||||
// 临时变更到期提醒情况
|
||||
const tempTotal = computed(() => {
|
||||
return (homeData.value?.dept_stats?.unclosed_total || 0) + (homeData.value?.dept_stats?.near_overdue_total || 0) + (homeData.value?.dept_stats?.overdue_total || 0);
|
||||
});
|
||||
|
||||
|
||||
// 跳转变更申请页面、变更台账、我的任务、发起变更
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='my_initiatechange' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString() } });
|
||||
}else if(key==='details_closedoc' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj = (list:any,val:number) => {
|
||||
const obj = list.find((item:any) => item.value == val);
|
||||
return obj || null;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:16">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:8" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton type="primary" size="small" @click="jumpTo('my_initiatechange')">
|
||||
<Icon icon="ic:round-plus" class="size-18px" />
|
||||
发起变更
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<p class="text-12px font-600">待修改驳回</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.dept_stats?.reject_count || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">修改后重新提交</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<p class="text-12px font-600">待上传资料</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.archive_todo?.total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">培训 / PSSR / 验收材料补交</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<p class="text-12px font-600">临时变更到期</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.dept_stats?.near_overdue_total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">到期 ≤ {{remindDays}} 天 · 及时处置</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<p class="text-12px font-600">超期未处置</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.dept_stats?.overdue_total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">超期临时变更 · 需恢复 / 延期 / 转永久</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 我的变更进度 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">我的变更进度</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">阶段进度与台账实时联动</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.my_changes_progress?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
全部台账
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.my_changes_progress?.total > 0">
|
||||
<div v-for="row in homeData?.my_changes_progress?.items?.slice(0, 3) || []" :key="row.change_id" @click="jumpTo('details_index',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ row.change_no }}</span>
|
||||
<NTag class="text-[11px]" v-if="row.urgent===1" type="error" size="small">紧急</NTag>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
<!-- 变更类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,row.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,row.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,row.change_type)?.color),
|
||||
}"
|
||||
>{{matchObj(typeList,row.change_type)?.label}}</NTag>
|
||||
|
||||
<NTag class="text-[11px]" v-if="row.duration_type===2" type="warning" size="small">临时</NTag>
|
||||
<NTag class="text-[11px]" v-else type="info" size="small">永久</NTag>
|
||||
<span v-if="row.apply_time" class="ml-auto text-[11px] text-slate-400">申请时间 {{ row.apply_time?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<span class="shrink-0 text-[11px] text-slate-400">当前状态</span>
|
||||
<!-- 状态标签 -->
|
||||
<NTag v-if="row.status==='APPROVING'" size="small" type="warning">审批中</NTag>
|
||||
<NTag v-if="row.status==='APPROVED'" size="small" type="success">已通过</NTag>
|
||||
<NTag v-if="row.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
|
||||
<NTag v-if="row.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
|
||||
<NTag v-if="row.status==='CLOSED'" size="small" type="success">已关闭</NTag>
|
||||
<NTag v-if="row.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
|
||||
<NTag v-if="row.status==='REJECTED'" size="small" type="error">已驳回</NTag>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400">
|
||||
<p v-if="row.status === 'CLOSED'">阶段全部完成,流程闭环已归档</p>
|
||||
<p v-else>当前节点:{{row.step_name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
还有{{(homeData?.my_changes_progress?.total - 3) > 0 ? (homeData?.my_changes_progress?.total - 3) : 0}}条,前往台账查看全部 →
|
||||
</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 临时变更到期提醒情况 -->
|
||||
<NGi span="12 s:12 m:12 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">临时变更到期提醒情况</p>
|
||||
<p class="text-11px text-#94a3b8">共 {{tempTotal}} 项</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.success }"></span>
|
||||
<span class="font-medium text-slate-700">正常(到期 > {{remindDays}} 天)</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{homeData?.dept_stats?.unclosed_total || 0}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.warning }"></span>
|
||||
<span class="font-medium text-slate-700">临期(到期 ≤ {{remindDays}} 天)</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{homeData?.dept_stats?.near_overdue_total || 0}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.error }"></span>
|
||||
<span class="font-medium text-slate-700">已超期</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{homeData?.dept_stats?.overdue_total || 0}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">提醒规则:到期前 {{remindDays}} 天触发(可在「我的任务 · 我的申请」中调整)· 临期 / 超期项请按期办理延期、恢复或转永久</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
前往「我的任务」办理延期 / 恢复 / 转永久 →
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<!-- 尚未关闭的变更所处状态 -->
|
||||
<NGi span="12 s:12 m:12 l:12">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',height:'100%' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">尚未关闭的变更所处状态</p>
|
||||
<p class="text-11px text-#94a3b8">共 {{openTotal}} 项</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="s in homeData?.unclosed_status?.items" :key="s.status">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 flex-1 text-slate-700">{{ s.label }}</span>
|
||||
<span class="shrink-0 font-semibold text-slate-600">{{ s.count }} 项</span>
|
||||
</div>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<!-- 审批中 -->
|
||||
<div v-if="s.status === 'APPROVING'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.warning }"></div>
|
||||
<!-- 已驳回 -->
|
||||
<div v-if="s.status === 'REJECTED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.error }"></div>
|
||||
<!-- 已批准待投用 -->
|
||||
<div v-if="s.status === 'APPROVED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.themeColor }"></div>
|
||||
<!-- 待验收 -->
|
||||
<div v-if="s.status === 'IMPLEMENTED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.success }"></div>
|
||||
<!-- 待关闭 -->
|
||||
<div v-if="s.status === 'ACCEPTED'" class="h-full rounded-full" :style="{ width: `${openTotal ? (s.count / openTotal) * 100 : 0}%`, background: themeStore.otherColor.error }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-400">阶段口径:审批中(含会签)→ 已批准待投用 → 待验收 → 待关闭 · 培训 / PSSR 线下签字后拍照上传,并入资料归档</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
明细见变更台账 →
|
||||
</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 被驳回信息处理 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="system-uicons:reset" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">被驳回信息处理</p>
|
||||
<p class="text-11px text-#94a3b8">修改后可重新提交</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.rejected_todo?.total > 0">
|
||||
<div
|
||||
v-for="r in homeData?.rejected_todo?.items?.slice(0, 1)"
|
||||
:key="r.id"
|
||||
class="border border-red-100 bg-red-50/50 px-3.5 py-2.5"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag type="error" size="small" class="text-11px">已驳回</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ r.change_no }}</span>
|
||||
<span class="ml-auto text-[11px] text-slate-400">{{ r.rejected_at?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm font-medium text-slate-800">{{ r.title }}</div>
|
||||
<div class="mt-0.5 text-[11px] leading-4 text-red-600/80">驳回原因:{{ r.reject_comment }}</div>
|
||||
<div class="mt-0.5 text-[11px] text-slate-400">驳回人:{{r.reject_user_name}}</div>
|
||||
<NButton ghost type="error" size="small" class="text-xs mt-1.5" @click="jumpTo('my_initiatechange',r.change_id)">修改重报 →</NButton>
|
||||
</div>
|
||||
<NButton dashed type="error" size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
还有 {{ (homeData?.rejected_todo?.total - 1) > 0 ? (homeData?.rejected_todo?.total - 1) : 0 }} 条驳回,前往我的任务处理 →
|
||||
</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 资料归档待办 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="quill:folder-open" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">资料归档待办</p>
|
||||
<p class="text-11px text-#94a3b8">{{homeData?.archive_todo?.total || 0}} 项待补交</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="d in homeData?.archive_todo?.items" :key="d.change_id" class="rounded-lg border border-[#ACC6E5] bg-[#EAF0F9]/40 px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag type="warning" size="small" class="text-11px">待归档</NTag>
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ d.change_no }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm font-medium text-slate-800">{{ d.title }}</div>
|
||||
<div class="mt-0.5 line-clamp-1 text-[11px] leading-4 text-slate-400">缺项:{{ d.missing_names }}</div>
|
||||
<NButton type="primary" size="small" class="text-xs mt-1.5" @click="jumpTo('details_closedoc',d.change_id)">
|
||||
去补交(资料关闭页上传)→
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="text-[11px] leading-4 text-slate-400">上传纸质扫描件 / 照片即视为完成确认并联动归档清单;支持传错删除重传、阶段性保存</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,406 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { homeProfessionalApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 跳转变更申请页面、变更台账、我的任务、发起变更
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else if(key==='details_approval' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj = (list:any,val:number) => {
|
||||
const obj = list.find((item:any) => item.value == val);
|
||||
return obj || null;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeProfessionalApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:12">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:12" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('changeledger')">
|
||||
专业变更查询
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="jumpTo('my_mytask')">
|
||||
<Icon icon="lucide:clipboard-check" class="size-14px" />
|
||||
审批待办
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.todo_sign?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: homeData?.todo_sign?.overdue_count > 0?themeStore.otherColor.error:themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">待我会签</p>
|
||||
<p v-if="homeData?.todo_sign?.overdue_count > 0" class="text-12px" :style="{ color: themeStore.otherColor.error }">其中超期 {{homeData?.todo_sign?.overdue_count}} 项 · 请优先处理</p>
|
||||
<p v-else class="text-12px text-slate-400">无超期</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('professionalstatistics')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.sign_stats?.month_signed || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">本月已会签</p>
|
||||
<p class="text-12px text-slate-400">含驳回 {{homeData?.sign_stats?.month_rejected || 0}} 起 · 系统自动采集</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_highrisk')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.high_risk?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">在办高风险</p>
|
||||
<p class="text-12px text-slate-400">本专业相关 · 建议关注</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('professionalstatistics')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.sign_stats?.risk_analysis_count || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">参与风险分析</p>
|
||||
<p class="text-12px text-slate-400">作为分析组成员(选择式)· 近一年</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 待我会签 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:clipboard-check" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">待我会签</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">超期项行内红色标记 · 审批时自动展示专业相关附件</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.todo_sign?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
我的任务
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.todo_sign?.total > 0">
|
||||
<div v-for="row in homeData?.todo_sign?.items?.slice(0, 3) || []" :key="row.change_id" @click="jumpTo('details_approval',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ row.change_no }}</span>
|
||||
<NTag class="text-[11px]" v-if="row.urgent===1" type="error" size="small">紧急</NTag>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
<!-- 变更类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,row.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,row.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,row.change_type)?.color),
|
||||
}"
|
||||
>{{matchObj(typeList,row.change_type)?.label}}</NTag>
|
||||
|
||||
<NTag class="text-[11px]" v-if="row.duration_type===2" type="warning" size="small">临时</NTag>
|
||||
<NTag class="text-[11px]" v-else type="info" size="small">永久</NTag>
|
||||
<span v-if="row.overdue_status===2" class="text-xs" :style="{ color: themeStore.otherColor.error }">已超期 {{row.overdue_days || 0}} 天</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<NTag v-if="row.step_name" size="small" type="warning">{{row.step_name}}</NTag>
|
||||
<NButton size="small" type="primary" class="text-xs">
|
||||
去审批
|
||||
<Icon icon="akar-icons:arrow-right" class="size-13px ml-1" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<p>申请人:{{row.applicant_name}}</p>
|
||||
<span v-if="row.apply_time" class="text-[11px] text-slate-400">{{ row.apply_time?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
还有{{(homeData?.todo_sign?.total - 3) > 0 ? (homeData?.todo_sign?.total - 3) : 0}}条,前往我的任务查看全部 →
|
||||
</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 最近已会签 -->
|
||||
<NGi span="24 s:24 m:24 l:24">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="ix:success" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">最近已会签</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">本人签署记录 · 点击直达变更内容</p>
|
||||
</div>
|
||||
<div v-if="homeData?.signed_recent?.items.length" class="flex flex-col gap-3">
|
||||
<div v-for="item in homeData?.signed_recent?.items" :key="item.change_id" class="rounded-md border px-3 py-2 flex items-center justify-between gap-2 cursor-pointer" @click="jumpTo('details_index', item.change_id)">
|
||||
<div>
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ item.title }}</p>
|
||||
<p class="font-mono text-[11px] text-slate-400 flex items-center gap-3">
|
||||
<span>{{ item.change_no }}</span>
|
||||
<span>{{ item.finished_at?.slice(0, 16) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<NTag v-if="item.status==='PENDING'" size="small" type="warning" class="text-xs">待会签</NTag>
|
||||
<NTag v-if="item.status==='PASSED'" size="small" type="success" class="text-xs">通过</NTag>
|
||||
<NTag v-if="item.status==='REJECTED'" size="small" type="error" class="text-xs">驳回</NTag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 高风险变更 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lucide:shield-alert" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">高风险变更</p>
|
||||
<p class="text-11px text-#94a3b8">在办 · 建议重点关注</p>
|
||||
</div>
|
||||
<template v-if="homeData?.high_risk?.total > 0">
|
||||
<div class="space-y-2.5">
|
||||
<div v-for="h in homeData?.high_risk?.items || []" :key="h.record_id" class="rounded-md border border-amber-200 bg-amber-50/60 px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">{{ h.change_title }}</span>
|
||||
<NButton size="tiny" ghost type="warning" class="text-xs" @click="jumpTo('my_highrisk')">查看分析</NButton>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px]" :style="{ color: themeStore.otherColor.warning }">{{ h.scene }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
<!-- 我的会签效率 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">我的会签效率</p>
|
||||
<p class="text-11px text-#94a3b8">系统自动采集 · 近 30 天</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 w-16">平均耗时</span>
|
||||
<NProgress
|
||||
type="line"
|
||||
status="info"
|
||||
:percentage="((homeData?.sign_stats?.avg_audit_days || 0) / 2) * 100"
|
||||
:height="10"
|
||||
border-radius="6px"
|
||||
>
|
||||
<span class="text-xs text-slate-700 font-semibold">{{homeData?.sign_stats?.avg_audit_days || 0}}天</span>
|
||||
</NProgress>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 w-16">最长耗时</span>
|
||||
<NProgress
|
||||
type="line"
|
||||
status="error"
|
||||
:percentage="((homeData?.sign_stats?.max_audit_days || 0) / 2) * 100"
|
||||
:height="10"
|
||||
border-radius="6px"
|
||||
>
|
||||
<span class="text-xs text-slate-700 font-semibold" :style="{ color: themeStore.otherColor.error }">{{homeData?.sign_stats?.max_audit_days || 0}}天</span>
|
||||
</NProgress>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span>会签及时率 <b :style="{ color: themeStore.otherColor.success }">{{homeData?.sign_stats?.timely_rate || 0}}%</b></span>
|
||||
<span>目标 2 天内签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor, downloadFileFun } from '@/utils/common';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { homeSafeApi } from "@/service/api/statistics";
|
||||
import previewComponent from '@/components/previewFile/index.vue'
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 附件在线预览弹窗
|
||||
const previewModal = ref<boolean>(false);
|
||||
// 附件在线预览文件
|
||||
const previewFile = ref<any>(null);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 跳转页面
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='details_approval' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 查看
|
||||
const viewFile = (file: any) => {
|
||||
previewFile.value = file;
|
||||
previewModal.value = true;
|
||||
};
|
||||
// 下载
|
||||
const downloadFile = (file: any) => {
|
||||
downloadFileFun(file.file_url, file.file_name);
|
||||
};
|
||||
// 下载模板
|
||||
const downloadTemplate = (record: any) => {
|
||||
window.$message?.info('还差接口')
|
||||
};
|
||||
|
||||
// 匹配对象
|
||||
const matchObj = (list:any,val:number) => {
|
||||
const obj = list.find((item:any) => item.value == val);
|
||||
return obj || null;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeSafeApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:10">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:14" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('my_pssr')">
|
||||
投用与验收总览
|
||||
</NButton>
|
||||
<NButton ghost size="small" @click="jumpTo('my_highrisk')">
|
||||
全厂风险看板
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="jumpTo('my_mytask')">
|
||||
<Icon icon="lucide:clipboard-check" class="size-14px" />
|
||||
审批待办
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.todo_sign?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: homeData?.todo_sign?.overdue_count > 0?themeStore.otherColor.error:themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">待我审批</p>
|
||||
<p v-if="homeData?.todo_sign?.overdue_count > 0" class="text-12px" :style="{ color: themeStore.otherColor.error }">其中超期 {{homeData?.todo_sign?.overdue_count}} 项 · 请优先处理</p>
|
||||
<p v-else class="text-12px text-slate-400">安全审查节点 · 与我的任务同源</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_pssr')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.pssr_supervise?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">PSSR监督</p>
|
||||
<p class="text-12px text-slate-400">待PSSR / 待验收 · 按变更状态实时统计</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_highrisk')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.high_risk?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.otherColor.error }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">高风险在办</p>
|
||||
<p class="text-12px" :style="{ color: themeStore.otherColor.error }">全厂 · 需重点跟踪</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.temp_expiry_scope?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: homeData?.temp_expiry_scope?.overdue_count > 0?themeStore.otherColor.error:themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">临时变更到期</p>
|
||||
<p v-if="homeData?.temp_expiry_scope?.overdue_count > 0" class="text-12px" :style="{ color: themeStore.otherColor.error }">含超期 {{homeData?.temp_expiry_scope?.overdue_count}} 项 · 红色预警</p>
|
||||
<p v-else class="text-12px text-slate-400">无超期</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 审批待办 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:clipboard-check" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">审批待办</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">安全审查 · 审批时自动展示相关附件</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.todo_sign?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
我的任务
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.todo_sign?.total > 0">
|
||||
<div v-for="row in homeData?.todo_sign?.items?.slice(0, 3) || []" :key="row.change_id" @click="jumpTo('details_approval',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-[11px] text-slate-400">{{ row.change_no }}</span>
|
||||
<NTag class="text-[11px]" v-if="row.urgent===1" type="error" size="small">紧急</NTag>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
<!-- 变更类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,row.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,row.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,row.change_type)?.color),
|
||||
}"
|
||||
>{{matchObj(typeList,row.change_type)?.label}}</NTag>
|
||||
|
||||
<NTag class="text-[11px]" v-if="row.duration_type===2" type="warning" size="small">临时</NTag>
|
||||
<NTag class="text-[11px]" v-else type="info" size="small">永久</NTag>
|
||||
<span v-if="row.overdue_status===2" class="text-xs" :style="{ color: themeStore.otherColor.error }">已超期 {{row.overdue_days || 0}} 天</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<NTag v-if="row.step_name" size="small" type="warning">审批:{{row.step_name}}</NTag>
|
||||
<NButton size="small" type="primary" class="text-xs">
|
||||
去审批
|
||||
<Icon icon="akar-icons:arrow-right" class="size-13px ml-1" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<p>申请人:{{row.applicant_name}}</p>
|
||||
<span v-if="row.apply_time" class="text-[11px] text-slate-400">{{ row.apply_time?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton dashed type="primary" size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
还有{{(homeData?.my_changes_progress?.total - 3) > 0 ? (homeData?.my_changes_progress?.total - 3) : 0}}条,前往我的任务查看全部 →
|
||||
</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 通过审批的变更(未关闭) -->
|
||||
<NGi span="24 s:24 m:24 l:24">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lucide:list-checks" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">通过审批的变更(未关闭)</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">PSSR 材料同行显示 · 查看 / 下载</p>
|
||||
<NButton ghost size="small" class="text-xs ml-auto" @click="jumpTo('my_pssr')">
|
||||
进入管理
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-if="homeData?.pssr_supervise?.total>0" class="flex flex-col gap-3">
|
||||
<div v-for="item in homeData?.pssr_supervise?.items" :key="item.id" class="rounded-md border px-3 py-2 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{item.title}}</p>
|
||||
<p class="mt-0.5 text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<span>{{item.change_no}}</span>
|
||||
<span>{{item.org_name}}</span>
|
||||
<span>{{item.applicant_name}}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-md border bg-slate-50/60 px-2 py-1">
|
||||
<Icon icon="akar-icons:file" class="size-14px" :style="{ color: themeStore.themeColor }" />
|
||||
<span class="text-[11px] font-medium text-slate-600">PSSR 确认表</span>
|
||||
<template v-if="item.pssr_attachment">
|
||||
<span class="max-w-[150px] truncate text-[11px] text-slate-400">{{ item.pssr_attachment?.file_name }}</span>
|
||||
<NButton ghost size="tiny" class="text-11px" @click="viewFile(item.pssr_attachment)">查看</NButton>
|
||||
<NButton ghost size="tiny" class="text-11px" @click="downloadFile(item.pssr_attachment)">下载</NButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-[11px]" :style="{ color: themeStore.otherColor.warning }">待传</span>
|
||||
<NButton ghost size="tiny" class="text-11px" @click="downloadTemplate(item)">下载模板</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 高风险变更跟踪 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lucide:shield-alert" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">高风险变更跟踪</p>
|
||||
<p class="text-11px text-#94a3b8">重要等级 · 未关闭 · 实时推导</p>
|
||||
</div>
|
||||
<template v-if="homeData?.high_risk?.total > 0">
|
||||
<div class="space-y-2.5">
|
||||
<div v-for="c in homeData?.high_risk?.items || []" :key="c.record_id" class="rounded-lg border border-red-200 bg-red-50/50 px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">{{ c.change_title }}</span>
|
||||
<NButton size="tiny" ghost type="error" class="text-xs" @click="jumpTo('my_highrisk')">查看分析</NButton>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] flex items-center gap-2" :style="{ color: themeStore.otherColor.error }">
|
||||
<span>{{ c.change_no }}</span>
|
||||
<!-- 状态 -->
|
||||
<span v-if="c.change_status==='APPROVING'">审批中</span>
|
||||
<span v-if="c.change_status==='APPROVED'">已通过</span>
|
||||
<span v-if="c.change_status==='IMPLEMENTED'">待验收</span>
|
||||
<span v-if="c.change_status==='ACCEPTED'">待关闭</span>
|
||||
<span v-if="c.change_status==='CLOSED'">已关闭</span>
|
||||
<span v-if="c.change_status==='WITHDRAWN'">已撤回</span>
|
||||
<span v-if="c.change_status==='REJECTED'">已驳回</span>
|
||||
<span>{{c.org_name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
<!-- 附件在线预览弹窗 -->
|
||||
<NModal
|
||||
v-model:show="previewModal"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '80%', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
>
|
||||
<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?.file_name }}
|
||||
</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>上传人:{{ previewFile?.uploader_name }}</span>
|
||||
<span>上传时间:{{ previewFile?.created_at?.slice(0, 16) }}</span>
|
||||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||||
</div>
|
||||
<previewComponent v-if="previewFile" :previewFile="previewFile" />
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,635 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor, formatTimestamp, getTimeRangeStamp } from '@/utils/common';
|
||||
import RingChart from "./ringChart.vue";
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { urgeApi } from "@/service/api/change";
|
||||
import { homeManagerApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const DATE_RANGE_OPTIONS = [
|
||||
{ label: "近一年", value: 'year' },
|
||||
{ label: "近半年", value: 'half_year' },
|
||||
{ label: "近三个月", value: 'quarter' },
|
||||
{ label: "近一个月", value: 'month' },
|
||||
{ label: "自定义", value: 'custom' },
|
||||
]
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 催办按钮loading
|
||||
const btnLoading = ref<boolean>(false);
|
||||
// 日期范围
|
||||
const dateRange = ref<string>('year');
|
||||
// 自定义日期范围
|
||||
const from = ref<number | null>(getTimeRangeStamp(dateRange.value)?.from ?? null);
|
||||
const to = ref<number | null>(getTimeRangeStamp(dateRange.value)?.to ?? null);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 时间范围切换
|
||||
const timeChange = (val: string) => {
|
||||
dateRange.value = val;
|
||||
if(val === 'custom'){
|
||||
from.value = null;
|
||||
to.value = null;
|
||||
return
|
||||
}else{
|
||||
from.value = getTimeRangeStamp(val)?.from ?? null;
|
||||
to.value = getTimeRangeStamp(val)?.to ?? null;
|
||||
}
|
||||
getData();
|
||||
}
|
||||
// 自定义时间选择
|
||||
const customTimeChange = (type:string,val:any) => {
|
||||
if(type === 'start'){
|
||||
from.value = val;
|
||||
}else{
|
||||
to.value = val;
|
||||
}
|
||||
if(from.value && to.value){
|
||||
if(from.value > to.value){
|
||||
return window.$message?.warning('开始时间不能大于结束时间');
|
||||
}
|
||||
getData();
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转页面
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else if(key==='details_approval' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 催办
|
||||
const urge = async (change_id:number) => {
|
||||
btnLoading.value = true;
|
||||
const {error} = await urgeApi(change_id);
|
||||
if(!error){
|
||||
window.$message?.success('催办成功');
|
||||
}
|
||||
btnLoading.value = false;
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj = (list:any,val:number) => {
|
||||
const obj = list.find((item:any) => item.value == val);
|
||||
return obj || null;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeManagerApi({
|
||||
from: formatTimestamp(from.value,'yyyy-MM-dd'),
|
||||
to: formatTimestamp(to.value,'yyyy-MM-dd'),
|
||||
});
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:8">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:16" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('workshopstatistics')">
|
||||
车间变更看板
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="jumpTo('my_mytask')">
|
||||
<Icon icon="lucide:clipboard-check" class="size-14px" />
|
||||
审批待办
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<p class="text-12px font-600">待我审批</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.todo_approvals?.total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="homeData?.todo_approvals?.overdue_count" class="text-12px text-[#e15555]">含超期未审 · 请优先处理</p>
|
||||
<p v-else class="text-12px">属地变更审批</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<p class="text-12px font-600">重要变更未关闭</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.important_open?.total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">本车间 · 需重点跟踪</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-white px-16px py-12px text-[#94a3b8] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<p class="text-12px font-600">未通过审批</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.rejected_open?.total || 0"
|
||||
class="text-26px font-600 text-[#333]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">本车间 · 驳回待修改重报</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff2f0] px-16px py-12px text-[#e15555] cursor-pointer"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<p class="text-12px font-600">超期未关闭</p>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.overdue_open?.total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-12px">本车间 · 红色预警</p>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 审批待办 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:clipboard-check" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">审批待办</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">属地变更审批</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.todo_approvals?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
我的任务
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.todo_approvals?.total > 0">
|
||||
<div v-for="row in homeData?.todo_approvals?.items || []" :key="row.change_id" @click="jumpTo('details_approval',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
borderColor: row.overdue_status===2?getColorWithOpacity(themeStore.otherColor.error, 0.25):getColorWithOpacity(themeStore.themeColor, 0.25),
|
||||
backgroundColor: row.overdue_status===2?getColorWithOpacity(themeStore.otherColor.error, 0.05):getColorWithOpacity(themeStore.themeColor, 0.05),
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ row.change_no }}</span>
|
||||
<NTag class="text-[11px]" v-if="row.urgent===1" type="error" size="small">紧急</NTag>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
<!-- 变更类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,row.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,row.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,row.change_type)?.color),
|
||||
}"
|
||||
>{{matchObj(typeList,row.change_type)?.label}}</NTag>
|
||||
|
||||
<NTag class="text-[11px]" v-if="row.duration_type===2" type="warning" size="small">临时</NTag>
|
||||
<NTag class="text-[11px]" v-else type="info" size="small">永久</NTag>
|
||||
<span v-if="row.overdue_status===2" class="text-xs" :style="{ color: themeStore.otherColor.error }">已超期 {{row.overdue_days}} 天</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<NTag size="small" type="warning">审批:{{row.step_name}}</NTag>
|
||||
<NButton size="small" type="primary" class="text-xs">
|
||||
去审批
|
||||
<Icon icon="akar-icons:arrow-right" class="size-13px ml-1" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<p>申请人:{{row.applicant_name}}</p>
|
||||
<span v-if="row.apply_time" class="text-[11px] text-slate-400">{{ row.apply_time?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 未关闭变更进度一览 -->
|
||||
<NCard :bordered="false" size="small" class="mt-16px" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">未关闭变更进度一览</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">本车间 · 全部未关闭变更</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.unclosed_progress?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
变更台账
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.unclosed_progress?.total > 0">
|
||||
<div v-for="row in homeData?.unclosed_progress?.items || []" :key="row.change_id" @click="jumpTo('details_index',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)]"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': getColorWithOpacity(themeStore.themeColor, 0.25),
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<NTag v-if="row.status==='APPROVING'" size="small" type="warning">审批中</NTag>
|
||||
<NTag v-if="row.status==='APPROVED'" size="small" type="success">已通过</NTag>
|
||||
<NTag v-if="row.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
|
||||
<NTag v-if="row.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
|
||||
<NTag v-if="row.status==='CLOSED'" size="small" type="success">已关闭</NTag>
|
||||
<NTag v-if="row.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
|
||||
<NTag v-if="row.status==='REJECTED'" size="small" type="error">已驳回</NTag>
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 font-mono text-[11px] text-slate-400">
|
||||
<span>{{row.change_no}}</span>
|
||||
<span>{{row.org_name}}</span>
|
||||
<span>{{row.applicant_name}}</span>
|
||||
</div>
|
||||
<!-- 审批流程 -->
|
||||
<div v-if="row.flow" class="mt-0.5 truncate text-[11px] flex items-center gap-2">
|
||||
<div v-for="(item,index) in row.flow" :key="index" class="flex items-center gap-1">
|
||||
<Icon v-if="index!==0" icon="formkit:right" class="size-12px text-slate-400" />
|
||||
<!-- 通过 -->
|
||||
<div v-if="item.status==='PASSED'" class="w-6px h-6px rounded-6px" :style="{ backgroundColor: themeStore.otherColor.success }"></div>
|
||||
<p v-if="item.status==='PASSED'" :style="{ color: themeStore.otherColor.success }">{{item.name}}</p>
|
||||
<!-- 进行中 -->
|
||||
<div v-if="item.status==='ACTIVE'" class="w-6px h-6px rounded-6px" :style="{ backgroundColor: themeStore.otherColor.warning }"></div>
|
||||
<p v-if="item.status==='ACTIVE'" :style="{ color: themeStore.otherColor.warning }">{{item.name}}</p>
|
||||
<!-- 待 -->
|
||||
<div v-if="item.status==='PENDING'" class="w-6px h-6px rounded-6px bg-slate-400"></div>
|
||||
<p v-if="item.status==='PENDING'" class="text-slate-400">{{item.name}}</p>
|
||||
<!-- 驳回 -->
|
||||
<div v-if="item.status==='REJECTED'" class="w-6px h-6px rounded-6px" :style="{ backgroundColor: themeStore.otherColor.error }"></div>
|
||||
<p v-if="item.status==='REJECTED'" :style="{ color: themeStore.otherColor.error }">{{item.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 临时变更投用后情况 -->
|
||||
<NCard :bordered="false" size="small" class="mt-16px" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">临时变更投用后情况</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">临期提醒 · 超期催办 · 延期 / 转永久跟踪</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.temp_after_use?.total > 0">
|
||||
<div v-for="row in homeData?.temp_after_use?.items || []" :key="row.change_id"
|
||||
class="flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
borderColor: row.overdue_status===2?getColorWithOpacity(themeStore.otherColor.error, 0.25):getColorWithOpacity(themeStore.themeColor, 0.25),
|
||||
backgroundColor: row.overdue_status===2?getColorWithOpacity(themeStore.otherColor.error, 0.05):getColorWithOpacity(themeStore.themeColor, 0.05),
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<NTag v-if="row.overdue_status===2" size="small" type="error">已超期{{row.overdue_days}}天</NTag>
|
||||
<NButton v-if="row.overdue_status===2" size="tiny" :disabled="btnLoading" @click="urge(row.change_id)">去催办</NButton>
|
||||
<NButton v-else size="tiny" @click="jumpTo('changeledger')">查看</NButton>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<span>{{ row.change_no }}</span>
|
||||
<!-- 状态 -->
|
||||
<span v-if="row.status==='APPROVING'">审批中</span>
|
||||
<span v-if="row.status==='APPROVED'">已通过</span>
|
||||
<span v-if="row.status==='IMPLEMENTED'">待验收</span>
|
||||
<span v-if="row.status==='ACCEPTED'">待关闭</span>
|
||||
<span v-if="row.status==='CLOSED'">已关闭</span>
|
||||
<span v-if="row.status==='WITHDRAWN'">已撤回</span>
|
||||
<span v-if="row.status==='REJECTED'">已驳回</span>
|
||||
<span v-if="row.use_time">{{row.use_time?.slice(0, 16)}} 投用</span>
|
||||
<span v-else>尚未投用</span>
|
||||
<p v-if="row.use_time">计划恢复 {{row.restore_deadline?.slice(0, 10)}}<span>(已延期 1 次)</span></p>
|
||||
<span v-else>原计划恢复 {{row.restore_deadline?.slice(0, 10)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 重点关注 -->
|
||||
<NCard :bordered="false" size="small" class="mt-16px" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:shield-alert" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">重点关注</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">安全管控重点 · 点击打开变更单(含风险分析)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.key_attention?.total > 0">
|
||||
<div v-for="row in homeData?.key_attention?.items || []" :key="row.change_id"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition !hover:border-[var(--hover-border)]"
|
||||
@click="jumpTo('details_index',row.change_id)"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': getColorWithOpacity(themeStore.otherColor.error, 0.3),
|
||||
backgroundColor: getColorWithOpacity(themeStore.otherColor.error, 0.05),
|
||||
borderColor: getColorWithOpacity(themeStore.otherColor.error, 0.05),
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<NTag size="small" type="error">重要</NTag>
|
||||
<span class="text-xs text-slate-400">{{ row.change_no }}</span>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-sm font-medium text-slate-800">{{ row.title }}</p>
|
||||
<div class="mt-1 flex items-center gap-2 text-[11px] text-slate-400">
|
||||
<!-- 状态 -->
|
||||
<span v-if="row.status==='APPROVING'">审批中</span>
|
||||
<span v-if="row.status==='APPROVED'">已通过</span>
|
||||
<span v-if="row.status==='IMPLEMENTED'">待验收</span>
|
||||
<span v-if="row.status==='ACCEPTED'">待关闭</span>
|
||||
<span v-if="row.status==='CLOSED'">已关闭</span>
|
||||
<span v-if="row.status==='WITHDRAWN'">已撤回</span>
|
||||
<span v-if="row.status==='REJECTED'">已驳回</span>
|
||||
<span>{{row.applicant_name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<NButton text><Icon icon="formkit:right" class="size-14px text-slate-400" /></NButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 本车间统计 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">本车间统计</p>
|
||||
<p class="text-11px text-#94a3b8">一车间 · 近一年</p>
|
||||
<NSelect :value="dateRange" size="small" :options="DATE_RANGE_OPTIONS" class="ml-auto w-120px" :on-update:value="timeChange"></NSelect>
|
||||
</div>
|
||||
<div v-if="dateRange==='custom'" class="flex items-center gap-2 mt-2">
|
||||
<NDatePicker :value="from" type="date" size="small" class="w-full" :on-update:value="(v) => customTimeChange('start',v)" />
|
||||
<span class="text-xs text-slate-400 flex-1">至</span>
|
||||
<NDatePicker :value="to" type="date" size="small" class="w-full" :on-update:value="(v) => customTimeChange('end',v)" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 mt-3">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<span class="text-3xl font-extrabold text-slate-800">{{ homeData?.dept_summary?.total || 0 }}</span>
|
||||
<span class="ml-1.5 text-[11px] text-slate-500">变更总数</span>
|
||||
</div>
|
||||
<span class="text-[11px] text-slate-400">状态构成占比条</span>
|
||||
</div>
|
||||
<div class="flex h-3 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div :title="`已关闭 ${homeData?.dept_summary?.closed || 0} 起`" :style="{width: `${(homeData?.dept_summary?.closed || 0) / (homeData?.dept_summary?.total || 0) * 100}%`, background: themeStore.themeColor}"></div>
|
||||
<div :title="`审批中 ${homeData?.dept_summary?.approving || 0} 起`" :style="{width: `${(homeData?.dept_summary?.approving || 0) / (homeData?.dept_summary?.total || 0) * 100}%`, background: themeStore.otherColor.warning}"></div>
|
||||
<div :title="`实施中 ${homeData?.dept_summary?.executing || 0} 起`" :style="{width: `${(homeData?.dept_summary?.executing || 0) / (homeData?.dept_summary?.total || 0) * 100}%`, background: themeStore.otherColor.success}"></div>
|
||||
<div :title="`超期未关闭 ${homeData?.dept_summary?.overdue || 0} 起`" :style="{width: `${(homeData?.dept_summary?.overdue || 0) / (homeData?.dept_summary?.total || 0) * 100}%`, background: themeStore.otherColor.error}"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<div class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 bg-slate-50">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: themeStore.themeColor }"></span>
|
||||
<span class="text-[11px] text-slate-500">已关闭</span>
|
||||
<span class="ml-auto text-sm font-bold text-slate-800">{{ homeData?.dept_summary?.closed || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 bg-slate-50">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: themeStore.otherColor.warning }"></span>
|
||||
<span class="text-[11px] text-slate-500">审批中</span>
|
||||
<span class="ml-auto text-sm font-bold text-slate-800">{{ homeData?.dept_summary?.approving || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 bg-slate-50">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: themeStore.otherColor.success }"></span>
|
||||
<span class="text-[11px] text-slate-500">实施中</span>
|
||||
<span class="ml-auto text-sm font-bold text-slate-800">{{ homeData?.dept_summary?.executing || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 bg-red-50">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :style="{ background: themeStore.otherColor.error }"></span>
|
||||
<span class="text-[11px] text-slate-500">超期未关闭</span>
|
||||
<span class="ml-auto text-sm font-bold" :style="{ color: themeStore.otherColor.error }">{{ homeData?.dept_summary?.overdue || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 资料管理 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="material-symbols:folder-open-outline" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">资料管理</p>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-2"
|
||||
:style="{
|
||||
'--hover-border': getColorWithOpacity(themeStore.themeColor, 0.25),
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="cursor-pointer flex w-full items-center gap-3 rounded-lg border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)]"
|
||||
@click="jumpTo('docs_archives')"
|
||||
>
|
||||
<Icon icon="material-symbols:folder-open-outline" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-semibold text-slate-700">本车间操作规程</p>
|
||||
<p class="truncate text-[11px] text-slate-400">现行受控版本查阅</p>
|
||||
</div>
|
||||
<Icon icon="formkit:right" class="size-14px text-slate-400" />
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer flex w-full items-center gap-3 rounded-lg border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)]"
|
||||
@click="jumpTo('docs_archives')"
|
||||
>
|
||||
<Icon icon="material-symbols:folder-open-outline" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-semibold text-slate-700">本车间变更档案</p>
|
||||
<p class="truncate text-[11px] text-slate-400">历史变更记录与验收材料</p>
|
||||
</div>
|
||||
<Icon icon="formkit:right" class="size-14px text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 近一年验收结论占比 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">近一年验收结论占比</p>
|
||||
<p class="text-11px text-#94a3b8">本车间 · 已验收变更</p>
|
||||
</div>
|
||||
<RingChart v-if="homeData" :data="homeData?.accept_conclusion?.items || []" :total="homeData?.accept_conclusion?.total_count || 0" />
|
||||
<div class="flex items-center justify-between border-slate-200 border-t pt-2 border-dashed">
|
||||
<span class="text-[11px] text-slate-400">投入合计(变更材料和实施费)</span>
|
||||
<span class="text-xs font-bold" :style="{ color: themeStore.themeColor }">{{homeData?.accept_conclusion?.total_fee || 0}} 万元</span>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,454 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { homeLeaderApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 跳转变更申请页面、变更台账、我的任务、发起变更
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='details_approval' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 获取正常、临期、超期数量
|
||||
const getNormalNearOverdue = (key:string) => {
|
||||
const targetItem = homeData.value?.temp_expiry_scope?.items?.find((item:any) => item.key === key);
|
||||
return targetItem?.count || 0;
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj = (list:any,val:number) => {
|
||||
const obj = list.find((item:any) => item.value == val);
|
||||
return obj || null;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeLeaderApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:8">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:16" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('factoryoverview')">
|
||||
全厂变更看板
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="jumpTo('my_mytask')">
|
||||
<Icon icon="lucide:clipboard-check" class="size-14px" />
|
||||
重要变更审批
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--theme-bg': getColorWithOpacity(themeStore.themeColor, 0.2),
|
||||
'--warning-bg': getColorWithOpacity(themeStore.otherColor.warning, 0.2),
|
||||
'--warning-color': themeStore.otherColor.warning,
|
||||
'--success-color': themeStore.otherColor.success,
|
||||
'--success-bg': getColorWithOpacity(themeStore.otherColor.success, 0.2),
|
||||
'--error-color': themeStore.otherColor.error,
|
||||
'--error-bg': getColorWithOpacity(themeStore.otherColor.error, 0.2),
|
||||
}"
|
||||
>
|
||||
<NGi>
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--theme-bg)]">
|
||||
<Icon icon="lucide:clipboard-check" class="size-24px text-[--theme-color]" />
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.todo_approvals?.total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
<p class="text-[11px] font-semibold text-slate-400">待我终批 · 均为重要变更</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--success-bg)]">
|
||||
<Icon icon="akar-icons:file" class="size-24px text-[--success-color]" />
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.unclosed_status?.total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
<div class="text-[11px] font-semibold text-slate-400 flex items-center gap-x-2 flex-wrap">
|
||||
当前未关闭变更
|
||||
<span v-for="item in homeData?.unclosed_status?.items || []" :key="item.status">{{item.label}} {{item.count}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--warning-bg)]">
|
||||
<Icon icon="lets-icons:clock" class="size-24px text-[--warning-color]" />
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.temp_expiry_scope?.total || 0"
|
||||
class="text-26px font-600 text-[var(--warning-color)]"
|
||||
/>
|
||||
<div class="text-[11px] font-semibold text-slate-400 flex items-center gap-x-2 flex-wrap">
|
||||
临时变更临期或到期
|
||||
<span v-for="item in homeData?.temp_expiry_scope?.items || []" :key="item.status">{{item.label}} {{item.count}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--error-bg)]">
|
||||
<Icon icon="proicons:alert-triangle" class="size-24px text-[--error-color]" />
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.overdue_unclosed?.total || 0"
|
||||
class="text-26px font-600 text-[var(--error-color)]"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[11px] font-semibold text-slate-400">超期未关闭 · 已升级上报 · 明细见右侧清单</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 重要变更审批 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:clipboard-check" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">重要变更审批</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">仅重要变更到达此节点</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.todo_approvals?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
我的任务
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.todo_approvals?.total > 0">
|
||||
<div v-for="row in homeData?.todo_approvals?.items || []" :key="row.change_id" @click="jumpTo('details_approval',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ row.change_no }}</span>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
<NTag class="text-[11px]" v-if="row.urgent===1" type="error" size="small">紧急</NTag>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<p>申请人:{{row.applicant_name}}</p>
|
||||
<p>{{row.apply_time?.slice(0, 10)}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" class="text-xs">
|
||||
去审批
|
||||
<Icon icon="akar-icons:arrow-right" class="size-13px ml-1" />
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 各部门 / 人员审批时长 -->
|
||||
<NGi span="24 s:24 m:24 l:24">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',height:'100%' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">各部门 / 人员审批时长</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">近一年 · 平均耗时(天)</p>
|
||||
</div>
|
||||
<NButton ghost size="small" class="text-xs" @click="jumpTo('my_datachangecenter')">
|
||||
变更数据中心
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between gap-6">
|
||||
<div class="w-50%">
|
||||
<div class="mb-2 text-[11px] font-semibold text-slate-500">按部门(全流程平均)</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="s in homeData?.dept_efficiency?.items" :key="s.org_id" class="flex items-center">
|
||||
<p class="text-slate-500 w-90px text-xs">{{ s.org_name }}</p>
|
||||
<div class="h-2.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div v-if="s.avg_days > homeData?.person_efficiency?.slowest?.avg_days " class="h-full rounded-full" :style="{ width: `${(s.avg_days / s.total) * 100}%`, background: themeStore.otherColor.error }"></div>
|
||||
<div v-else class="h-full rounded-full" :style="{ width: `${(s.avg_days / s.total) * 100}%`, background: themeStore.themeColor }"></div>
|
||||
</div>
|
||||
<p class="w-45px font-semibold text-slate-600 text-xs text-right">{{ s.avg_days || 0 }} 天</p>
|
||||
</div>
|
||||
<div v-if="homeData?.dept_efficiency?.items?.length===0" class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-50%">
|
||||
<div class="mb-2 text-[11px] font-semibold text-slate-500">按人员(审批 / 会签环节平均)</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="s in homeData?.person_efficiency?.items" :key="s.user_id" class="flex items-center">
|
||||
<p class="text-slate-500 w-90px text-xs">{{ s.user_name }}</p>
|
||||
<div class="h-2.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div v-if="s.avg_days > homeData?.person_efficiency?.slowest?.avg_days " class="h-full rounded-full" :style="{ width: `${(s.avg_days / s.total) * 100}%`, background: themeStore.otherColor.error }"></div>
|
||||
<div v-else class="h-full rounded-full" :style="{ width: `${(s.avg_days / s.total) * 100}%`, background: themeStore.themeColor }"></div>
|
||||
</div>
|
||||
<p class="w-45px font-semibold text-slate-600 text-xs text-right">{{ s.avg_days || 0 }} 天</p>
|
||||
</div>
|
||||
<div v-if="homeData?.person_efficiency?.items?.length===0" class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 rounded-lg bg-[var(--warning-bg)] px-3 py-2 text-[11px] text-[var(--warning-color)]"
|
||||
:style="{
|
||||
'--warning-bg': getColorWithOpacity(themeStore.otherColor.warning, 0.1),
|
||||
'--warning-color': themeStore.otherColor.warning,
|
||||
}"
|
||||
>专业会签环节平均 {{homeData?.person_efficiency?.slowest?.avg_days || 0}} 天,为全流程最慢环节,建议优化会签并行机制</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 超期未关闭变更 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="proicons:alert-triangle" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">超期未关闭变更</p>
|
||||
<p class="text-11px text-#94a3b8">已升级上报 · 催办由属地车间主任执行</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.overdue_unclosed?.total > 0">
|
||||
<div
|
||||
v-for="r in homeData?.overdue_unclosed?.items"
|
||||
:key="r.id"
|
||||
class="border border-red-100 bg-red-50/50 px-3.5 py-2.5"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 状态标签 -->
|
||||
<NTag v-if="r.status==='APPROVING'" size="small" type="warning">审批中</NTag>
|
||||
<NTag v-if="r.status==='APPROVED'" size="small" type="success">已通过</NTag>
|
||||
<NTag v-if="r.status==='IMPLEMENTED'" size="small" type="success">待验收</NTag>
|
||||
<NTag v-if="r.status==='ACCEPTED'" size="small" type="error">待关闭</NTag>
|
||||
<NTag v-if="r.status==='CLOSED'" size="small" type="success">已关闭</NTag>
|
||||
<NTag v-if="r.status==='WITHDRAWN'" size="small" type="error">已撤回</NTag>
|
||||
<NTag v-if="r.status==='REJECTED'" size="small" type="error">已驳回</NTag>
|
||||
<NTag type="error" size="small" class="text-11px">超期{{r.overdue_days || 0}}天</NTag>
|
||||
<NButton ghost size="tiny" class="text-xs ml-auto" @click="jumpTo('details_index',r.change_id)">打开变更单</NButton>
|
||||
</div>
|
||||
<p class="mt-0.5 text-sm font-medium text-slate-800">{{ r.title }}</p>
|
||||
<div class="mt-0.5 text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<span>{{ r.change_no }}</span>
|
||||
<span>{{r.org_name}}</span>
|
||||
<span>{{r.applicant_name}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 临时变更临期或到期 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">临时变更临期或到期</p>
|
||||
</div>
|
||||
<p class="text-11px text-#94a3b8">到期前提醒 · 恢复 / 延期 / 转永久</p>
|
||||
</div>
|
||||
<NButton size="small" class="text-xs" @click="jumpTo('changeledger')">
|
||||
变更台账<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="space-y-3 mt-3">
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.success }"></span>
|
||||
<span class="font-medium text-slate-700">正常(到期 > {{remindDays}} 天)</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{getNormalNearOverdue('normal')}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.warning }"></span>
|
||||
<span class="font-medium text-slate-700">临期(到期 ≤ {{remindDays}} 天)</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{getNormalNearOverdue('near')}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ background: themeStore.otherColor.error }"></span>
|
||||
<span class="font-medium text-slate-700">已超期</span>
|
||||
<span class="ml-auto font-semibold text-slate-600">{{getNormalNearOverdue('overdue')}} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
|
||||
import { systemConfigApi } from "@/service/api/system";
|
||||
import { homeQualityApi } from "@/service/api/statistics";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 变更临期天数
|
||||
const remindDays = ref<number>(0);
|
||||
// 变更类型列表
|
||||
const typeList = ref<any>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any>([]);
|
||||
// 时间显示
|
||||
const clock = ref<string>('');
|
||||
let timer: any = null;
|
||||
// 首页数据
|
||||
const homeData = ref<any>(null);
|
||||
|
||||
// 跳转变更申请页面、变更台账、我的任务、发起变更
|
||||
const jumpTo = (key: string,qid:number = 0) => {
|
||||
if(key==='details_approval' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'home' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前时间字符串
|
||||
const getCurrentTimeStr = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const date = now.getDate();
|
||||
const week = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'][now.getDay()];
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${year}年${month}月${date}日 ${week} ${hours}:${minutes}`;
|
||||
}
|
||||
// 更新时间显示
|
||||
const tick = () => {
|
||||
clock.value = getCurrentTimeStr();
|
||||
// 计算距离下一分钟还有多少毫秒(+50ms 容错,防止定时器提前触发)
|
||||
const now = new Date();
|
||||
const delay = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50;
|
||||
timer = setTimeout(tick, delay);
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj = (list:any,val:number) => {
|
||||
const obj = list.find((item:any) => item.value == val);
|
||||
return obj || null;
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await homeQualityApi();
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
const getConfig = async () => {
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 临时变更临期提醒
|
||||
const remindDaysGroup = data.find((group:{group_key:string}) => group.group_key === 'change_remind');
|
||||
if (remindDaysGroup) {
|
||||
remindDaysGroup?.items.forEach((item:any) => {
|
||||
if(item.item_key === 'temp_due_remind_days'){
|
||||
remindDays.value = Number(item.value);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = changeTypeGroup.items;
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = changeLevelGroup.items;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tick();
|
||||
getConfig();
|
||||
getData();
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if(timer){
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3.5">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:12">
|
||||
<div class="flex-y-center">
|
||||
<p
|
||||
class="py-1.5 px-1.5 flex items-center justify-center bg-[rgb(var(--primary-color))] text-white text-xs font-bold"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
MOC
|
||||
</p>
|
||||
<p class="pl-12px text-14px font-semibold">我的工作台</p>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:12" class="flex items-center justify-end gap-12px">
|
||||
<p class="text-#999 text-xs">{{clock}}</p>
|
||||
<NButton ghost size="small" @click="jumpTo('changeledger')">
|
||||
质量相关变更
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="jumpTo('my_mytask')">
|
||||
<Icon icon="lucide:clipboard-check" class="size-14px" />
|
||||
审批待办
|
||||
</NButton>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('my_mytask')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.todo_sign?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: homeData?.todo_sign?.overdue_count ? themeStore.otherColor.error : themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">待我会签</p>
|
||||
<p v-if="homeData?.todo_sign?.overdue_count" class="text-12px" :style="{ color: themeStore.otherColor.error }">其中超期 {{homeData?.todo_sign?.overdue_count || 0}} 项 · 请优先处理</p>
|
||||
<p v-else class="text-12px text-slate-400">无超期</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('signstatistics')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.sign_stats?.month_signed || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">本月已会签</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.quality_focus?.total || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">涉及产品质量变更</p>
|
||||
<p class="text-12px text-slate-400">在办 · 影响产品合格判定</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi>
|
||||
<div
|
||||
class="flex-1 bg-[#fff] px-16px py-12px cursor-pointer flex items-center gap-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="jumpTo('signstatistics')"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.quality_focus?.year_fmea_count || 0"
|
||||
class="text-26px font-600"
|
||||
:style="{ color: themeStore.themeColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-12px font-600">FMEA分析条数</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive>
|
||||
<!-- 左侧 -->
|
||||
<NGi span="24 s:24 m:24 l:16">
|
||||
<!-- 待我会签 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:clipboard-check" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">待我会签</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">超期项行内红色标记 · 审批时自动展示专业相关附件</p>
|
||||
</div>
|
||||
<NButton v-if="homeData?.todo_sign?.total > 0" ghost size="small" class="text-xs" @click="jumpTo('my_mytask')">
|
||||
我的任务
|
||||
<Icon icon="formkit:right" class="size-12px" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<template v-if="homeData?.todo_sign?.total > 0">
|
||||
<div v-for="row in homeData?.todo_sign?.items || []" :key="row.change_id" @click="jumpTo('details_approval',row.change_id)"
|
||||
class="cursor-pointer flex w-full items-center gap-3 border px-3.5 py-2.5 text-left transition hover:border-[var(--hover-border)] hover:bg-[var(--hover-bg)] hover:bg-[#EAF0F9]/40"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--hover-border': themeStore.themeColor,
|
||||
'--hover-bg': getColorWithOpacity(themeStore.themeColor, 0.05)
|
||||
}"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ row.change_no }}</span>
|
||||
<NTag class="text-[11px]" v-if="row.urgent===1" type="error" size="small">紧急</NTag>
|
||||
<!-- 变更等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,row.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,row.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,row.change_level)?.color),
|
||||
}"
|
||||
>{{matchObj(levelList,row.change_level)?.label}}</NTag>
|
||||
<!-- 变更类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,row.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,row.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,row.change_type)?.color),
|
||||
}"
|
||||
>{{matchObj(typeList,row.change_type)?.label}}</NTag>
|
||||
|
||||
<NTag class="text-[11px]" v-if="row.duration_type===2" type="warning" size="small">临时</NTag>
|
||||
<NTag class="text-[11px]" v-else type="info" size="small">永久</NTag>
|
||||
<span v-if="row.overdue_status===2" class="text-xs" :style="{ color: themeStore.otherColor.error }">已超期 {{row.overdue_days}} 天</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ row.title }}</span>
|
||||
<NTag size="small" type="warning">{{row.step_name}}</NTag>
|
||||
<NButton size="small" type="primary" class="text-xs">
|
||||
去审批
|
||||
<Icon icon="akar-icons:arrow-right" class="size-13px ml-1" />
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-[11px] text-slate-400 flex items-center gap-2">
|
||||
<p>申请人:{{row.applicant_name}}</p>
|
||||
<span v-if="row.apply_time" class="text-[11px] text-slate-400">{{ row.apply_time?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" responsive="screen" item-responsive class="mt-[16px]">
|
||||
<!-- 最近已会签 -->
|
||||
<NGi span="24 s:24 m:24 l:24">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="ix:success" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">最近已会签</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">本人签署记录 · 点击直达变更内容</p>
|
||||
</div>
|
||||
<div v-if="homeData?.signed_recent?.items.length" class="flex flex-col gap-3">
|
||||
<div v-for="item in homeData?.signed_recent?.items" :key="item.change_id" class="rounded-md border px-3 py-2 flex items-center justify-between gap-2 cursor-pointer" @click="jumpTo('details_index', item.change_id)">
|
||||
<div>
|
||||
<p class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800 dark:text-white">{{ item.title }}</p>
|
||||
<p class="font-mono text-[11px] text-slate-400 flex items-center gap-3">
|
||||
<span>{{ item.change_no }}</span>
|
||||
<span>{{ item.finished_at?.slice(0, 16) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<NTag v-if="item.status==='PENDING'" size="small" type="warning" class="text-xs">待会签</NTag>
|
||||
<NTag v-if="item.status==='PASSED'" size="small" type="success" class="text-xs">通过</NTag>
|
||||
<NTag v-if="item.status==='REJECTED'" size="small" type="error" class="text-xs">驳回</NTag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NGi>
|
||||
<!-- 右侧 -->
|
||||
<NGi span="24 s:24 m:24 l:8">
|
||||
<!-- 质量关注变更 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lucide:shield-alert" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">质量关注变更</p>
|
||||
<p class="text-11px text-#94a3b8">在办 · 影响产品合格判定,建议重点关注</p>
|
||||
</div>
|
||||
<template v-if="homeData?.quality_focus?.items?.length">
|
||||
<div class="space-y-2.5">
|
||||
<div v-for="h in homeData?.quality_focus?.items || []" :key="h.change_id" class="rounded-md border border-amber-200 bg-amber-50/60 px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="min-w-0 flex-1 truncate text-sm font-medium text-slate-800">{{ h.title }}</span>
|
||||
<NButton size="tiny" ghost type="warning" class="text-xs" @click="jumpTo('changeledger')">查看</NButton>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px]" :style="{ color: themeStore.otherColor.warning }">{{ h.effect }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="h-100% flex items-center justify-center py-3">
|
||||
<NEmpty description="暂无数据" class="!text-xs"></NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
<!-- 我的会签效率 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px',marginTop:'16px' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lets-icons:clock" class="size-16px" :style="{ color: themeStore.themeColor }" />
|
||||
<p class="text-13px font-700 text-#64749a">我的会签效率</p>
|
||||
<p class="text-11px text-#94a3b8">系统自动采集 · 近 30 天</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 w-16">平均耗时</span>
|
||||
<NProgress
|
||||
type="line"
|
||||
status="info"
|
||||
:percentage="((homeData?.sign_stats?.avg_audit_days || 0) / 2) * 100"
|
||||
:height="10"
|
||||
border-radius="6px"
|
||||
>
|
||||
<span class="text-xs text-slate-700 font-semibold">{{homeData?.sign_stats?.avg_audit_days || 0}}天</span>
|
||||
</NProgress>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500 w-16">最长耗时</span>
|
||||
<NProgress
|
||||
type="line"
|
||||
status="error"
|
||||
:percentage="((homeData?.sign_stats?.max_audit_days || 0) / 2) * 100"
|
||||
:height="10"
|
||||
border-radius="6px"
|
||||
>
|
||||
<span class="text-xs text-slate-700 font-semibold" :style="{ color: themeStore.otherColor.error }">{{homeData?.sign_stats?.max_audit_days || 0}}天</span>
|
||||
</NProgress>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span>会签及时率 <b :style="{ color: themeStore.otherColor.success }">{{homeData?.sign_stats?.timely_rate || 0}}%</b></span>
|
||||
<span>目标 2 天内签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -14,8 +14,8 @@ interface FormModel {
|
||||
}
|
||||
|
||||
const model: FormModel = reactive({
|
||||
username: 'admin',
|
||||
password: 'Admin@123'
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const btnLoading = ref(false);
|
||||
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from '@/utils/common';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
|
||||
const emit = defineEmits<{ go: [v: string, preset?: string];}>();
|
||||
|
||||
const restoresDone = ref<boolean>(false);
|
||||
const restores = ref<{ item: string, done: boolean }[]>([
|
||||
{ item: "安全阀起跳压力已恢复至原设定 0.8MPa(恢复操作票 CAO-2026-0812)", done: true },
|
||||
{ item: "恢复后运行参数核对:连续 24h 无起跳、阀体无泄漏", done: false },
|
||||
{ item: "恢复性检查记录与现场照片已上传", done: false },
|
||||
]);
|
||||
const canRestore = ref<boolean>(false);
|
||||
const readyToClose = ref<boolean>(false);
|
||||
|
||||
const checks = ref<{ item: string, done: boolean, note?: string }[]>([
|
||||
{ item: "变更验收完成(结论:基本达到 · 遗留项 2 项已登记跟踪)", done: true },
|
||||
{ item: "资料上传齐全(验收报告、运行记录、恢复确认记录)", done: false, note: "缺:更新后的安全阀台账" },
|
||||
{ item: "行动项全部关闭(4/4,含「关闭前完成」标记项)", done: true },
|
||||
{ item: "受控文件已更新至生效版(P&ID Rev.4、操作规程)", done: false, note: "安全阀台账待更新发布" },
|
||||
{ item: "培训考核完成(12/12 人),签到与成绩已归档", done: true },
|
||||
]);
|
||||
|
||||
const archiving = ref<boolean>(false);
|
||||
const step = ref<number>(0);
|
||||
const archivedHash = ref<string | null>(null);
|
||||
const allDone = computed(() => checks.value.every((c: { done: boolean }) => c.done));
|
||||
const isCloser = ref<boolean>(true);
|
||||
const startArchive = () => { step.value = 0; archiving.value = true; };
|
||||
|
||||
// 关闭即归档:自动归档八步流程
|
||||
const ARCHIVE_STEPS = [
|
||||
"关闭触发:接收「同意关闭」归档指令(不可逆)",
|
||||
"文件收集:申请表 / 审批记录 / 风险分析 / 培训 / PSSR / 验收单生成 PDF",
|
||||
"附件归集:原始附件、培训签到照片、PSSR 现场照片、验收签字文件",
|
||||
"受控文件复制:生效版与被替代旧版物理复制,与工作区脱离",
|
||||
"清单生成:自动生成归档索引与文件更新清单(新旧版本对照)",
|
||||
"打包锁定:写入归档区(装置 / 年份 / 变更单号),设为只读",
|
||||
"哈希存证:计算归档包 SHA-256 哈希值入库,防篡改校验",
|
||||
"状态更新:变更为「已关闭-已归档」,首页数据同步",
|
||||
];
|
||||
const closedAt = ref<string | null>(null);
|
||||
|
||||
const OVERDUE_LIST = [
|
||||
{ id: "MOC-2026-R01-0039", title: "V-102 安全阀起跳压力临时调整", stage: "待关闭", days: 4 },
|
||||
{ id: "MOC-2026-R02-0031", title: "冷冻盐水泵 P-103 叶轮材质变更", stage: "待验收", days: 7 },
|
||||
{ id: "MOC-2026-R01-0029", title: "操作规程修订(夏季工况)", stage: "审批中", days: 3 },
|
||||
];
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 关闭确认 -->
|
||||
<NCard
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:style="{
|
||||
borderRadius: themeStore.themeRadius + 'px',
|
||||
'--error-color': themeStore.otherColor.error,
|
||||
'--success-color': themeStore.otherColor.success,
|
||||
'--success-bg': getColorWithOpacity(themeStore.otherColor.success, 0.1),
|
||||
'--warning-color': themeStore.otherColor.warning,
|
||||
'--warning-bg': getColorWithOpacity(themeStore.otherColor.warning, 0.1),
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--theme-bg': getColorWithOpacity(themeStore.themeColor, 0.1),
|
||||
}"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<NCard :bordered="true" :content-style="{ padding: '0' }" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="py-3 px-4 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-base font-semibold">
|
||||
<Icon icon="system-uicons:reset" class="size-16px text-[var(--error-color)]" />临时变更恢复确认 · 期限 30 天
|
||||
</div>
|
||||
<NTag v-if="restoresDone" size="small" type="success">恢复确认完成</NTag>
|
||||
<NTag v-else size="small" type="error">已超期 4 天 · 已升级上报刘副总</NTag>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-1 text-xs text-slate-500">
|
||||
<span>计划恢复时间:2026-08-14(临时调整起跳压力 0.88MPa → 恢复原设定 0.8MPa)</span>
|
||||
<span class="text-[var(--error-color)]">超期未恢复:每日 08:00 提醒申请人与属地负责人,已自动升级上报分管领导</span>
|
||||
</div>
|
||||
<div class="space-y-2 pt-3">
|
||||
<div v-for="(r, i) in restores" :key="r.item" class="flex items-center gap-3 rounded-lg border p-3"
|
||||
:class="r.done ? 'border-emerald-200 bg-emerald-50/40' : 'border-amber-200 bg-amber-50/40'">
|
||||
<Icon v-if="r.done" icon="material-symbols:check-circle-outline" class="size-20px text-[var(--success-color)]" />
|
||||
<Icon v-else icon="system-uicons:reset" class="size-18px text-[var(--warning-color)]" />
|
||||
<div class="flex-1 text-sm">{{ r.item }}</div>
|
||||
<NButton v-if="!r.done && canRestore" size="small" ghost class="text-xs"
|
||||
@click="restores = restores.map((x, xi) => (xi === i ? { ...x, done: true } : x)); toast.success('演示:恢复确认完成(移动端可拍照上传凭证)');">
|
||||
<Icon icon="ant-design:camera-outlined" class="size-16px mr-1" />
|
||||
确认恢复
|
||||
</NButton>
|
||||
<span v-if="!r.done && !canRestore" class="text-xs text-slate-400">待申请人 / 属地负责人确认</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="restoresDone" class="mt-3 rounded-lg bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
临时变更恢复确认完成,恢复操作票、参数核对记录与照片将自动纳入归档包「5-验收记录」
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<NCard :bordered="true" :content-style="{ padding: '0' }" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="py-3 px-4 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-base font-semibold">
|
||||
<Icon icon="material-symbols:check-circle-outline" class="size-18px text-slate-500" />关闭校验 · MOC-2026-R01-0039(紧急变更补办)
|
||||
</div>
|
||||
<NTag v-if="readyToClose" size="small" type="success">校验全绿 · 已推送专责</NTag>
|
||||
<NTag v-else size="small" type="warning">超期 4 天</NTag>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 rounded-lg bg-[var(--theme-bg)] px-3 py-2 text-xs text-[var(--theme-color)]">
|
||||
<Icon icon="lucide:bell" class="size-14px" />
|
||||
前置条件(验收完成 / 资料齐全 / 行动项关闭 / 恢复确认 / 受控文件更新)全部满足后,系统自动推送变更主管部门专责(王海燕 · 安全环保部),并抄送申请人与属地负责人
|
||||
</div>
|
||||
<div class="space-y-2 pt-3">
|
||||
<div v-for="(c, i) in checks" :key="c.item" class="flex items-center gap-3 rounded-lg border p-3"
|
||||
:class="c.done ? 'border-emerald-200 bg-emerald-50/40' : 'border-red-200 bg-[#FFF2F0]'">
|
||||
<Icon v-if="c.done" icon="material-symbols:check-circle-outline" class="size-20px text-[var(--success-color)]" />
|
||||
<Icon v-else icon="ant-design:close-circle-outlined" class="size-18px text-[var(--error-color)]" />
|
||||
<div class="flex-1 text-sm">{{ c.item }}<span v-if="c.note" class="ml-2 text-xs text-[var(--error-color)]">{{ c.note }}</span></div>
|
||||
<NButton v-if="!c.done" text type="primary"
|
||||
@click="checks = checks.map((x, xi) => (xi === i ? { ...x, done: true } : x)); toast.success('演示:资料已补充上传,关闭校验清单已同步更新并重新推送专责');">去补充</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 关闭按钮区 -->
|
||||
<div v-if="!archivedHash && !archiving" class="mt-4 space-y-2">
|
||||
<div v-if="readyToClose" class="flex items-center gap-2 rounded-lg bg-[var(--theme-bg)] px-3 py-2 text-xs text-[var(--theme-color)]">
|
||||
<Icon icon="lucide:bell" class="size-14px" />
|
||||
系统已于今日 09:12 自动推送关闭待办至变更主管部门专责(王海燕)· 系统消息 + 企业微信
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-slate-50 p-3">
|
||||
<span class="text-sm text-slate-500">
|
||||
{{ !restoresDone
|
||||
? '临时变更尚未完成恢复确认,禁止关闭(恢复确认是临时变更关闭的前置条件)'
|
||||
: allDone
|
||||
? isCloser
|
||||
? '全部条件满足。确认关闭后系统记录关闭时间与关闭人,并自动生成归档包(副本归档 · 只读锁定 · 哈希存证)'
|
||||
: '全部条件满足,待变更主管部门专责(王海燕 · 安全环保部)确认关闭'
|
||||
: '存在未满足条件,禁止关闭;确需例外须填写说明并经分管领导确认' }}
|
||||
</span>
|
||||
<NButton v-if="isCloser" size="small" type="primary" :disabled="!readyToClose" class="text-xs" @click="startArchive">
|
||||
<Icon icon="uil:lock" class="size-14px mr-1" /> 确认关闭并归档
|
||||
</NButton>
|
||||
<span v-else class="text-xs text-[var(--warning-color)]">仅变更主管部门专责可确认关闭 · 切换至「王海燕 · 安全工程师」体验</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自动归档流程 -->
|
||||
<div v-if="archiving" class="mt-4 space-y-1.5 rounded-lg border border-[var(--theme-color)] bg-[var(--theme-bg)] p-4">
|
||||
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-[var(--theme-color)]">
|
||||
<Icon icon="ri:loader-4-fill" class="size-18px animate-spin" />
|
||||
系统自动归档中,无需人工整理…
|
||||
</div>
|
||||
<div v-for="(s, i) in ARCHIVE_STEPS" :key="s" class="flex items-center gap-2 text-xs"
|
||||
:class="i < step ? 'text-[var(--success-color)]' : i === step ? 'font-medium text-[var(--theme-color)]' : 'text-slate-400'">
|
||||
<Icon v-if="i < step" icon="material-symbols:check-circle-outline" class="size-16px" />
|
||||
<Icon v-else-if="i === step" icon="ri:loader-4-fill" class="size-16px animate-spin" />
|
||||
<span v-else class="h-3.5 w-3.5 shrink-0 rounded-full border border-slate-300"></span>
|
||||
{{ i + 1 }}. {{ s }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 归档完成 -->
|
||||
<div v-if="archivedHash" class="mt-4 space-y-3 rounded-lg border border-emerald-300 bg-emerald-50/60 p-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm font-semibold text-emerald-800">
|
||||
<Icon icon="uil:lock" class="size-18px" /> 已关闭-已归档 · 归档包只读锁定,任何角色不可修改或删除
|
||||
</div>
|
||||
<div class="grid gap-1.5 text-xs text-emerald-800/90 sm:grid-cols-2">
|
||||
<span>关闭时间:{{ closedAt }} · 关闭人:王海燕(变更管理专责 · 安全环保部)</span>
|
||||
<span>归档路径:归档区 / 01-常减压装置 / 2026 / MOC-2026-R01-0039/</span>
|
||||
<span class="flex items-center gap-1"><Hash class="h-3 w-3" /> SHA-256:{{ archivedHash }}</span>
|
||||
<span>归档内容:6 类文件夹 + 归档索引.txt(共 24 个文件,18.4MB)</span>
|
||||
<span>防篡改:系统定期校验哈希值,异常自动告警并从备份恢复</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NButton size="small" type="success" class="text-xs" @click="emit('go', 'knowledge')">
|
||||
<Icon icon="lucide:folder-archive" class="size-14px mr-1" /> 查看归档包
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" class="text-xs" @click="toast.success('演示:下载完整归档包(ZIP,用于外部审计)')">
|
||||
<Icon icon="material-symbols:download" class="size-18px" /> 下载 ZIP
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
<NCard :bordered="true" :content-style="{ padding: '0' }" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="py-3 px-4 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-base font-semibold">
|
||||
<Icon icon="proicons:alert-triangle" class="size-18px text-slate-500" />关闭提醒
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 p-4">
|
||||
<div v-for="o in OVERDUE_LIST" :key="o.id" class="flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm">{{ o.title }}</div>
|
||||
<div class="text-xs text-slate-500">{{ o.id }} · {{ o.stage }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag size="small" type="error">超期 {{ o.days }} 天</NTag>
|
||||
<NButton size="small" class="text-xs" @click="toast.info('演示:已催办(系统消息 + 企业微信)')">催办</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
@@ -1,82 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from "@/utils/common";
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getColorWithOpacity, formatTimestamp, getTimeRangeStamp } from "@/utils/common";
|
||||
import ColumnChart from './modules/columnChart.vue';
|
||||
import RingChart from "./modules/ringChart.vue";
|
||||
import RingChart2 from "./modules/ringChart2.vue";
|
||||
import { changeDataCenterApi, exportChangeDataCenterApi } from "@/service/api/statistics";
|
||||
|
||||
const appStore = useAppStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const themeStore = useThemeStore();
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16));
|
||||
|
||||
const emit = defineEmits<{ go: [v: string, preset?: string]; openChange: [c: any] }>();
|
||||
const DATE_RANGE_OPTIONS = [
|
||||
{ label: "近一年", value: 'year' },
|
||||
{ label: "近半年", value: 'half_year' },
|
||||
{ label: "近三个月", value: 'quarter' },
|
||||
{ label: "近一个月", value: 'month' },
|
||||
]
|
||||
|
||||
const range = ref("近一年");
|
||||
const rangeOptions = [
|
||||
{label: "近一年", value: "近一年"},
|
||||
{label: "近半年", value: "近半年"},
|
||||
{label: "近三个月", value: "近三个月"},
|
||||
{label: "近一个月", value: "近一个月"},
|
||||
];
|
||||
const loading = ref<boolean>(false);
|
||||
const btnLoading = ref<boolean>(false);
|
||||
const homeData = ref<any>(null);
|
||||
// 日期范围
|
||||
const dateRange = ref<string>('year');
|
||||
// 日期
|
||||
const from = ref<number | null>(getTimeRangeStamp(dateRange.value)?.from ?? null);
|
||||
const to = ref<number | null>(getTimeRangeStamp(dateRange.value)?.to ?? null);
|
||||
|
||||
const changeData = ref([
|
||||
{
|
||||
name: '一车间',
|
||||
value: 42,
|
||||
},
|
||||
{
|
||||
name: '二车间',
|
||||
value: 35,
|
||||
},
|
||||
{
|
||||
name: '三车间',
|
||||
value: 28,
|
||||
},
|
||||
{
|
||||
name: '供应部',
|
||||
value: 12,
|
||||
},
|
||||
{
|
||||
name: '质量部',
|
||||
value: 9,
|
||||
},
|
||||
]);
|
||||
// 匹配日期范围
|
||||
const dateLabel = computed(() => {
|
||||
return DATE_RANGE_OPTIONS.find(item => item.value === dateRange.value)?.label || '';
|
||||
})
|
||||
|
||||
const DEPT_ACCEPT = [
|
||||
{ name: "工艺", value: 58 },
|
||||
{ name: "设备", value: 41 },
|
||||
{ name: "管理", value: 27 },
|
||||
];
|
||||
const ACCEPT_COLORS = ["#2080f0", "#faad14", "#52c41a"];
|
||||
// 时间范围切换
|
||||
const timeChange = (val: string) => {
|
||||
dateRange.value = val;
|
||||
from.value = getTimeRangeStamp(val)?.from ?? null;
|
||||
to.value = getTimeRangeStamp(val)?.to ?? null;
|
||||
getData();
|
||||
}
|
||||
|
||||
const DEPT_ACCEPT2 = [
|
||||
{ name: "完全达到", value: 13, money: 61.2, rate: 68 },
|
||||
{ name: "基本达到", value: 4, money: 19.8, rate: 21 },
|
||||
{ name: "未达到", value: 2, money: 5.4, rate: 11 },
|
||||
];
|
||||
const ACCEPT_COLORS2 = ["#52c41a", "#faad14", "#ff4d4f"];
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
btnLoading.value = true;
|
||||
const {data,error} = await exportChangeDataCenterApi({
|
||||
from: formatTimestamp(from.value,'yyyy-MM-dd'),
|
||||
to: formatTimestamp(to.value,'yyyy-MM-dd'),
|
||||
});
|
||||
if(!error){
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
// 确保 res 为 Blob 类型(若 API 返回 ArrayBuffer 或原始数据,需适配)
|
||||
let blob;
|
||||
if (data instanceof Blob) {
|
||||
blob = data;
|
||||
} else {
|
||||
// 假设返回的是二进制数据(如 ArrayBuffer),指定 MIME 类型
|
||||
blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = '变更数据中心.xlsx'; // 推荐使用 download 属性
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// 清理:移除链接并释放 URL 对象
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
btnLoading.value = false;
|
||||
}
|
||||
|
||||
const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',title:'R-201 反应釜搅拌器电机更换(55kW→75kW 防爆电机)'});
|
||||
// 跳转页面
|
||||
const jumpTo = (key: string, qid: number = 0) => {
|
||||
if(key==='details_index' && qid){
|
||||
routerPushByKey(key,{ query: { id: qid.toString(),key:'my_datachangecenter' } });
|
||||
}else{
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const getData = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await changeDataCenterApi({
|
||||
from: formatTimestamp(from.value,'yyyy-MM-dd'),
|
||||
to: formatTimestamp(to.value,'yyyy-MM-dd'),
|
||||
});
|
||||
if(!error){
|
||||
homeData.value = data;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getData();
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<NSpin :show="loading" size="small">
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 我的工作台 -->
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-sm font-bold text-slate-800">全厂变更数据中心</p>
|
||||
<p class="text-[11px] text-slate-400 mt-[1px]">时间相关数据统一在此查询 · 统计时段</p>
|
||||
<NSelect v-model:value="range" :options="rangeOptions" size="tiny" class="w-28" />
|
||||
<NButton ghost size="small" class="ml-auto text-xs">导出</NButton>
|
||||
<NSelect :value="dateRange" :options="DATE_RANGE_OPTIONS" size="tiny" class="w-28" :on-update:value="timeChange" />
|
||||
<NButton ghost size="small" class="ml-auto text-xs" :disabled="btnLoading" @click="exportData">导出</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
<!-- 卡片数据 -->
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="gap" :y-gap="16"
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--theme-bg': getColorWithOpacity(themeStore.themeColor, 0.2),
|
||||
@@ -92,7 +130,7 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--theme-bg)]">
|
||||
<Icon icon="akar-icons:file" class="size-24px text-[--theme-color]" />
|
||||
@@ -100,10 +138,10 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div class="flex flex-col">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="126"
|
||||
:end-value="homeData?.summary?.total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
<p class="text-[11px] font-semibold text-slate-400">变更总数 · {{ range }}</p>
|
||||
<p class="text-[11px] font-semibold text-slate-400">变更总数 · {{ dateLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
@@ -111,7 +149,7 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
@click="jumpTo('changeledger')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--warning-bg)]">
|
||||
<Icon icon="lucide:shield-check" class="size-24px text-[--warning-color]" />
|
||||
@@ -119,10 +157,10 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div class="flex flex-col">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="18"
|
||||
:end-value="homeData?.summary?.important_total || 0"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
<p class="text-[11px] font-semibold text-slate-400">重要变更数量 · {{ range }} · 占比 14%</p>
|
||||
<p class="text-[11px] font-semibold text-slate-400">重要变更数量 · {{ dateLabel }} · 占比 {{homeData?.summary?.important_pct || 0}}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
@@ -130,7 +168,7 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
@click="jumpTo('my_highrisk')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--warning-bg)]">
|
||||
<Icon icon="lucide:shield-check" class="size-24px text-[--warning-color]" />
|
||||
@@ -139,17 +177,17 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="4"
|
||||
:end-value="homeData?.risk?.inherent_high || 0"
|
||||
class="text-26px font-600 text-[var(--error-color)]"
|
||||
/>
|
||||
<p class="text-26px font-600 text-[var(--error-color)]"> 高 /</p>
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="11"
|
||||
:end-value="homeData?.risk?.inherent_mid || 0"
|
||||
class="text-26px font-600 text-[var(--error-color)]"
|
||||
/><p class="text-26px font-600 text-[var(--error-color)]"> 中</p>
|
||||
</div>
|
||||
<p class="text-[11px] font-semibold text-slate-400">变更新增风险 · 剩余高风险 1 · 剩余中风险 6</p>
|
||||
<p class="text-[11px] font-semibold text-slate-400">变更新增风险 · 剩余高风险 {{homeData?.risk?.residual_high || 0}} · 剩余中风险 {{homeData?.risk?.residual_mid || 0}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
@@ -157,87 +195,90 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div
|
||||
class="h-100% flex-1 flex items-center gap-3 bg-white px-16px py-12px cursor-pointer transition hover:-translate-y-1"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
@click="emit('go', 'tasks')"
|
||||
@click="jumpTo('my_effect')"
|
||||
>
|
||||
<span class="shrink-0 rounded-lg p-2.5 bg-[var(--success-bg)]">
|
||||
<Icon icon="material-symbols:check-circle-outline" class="size-24px text-[--success-color]" />
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="86.4"
|
||||
class="text-26px font-600"
|
||||
/>
|
||||
<p class="text-[11px] font-semibold text-slate-400">验收投入合计 · 已验收 19 起 · 完全达标率 68%</p>
|
||||
<div class="flex items-center">
|
||||
<CountTo
|
||||
:start-value="0"
|
||||
:end-value="homeData?.invest?.total_fee || 0"
|
||||
:decimals="1"
|
||||
class="text-26px font-600"
|
||||
/><p class="text-26px font-600 ml-1">万元</p>
|
||||
</div>
|
||||
<p class="text-[11px] font-semibold text-slate-400">验收投入合计 · 已验收 {{homeData?.invest?.total_count || 0}} 起 · 完全达标率 {{homeData?.invest?.full_rate || 0}}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid
|
||||
:x-gap="gap" :y-gap="16" responsive="screen" item-responsive
|
||||
:style="{
|
||||
:x-gap="16" :y-gap="16" responsive="screen" item-responsive
|
||||
:style="{
|
||||
'--theme-color':themeStore.themeColor
|
||||
}"
|
||||
}"
|
||||
>
|
||||
<NGi span="24 s:24 m:24 l:14">
|
||||
<NGi span="24 s:24 m:24 l:14">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px text-[--theme-color]" />
|
||||
<p class="text-13px font-700 text-#64749a">各车间变更数量</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{range}} · 按申请部门</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{dateLabel}} · 按申请部门</p>
|
||||
</div>
|
||||
<ColumnChart :data="changeData" style="height: 100%;" />
|
||||
<ColumnChart v-if="homeData" :data="homeData?.workshop_counts?.items" style="height: 100%;" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:10">
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:10">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px text-[--theme-color]" />
|
||||
<p class="text-13px font-700 text-#64749a">变更类型分布</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{range}} · 工艺 / 设备 / 管理</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{dateLabel}} · 工艺 / 设备 / 管理</p>
|
||||
</div>
|
||||
<RingChart :data="DEPT_ACCEPT" :colors="ACCEPT_COLORS" :range="range" />
|
||||
<RingChart v-if="homeData" :data="homeData?.type_dist?.items" :range="dateLabel" />
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<NGrid
|
||||
:x-gap="gap" :y-gap="16" responsive="screen" item-responsive
|
||||
:style="{
|
||||
:x-gap="16" :y-gap="16" responsive="screen" item-responsive
|
||||
:style="{
|
||||
'--error-color':themeStore.otherColor.error,
|
||||
'--warning-color':themeStore.otherColor.warning,
|
||||
'--success-color':themeStore.otherColor.success,
|
||||
'--theme-color':themeStore.themeColor
|
||||
}"
|
||||
}"
|
||||
>
|
||||
<NGi span="24 s:24 m:24 l:10">
|
||||
<NGi span="24 s:24 m:24 l:10">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="lucide:shield-alert" class="size-16px text-[--theme-color]" />
|
||||
<p class="text-13px font-700 text-#64749a">风险增量与管控</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{range}} · 变更新增风险与管控落实</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{dateLabel}} · 变更新增风险与管控落实</p>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg bg-red-50 px-3 py-2">
|
||||
<div class="text-[11px] text-[var(--error-color)]">新增固有高风险项</div>
|
||||
<div class="text-xl font-bold text-[var(--error-color)]">4</div>
|
||||
<div class="text-xl font-bold text-[var(--error-color)]">{{homeData?.risk?.inherent_high || 0}}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-amber-50 px-3 py-2">
|
||||
<div class="text-[11px] text-[var(--warning-color)]">新增中风险项</div>
|
||||
<div class="text-xl font-bold text-[var(--warning-color)]">11</div>
|
||||
<div class="text-xl font-bold text-[var(--warning-color)]">{{homeData?.risk?.inherent_mid || 0}}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div class="text-[11px] text-slate-500">剩余高风险项</div>
|
||||
<div class="text-xl font-bold text-slate-800">1</div>
|
||||
<div class="text-xl font-bold text-slate-800">{{homeData?.risk?.residual_high || 0}}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div class="text-[11px] text-slate-500">剩余中风险项</div>
|
||||
<div class="text-xl font-bold text-slate-800">6</div>
|
||||
<div class="text-xl font-bold text-slate-800">{{homeData?.risk?.residual_mid || 0}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- <div>
|
||||
<div class="flex justify-between text-[11px] text-slate-500">
|
||||
<span>管控措施落实率</span>
|
||||
<span class="font-semibold text-[var(--success-color)]">96%</span>
|
||||
@@ -245,36 +286,37 @@ const residualHigh = ref<{id: string, title: string}>({id:'MOC-2026-R01-0035',ti
|
||||
<div class="mt-1 h-2 w-full rounded-full bg-slate-100">
|
||||
<div class="h-2 rounded-full bg-[var(--success-color)]" style="width: 96%" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="residualHigh.id" @click="emit('openChange', residualHigh)"
|
||||
</div> -->
|
||||
<div v-if="homeData?.risk?.high_item" @click="jumpTo('details_index', homeData?.risk?.high_item?.change_id)"
|
||||
class="cursor-pointer w-full rounded-lg border border-red-100 bg-[#FFF2F0] px-3 py-2 text-left transition hover:border-red-300">
|
||||
<span class="block text-[11px] font-medium text-[var(--error-color)]">剩余高风险 · 措施落实中</span>
|
||||
<span class="mt-0.5 block truncate text-xs font-medium text-slate-800">{{ residualHigh.title }}</span>
|
||||
<span class="mt-0.5 block text-[11px] text-slate-400">{{ residualHigh.id }} · 电机吊装作业管控措施执行跟踪</span>
|
||||
<span class="mt-0.5 block truncate text-xs font-medium text-slate-800">{{ homeData?.risk?.high_item?.title }}</span>
|
||||
<span class="mt-0.5 block text-[11px] text-slate-400">{{ homeData?.risk?.high_item?.change_no }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:14">
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:24 l:14">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px', height: '100%' }">
|
||||
<div class="flex items-center mb-3 gap-2">
|
||||
<Icon icon="material-symbols:check-circle-outline" class="size-16px text-[--theme-color]" />
|
||||
<p class="text-13px font-700 text-#64749a">变更投入和效果</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{range}} · 支持查询验收明细</p>
|
||||
<p class="text-11px text-#94a3b8 mt-2px">{{dateLabel}} · 支持查询验收明细</p>
|
||||
</div>
|
||||
<RingChart2 :data="DEPT_ACCEPT2" :colors="ACCEPT_COLORS2" style="height: 210px;" />
|
||||
<RingChart2 v-if="homeData" :data="homeData?.invest?.items" :range="dateLabel" />
|
||||
<div class="flex items-center justify-between border-slate-200 pt-1.5">
|
||||
<span class="text-[11px] text-slate-400">投入合计(变更材料和实施费) · 完全达到率 68%</span>
|
||||
<span class="text-xs font-bold text-[var(--theme-color)]">86.4 万元</span>
|
||||
<span class="text-[11px] text-slate-400">投入合计(变更材料和实施费) · 完全达到率 {{homeData?.invest?.full_rate || 0}}%</span>
|
||||
<span class="text-xs font-bold text-[var(--theme-color)]">{{homeData?.invest?.total_fee || 0}} 万元</span>
|
||||
</div>
|
||||
<div @click="emit('go', 'invest')"
|
||||
<div @click="jumpTo('my_effect')"
|
||||
class="cursor-pointer mt-3 flex w-full items-center justify-center gap-1 rounded-lg border border-dashed py-2 text-[11px] text-slate-400 transition hover:border-[#6E93C6] hover:text-[#1D4E9C]">
|
||||
验收明细(名称 / 日期链 / 费用 / 结论)见左边栏「变更效果跟踪」<ChevronRight class="h-3 w-3" />
|
||||
</div>
|
||||
验收明细(名称 / 日期链 / 费用 / 结论)见左边栏「变更投入和效果」<ChevronRight class="h-3 w-3" />
|
||||
</div>
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
@@ -8,6 +8,10 @@ defineOptions({
|
||||
|
||||
const props = defineProps<{ data: { name: string; value: number; }[]; }>();
|
||||
|
||||
watch(() => props.data, (newVal:any) => {
|
||||
init();
|
||||
});
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
@@ -30,7 +34,7 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: "5%",
|
||||
top: "10%",
|
||||
left: "5%",
|
||||
right: "5%",
|
||||
bottom: "5%",
|
||||
@@ -72,9 +76,13 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
let allMonth = props.data.map((item:any) => item.name);
|
||||
let values = props.data.map((item:any) => item.value);
|
||||
opts.xAxis[0].data = allMonth;
|
||||
const result:any = props.data?.map((item:any) => ({
|
||||
name: item.org_name,
|
||||
value: item.count,
|
||||
}));
|
||||
let allOrg = result.map((item:any) => item.name);
|
||||
let values = result.map((item:any) => item.value);
|
||||
opts.xAxis[0].data = allOrg;
|
||||
opts.series[0].data = values;
|
||||
return opts;
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'RingChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; colors?: string[]; range?: string }>();
|
||||
const range = ref("近一年");
|
||||
const props = defineProps<{ data?: { name: string; value: number }[]; range?: string }>();
|
||||
watch(() => props.data, (newVal:any) => {
|
||||
init();
|
||||
});
|
||||
const chartData = ref<{ name: string; value: number; fee: number; rate: number }[]>([]);
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -23,9 +26,28 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
itemStyle: {
|
||||
borderWidth: 0
|
||||
},
|
||||
textStyle: {
|
||||
rich: {
|
||||
name: {
|
||||
padding: [0, 0, 0, 0],
|
||||
},
|
||||
count: {
|
||||
padding: [0, 0, 0, 5],
|
||||
fontWeight: 600,
|
||||
},
|
||||
}
|
||||
},
|
||||
formatter: function (name: string) {
|
||||
var info = chartData.value?.find((item: { name: string }) => item.name === name);
|
||||
if(info){
|
||||
return '{name|'+name+'} {count|'+(info.value)+'}';
|
||||
}else{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: `{a|126}\n{b|${range.value}}`,
|
||||
text: '',
|
||||
textStyle: {
|
||||
rich: {
|
||||
a: {
|
||||
@@ -73,9 +95,24 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
range.value = props.range || "近一年";
|
||||
opts.series[0].data = props.data || [];
|
||||
opts.series[0].color = props.colors || [];
|
||||
const total = props.data?.reduce((sum:number, item:any) => sum + item.count, 0);
|
||||
const result:any = props.data?.map((item:any) => {
|
||||
// 1. 避免除以 0 的情况
|
||||
const safeTotal = total || 0;
|
||||
// 2. 计算 rate,最多保留两位小数 (乘10000四舍五入再除以100)
|
||||
// 如果 total 为 0,则 rate 直接为 0
|
||||
const rate = safeTotal === 0 ? 0 : Math.round((item.count / safeTotal) * 10000) / 100;
|
||||
return {
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
rate: rate
|
||||
}
|
||||
});
|
||||
chartData.value = result;
|
||||
|
||||
opts.title.text = `{a|${total}}\n{b|${props.range}}`;
|
||||
opts.series[0].data = result;
|
||||
opts.series[0].color = ["#2080f0", "#faad14", "#52c41a"];
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
@@ -84,7 +121,7 @@ init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="w-full h-220px overflow-hidden"></div>
|
||||
<div ref="domRef" class="w-full h-210px overflow-hidden"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
|
||||
defineOptions({
|
||||
name: 'RingChart'
|
||||
});
|
||||
|
||||
const props = defineProps<{ data?: { name: string; value: number; money: number; rate: number }[]; colors?: string[]; range?: string }>();
|
||||
const props = defineProps<{ data?: { name: string; value: number; }[]; range?: string }>();
|
||||
watch(() => props.data, (newVal:any) => {
|
||||
init();
|
||||
});
|
||||
const chartData = ref<{ name: string; value: number; fee: number; rate: number }[]>([]);
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
@@ -33,9 +37,6 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
align: 'right',
|
||||
fontWeight: 600,
|
||||
},
|
||||
rate: {
|
||||
align: 'left',
|
||||
},
|
||||
money: {
|
||||
width: 80,
|
||||
align: 'right',
|
||||
@@ -44,16 +45,16 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
}
|
||||
},
|
||||
formatter: function (name: string) {
|
||||
var info = props.data?.find((item: { name: string }) => item.name === name);
|
||||
var info = chartData.value?.find((item: { name: string }) => item.name === name);
|
||||
if(info){
|
||||
return '{name|'+name+'} {count|'+(info.value)+' 起}{rate|'+(info.rate)+' %} {money|'+(info.money)+' 万元}';
|
||||
return '{name|'+name+'} {count|'+(info.value)+' 起} {money|'+(info.fee)+' 万元}';
|
||||
}else{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: `{a|19}\n{b|已验收}`,
|
||||
text: '',
|
||||
textStyle: {
|
||||
rich: {
|
||||
a: {
|
||||
@@ -101,8 +102,24 @@ const { domRef, updateOptions } = useEcharts(() => ({
|
||||
|
||||
function init() {
|
||||
updateOptions(opts => {
|
||||
opts.series[0].data = props.data || [];
|
||||
opts.series[0].color = props.colors || [];
|
||||
const total = props.data?.reduce((sum:number, item:any) => sum + item.count, 0);
|
||||
const result:any = props.data?.map((item:any) => {
|
||||
// 1. 避免除以 0 的情况
|
||||
const safeTotal = total || 0;
|
||||
// 2. 计算 rate,最多保留两位小数 (乘10000四舍五入再除以100)
|
||||
// 如果 total 为 0,则 rate 直接为 0
|
||||
const rate = safeTotal === 0 ? 0 : Math.round((item.count / safeTotal) * 10000) / 100;
|
||||
return {
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
fee: item.fee,
|
||||
rate: rate
|
||||
}
|
||||
});
|
||||
chartData.value = result;
|
||||
opts.title.text = `{a|${total}}\n{b|已验收}`;
|
||||
opts.series[0].data = result;
|
||||
opts.series[0].color = ["#52c41a", "#faad14", "#ff4d4f"];
|
||||
return opts;
|
||||
});
|
||||
}
|
||||
@@ -111,7 +128,7 @@ init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="w-full h-220px overflow-hidden"></div>
|
||||
<div ref="domRef" class="w-full h-210px overflow-hidden"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
+172
-59
@@ -1,46 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { tagTextColor, tagBgColor, tagBorderColor } from '@/utils/common';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { systemConfigApi } from '@/service/api/system';
|
||||
import { effectListApi, exportEffectApi } from '@/service/api/pendingChange';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const permission = computed(() => {
|
||||
return localStg.get('userInfo')?.role_id || 1;
|
||||
})
|
||||
const loading = ref<boolean>(false);
|
||||
const list = ref<any[]>([]);
|
||||
const lists = [
|
||||
{ title: "氮气管网压力分级调整", id: "MOC-2026-R01-0024", applicant: "张工艺", type: "工艺", level: "重要", duration: "永久", applyDate: "2026-05-28", useTime: "2026-06-20", acceptDate: "2026-07-08", cost: 3.2, result: "完全达到" },
|
||||
{ title: "R-201 搅拌密封改造", id: "MOC-2026-R01-0019", applicant: "张工艺", type: "设备", level: "一般", duration: "永久", applyDate: "2026-03-18", useTime: "2026-04-12", acceptDate: "2026-05-20", cost: 6.8, result: "完全达到" },
|
||||
{ title: "循环水加药泵冲程临时调整", id: "MOC-2026-R01-0022", applicant: "张工艺", type: "设备", level: "一般", duration: "临时", applyDate: "2026-04-25", useTime: "2026-05-18", acceptDate: "2026-06-30", cost: 1.5, result: "基本达到" },
|
||||
];
|
||||
// 获取列表数据
|
||||
const getList = () => {
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
list.value = lists;
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
// 总投入
|
||||
const totalCost = ref<number>(0);
|
||||
// 完全达到率
|
||||
const fullRate = ref<number>(0);
|
||||
// 标准列表
|
||||
const standardList = ref<any[]>([]);
|
||||
// 分页
|
||||
const pagination = ref<{page: number, pageSize: number, itemCount: number}>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
// 变更类型列表
|
||||
const typeList = ref<any[]>([]);
|
||||
// 变更等级列表
|
||||
const levelList = ref<any[]>([]);
|
||||
|
||||
// 导出文件
|
||||
const exportFile = () => {
|
||||
window.$message?.success(`导出本车间已验收变更投入和效果表(Excel)`)
|
||||
const exportFile = async () => {
|
||||
const {data,error} = await exportEffectApi();
|
||||
if(!error){
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
// 确保 res 为 Blob 类型(若 API 返回 ArrayBuffer 或原始数据,需适配)
|
||||
let blob;
|
||||
if (data instanceof Blob) {
|
||||
blob = data;
|
||||
} else {
|
||||
// 假设返回的是二进制数据(如 ArrayBuffer),指定 MIME 类型
|
||||
blob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = '变更投入和效果.xlsx'; // 推荐使用 download 属性
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// 清理:移除链接并释放 URL 对象
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
// 查看
|
||||
const openRow = (r: any) => {
|
||||
window.$message?.info(`打开变更详情页 ${r.id}(含验收报告与费用明细)`);
|
||||
routerPushByKey("details_index",{ query: { id: r?.change_id?.toString(),key:'my_effect' } });
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const pageChange = (page: number) => {
|
||||
pagination.value.page = page;
|
||||
getList();
|
||||
}
|
||||
|
||||
// 匹配对象
|
||||
const matchObj= (list:any[],val:any) => {
|
||||
const item = list.find((item:any) => item.value == val);
|
||||
return item || null;
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await effectListApi({
|
||||
page_num:pagination.value.page,
|
||||
page_size:pagination.value.pageSize,
|
||||
});
|
||||
if(!error){
|
||||
list.value = data?.list || [];
|
||||
pagination.value.itemCount = data?.total || 0;
|
||||
totalCost.value = data?.summary?.total_cost || 0;
|
||||
fullRate.value = data?.summary?.full_rate || 0;
|
||||
standardList.value = data?.summary?.items || [];
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
const getConfig = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await systemConfigApi();
|
||||
if(!error){
|
||||
// 解析 变更类型设置
|
||||
const changeTypeGroup = data.find((group:{group_key:string}) => group.group_key === 'change_type');
|
||||
if (changeTypeGroup) {
|
||||
typeList.value = typeList.value.concat(changeTypeGroup.items);
|
||||
}
|
||||
// 解析 变更等级设置
|
||||
const changeLevelGroup = data.find((group:{group_key:string}) => group.group_key === 'change_level');
|
||||
if (changeLevelGroup) {
|
||||
levelList.value = levelList.value.concat(changeLevelGroup.items);
|
||||
}
|
||||
getList();
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
getConfig();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- PSSR监督 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div
|
||||
@@ -52,23 +131,20 @@ onMounted(() => {
|
||||
'--error-bg': themeStore.otherColor.error,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm font-bold text-slate-800">本车间已验收变更</span>
|
||||
<span class="text-xs text-slate-500">共 <b class="text-[#1D4E9C]">{{ list.length }}</b> 起</span>
|
||||
<span class="text-xs text-slate-500">投入合计 <b class="text-[#1D4E9C]">11.5</b> 万元</span>
|
||||
<span class="text-xs text-slate-500">完全达到率 <b class="text-[var(--success-bg)]">67%</b></span>
|
||||
<span class="text-sm font-bold text-slate-800">{{permission===7?'本车间':permission===8?'全厂':''}}已验收变更</span>
|
||||
<span class="text-xs text-slate-500">共 <b class="text-[#1D4E9C]">{{ pagination.itemCount }}</b> 起</span>
|
||||
<span class="text-xs text-slate-500">投入合计 <b class="text-[#1D4E9C]">{{totalCost}}</b> 万元</span>
|
||||
<span class="text-xs text-slate-500">完全达到率 <b class="text-[var(--success-bg)]">{{fullRate}}%</b></span>
|
||||
<span class="hidden h-4 w-px bg-slate-200 sm:block"></span>
|
||||
<span class="text-xs text-slate-500">
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full align-middle bg-[var(--success-bg)]"></span>
|
||||
完全达到<b class="text-slate-800">2</b> 起 · <b class="text-slate-800">10.0</b> 万元
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full align-middle bg-[var(--warning-bg)]"></span>
|
||||
基本达到<b class="text-slate-800">1</b> 起 · <b class="text-slate-800">1.5</b> 万元
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full align-middle bg-[var(--error-bg)]"></span>
|
||||
未达到<b class="text-slate-800">0</b> 起 · <b class="text-slate-800">0.0</b> 万元
|
||||
</span>
|
||||
|
||||
<template v-for="item in standardList" :key="item.key">
|
||||
<span class="text-xs text-slate-500">
|
||||
<span v-if="item.key==='3'" class="mr-1 inline-block h-2 w-2 rounded-full align-middle bg-[var(--success-bg)]"></span>
|
||||
<span v-if="item.key==='2'" class="mr-1 inline-block h-2 w-2 rounded-full align-middle bg-[var(--warning-bg)]"></span>
|
||||
<span v-if="item.key==='1'" class="mr-1 inline-block h-2 w-2 rounded-full align-middle bg-[var(--error-bg)]"></span>
|
||||
{{item.label}}<b class="text-slate-800">{{item.count}}</b> 起 · <b class="text-slate-800">{{item.fee}}</b> 万元
|
||||
</span>
|
||||
</template>
|
||||
<NButton size="small" ghost class="ml-auto text-xs" @click="exportFile">
|
||||
<Icon icon="material-symbols:download" class="size-16px" />导出
|
||||
</NButton>
|
||||
@@ -84,35 +160,72 @@ onMounted(() => {
|
||||
<span class="w-[64px] shrink-0 text-right">操作</span>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="divide-y">
|
||||
<div class="divide-y h-100%">
|
||||
<div v-for="r in list" :key="r.id" class="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-4 py-3 transition hover:bg-[#EAF0F9]/40">
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium text-slate-800">{{ r.title }}</span>
|
||||
<NTag v-if="r.level==='一般'" size="small">{{ r.level }}</NTag>
|
||||
<NTag v-if="r.level==='重要'" size="small" type="error">{{ r.level }}</NTag>
|
||||
<NTag size="small" type="info">{{ r.type }}</NTag>
|
||||
<NTag v-if="r.duration==='永久'" size="small">{{ r.duration }}</NTag>
|
||||
<NTag v-if="r.duration==='临时'" size="small" type="warning">{{ r.duration }}</NTag>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium text-slate-800">{{ r.title }}</span>
|
||||
<!-- 等级 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(levelList,r.change_level)?.color),
|
||||
textColor: tagTextColor(matchObj(levelList,r.change_level)?.color),
|
||||
borderColor: tagBorderColor(matchObj(levelList,r.change_level)?.color),
|
||||
}"
|
||||
>
|
||||
{{ matchObj(levelList,r.change_level)?.label }}
|
||||
</NTag>
|
||||
<!-- 类型 -->
|
||||
<NTag
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(matchObj(typeList,r.change_type)?.color),
|
||||
textColor: tagTextColor(matchObj(typeList,r.change_type)?.color),
|
||||
borderColor: tagBorderColor(matchObj(typeList,r.change_type)?.color),
|
||||
}"
|
||||
>
|
||||
{{ matchObj(typeList,r.change_type)?.label }}
|
||||
</NTag>
|
||||
<NTag v-if="r.duration_type===2" size="small" type="warning">临时</NTag>
|
||||
<NTag v-if="r.duration_type===1" size="small" type="info">永久</NTag>
|
||||
</span>
|
||||
<span class="mt-0.5 block font-mono text-[11px] text-slate-400">{{ r.change_no }} · {{ r.applicant_name }} · 费用取验收时填报的「变更材料和实施费」</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block font-mono text-[11px] text-slate-400">{{ r.id }} · {{ r.applicant }} · 费用取验收时填报的「变更材料和实施费」</span>
|
||||
</span>
|
||||
<span class="w-[92px] shrink-0 text-xs text-slate-600">{{ r.applyDate }}</span>
|
||||
<span class="w-[92px] shrink-0 text-xs text-slate-600">{{ r.useTime }}</span>
|
||||
<span class="w-[92px] shrink-0 text-xs text-slate-600">{{ r.acceptDate }}</span>
|
||||
<span class="w-[80px] shrink-0 text-right text-sm font-bold text-slate-800">{{ r.cost.toFixed(1) }}</span>
|
||||
<span class="w-[100px] shrink-0 text-center">
|
||||
<NTag v-if="r.result==='完全达到'" size="small" type="success">{{ r.result }}</NTag>
|
||||
<NTag v-if="r.result==='基本达到'" size="small" type="warning">{{ r.result }}</NTag>
|
||||
<NTag v-if="r.result==='未达到'" size="small" type="error">{{ r.result }}</NTag>
|
||||
</span>
|
||||
<span class="w-[64px] shrink-0 text-right">
|
||||
<NButton size="small" ghost class="text-xs" @click="openRow(r)">查看</NButton>
|
||||
</span>
|
||||
<span class="w-[92px] shrink-0 text-xs text-slate-600">{{ r.apply_time?.slice(0,10) }}</span>
|
||||
<span class="w-[92px] shrink-0 text-xs text-slate-600">{{ r.use_time?.slice(0,10) }}</span>
|
||||
<span class="w-[92px] shrink-0 text-xs text-slate-600">{{ r.accept_time?.slice(0,10) }}</span>
|
||||
<span class="w-[80px] shrink-0 text-right text-sm font-bold text-slate-800">{{ r.cost }}</span>
|
||||
<span class="w-[100px] shrink-0 text-center">
|
||||
<!-- 完全达到 -->
|
||||
<NTag v-if="r.result_status===3" size="small" type="success">{{r.result_label}}</NTag>
|
||||
<!-- 基本达到 -->
|
||||
<NTag v-if="r.result_status===2" size="small" type="warning">{{r.result_label}}</NTag>
|
||||
<!-- 未达到 -->
|
||||
<NTag v-if="r.result_status===1" size="small" type="error">{{r.result_label}}</NTag>
|
||||
</span>
|
||||
<span class="w-[64px] shrink-0 text-right">
|
||||
<NButton size="small" ghost class="text-xs" @click="openRow(r)">查看</NButton>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="list.length === 0" class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无数据"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-3">
|
||||
<NPagination
|
||||
v-model:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:item-count="pagination.itemCount"
|
||||
:on-update:page="pageChange"
|
||||
>
|
||||
<template #prefix="{ itemCount }">
|
||||
共 {{ itemCount }} 条
|
||||
</template>
|
||||
</NPagination>
|
||||
</div>
|
||||
</NCard>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
@@ -120,7 +233,7 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 240px);
|
||||
height: calc(100vh - 279px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,476 +1,14 @@
|
||||
<!-- 高风险 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
import Risk from './modules/risk.vue';
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
const keyword = ref<string>("");
|
||||
const progressDrawer = ref<boolean>(false);
|
||||
const currentTab = ref<string>("highRisk");
|
||||
const range = ref<string>("近一个月");
|
||||
const dept = ref<string>("全部部门");
|
||||
const method = ref<string>("全部");
|
||||
// 当前记录
|
||||
const currentRecord = ref<any>(null);
|
||||
const TABS = [
|
||||
{ k: "highRisk", name: "高风险场景" },
|
||||
{ k: "history", name: "历史分析检索" },
|
||||
{ k: "tpl", name: "方法模板库" },
|
||||
];
|
||||
const RISK_RANGES = ["近一个月", "近三个月", "近半年", "全部"];
|
||||
const departments = ref<any[]>([
|
||||
{ id: 1, name: "全部部门" },
|
||||
{ id: 2, name: "一车间" },
|
||||
{ id: 3, name: "公用工程车间" },
|
||||
{ id: 4, name: "供应部" },
|
||||
{ id: 5, name: "三车间" },
|
||||
]);
|
||||
const list = ref<any[]>([]);
|
||||
const highRiskList = [
|
||||
{
|
||||
cid: "MOC-2026-R01-0036", item: "反应飞温", scene: "温度上限提高至 82℃ 后飞温反应加速,联锁动作前安全裕量收窄",
|
||||
method: "HAZOP", residual: "中", dept: "一车间", adate: "2026-08-02", owner: "王海燕",
|
||||
title: '聚合反应温度控制上限调整(78℃→82℃)', status: '审批中',
|
||||
info: 'MOC-2026-R01-0036 聚合反应温度控制上限调整(78℃→82℃)', count: 3,
|
||||
hazopRows: [
|
||||
{ node: "节点1 · 聚合釜 R-101", dev: "温度过高", cause: "温控回路失效或冷却水中断;82℃ 新上限更接近溶剂回流温度,超温溜温概率上升", cons: "反应溜温冲料,釜压骤升,物料喷溅造成灼烫与环境污染", L: 3, S: 4, safety: "DCS 温度高报 82℃、高高报及联锁 85℃(维持安全边界不变);紧急冷却有效", suggest: "投用前完成联锁测试并留存记录;投用首周每班复核温度趋势" },
|
||||
{ node: "节点1 · 聚合釜 R-101", dev: "温度过高", cause: "未知杂质含量升高(0.05%→0.12%),热稳定性尚未定性,长期累积加速釜壁结垢", cons: "副反应加剧,可能引发二次分解放热,搅拌负荷异常", L: 2, S: 4, safety: "小试热稳定性筛查;安全阀泄放能力不受本次调整影响,仍然有效", suggest: "将杂质含量纳入日常分析计划(每周 1 次,连续 8 周)" },
|
||||
{ node: "节点2 · DCS 温控与联锁", dev: "报警/联锁值设置不当", cause: "上限调整后报警值与联锁值未同步复核整定", cons: "报警提前量不足,操作响应时间被压缩", L: 2, S: 4, safety: "修改执行申请-复核双人确认;修改清单与变更单逐项核对", suggest: "联锁测试全覆盖并留存记录;修改期间工艺加强监控" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cid: "MOC-2026-R01-0035", item: "动火与临时用电燃爆", scene: "电机更换动火与临时用电作业引发可燃气体燃爆",
|
||||
method: "JSA", residual: "中", dept: "一车间", adate: "2026-07-30", owner: "王仪表",
|
||||
title: '反应釜搅拌器电机更换(55kW→75kW 防爆电机)', status: '审批中',
|
||||
info: 'MOC-2026-R01-0035 R-201 反应釜搅拌器电机更换(55kW→75kW 防爆电机)', count: 3,
|
||||
jsaRows: [
|
||||
{ item: "动火作业燃爆", scene: "步骤2:旧电机拆除与新电机安装动火,可燃蒸气聚集遇火源燃爆", inherent: "高", existing: "作业票证办理;动火点可燃气体检测合格;消防器材到位", suggest: "动火点下方设接火盘与防火毯,专人监护并每 30 分钟复测", residual: "中" },
|
||||
{ item: "临时用电触电", scene: "步骤1:临时电缆敷设与配电箱接线,绝缘破损导致触电", inherent: "中", existing: "临时用电票证;漏保校验合格", suggest: "电缆架空敷设并挂牌,每日开工前绝缘检查", residual: "低" },
|
||||
{ item: "起重吊装伤害", scene: "步骤3:75kW 电机吊装就位,吊具失效或指挥失误导致起重伤害", inherent: "中", existing: "吊具检验合格;起重指挥持证", suggest: "吊装区设警戒线,禁止交叉作业", residual: "低" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cid: null, changeTitle: "MOC-2026-R01-0048 R-101 温度联锁修改", item: "VOCs 直排超标", scene: "活性炭吸附饱和穿透,VOCs 直排大气超标",
|
||||
method: "JSA", residual: "高", dept: "一车间", adate: "2026-07-31", owner: "王仪表",
|
||||
title: '温度联锁修改', status: '审批中',
|
||||
info: 'MOC-2026-R01-0048 R-101 温度联锁修改', count: 2,
|
||||
jsaRows: [
|
||||
{ item: "吸附饱和穿透", scene: "步骤3:活性炭吸附床运行后期,吸附饱和穿透导致 VOCs 直排大气", inherent: "高", existing: "出口 VOCs 在线监测报警;按周期更换活性炭", suggest: "增设穿透点压差监测与双床切换程序;更换周期由 6 个月缩短至 4 个月", residual: "中" },
|
||||
{ item: "联锁参数误改", scene: "步骤3:DCS 参数修改与联锁测试,参数误改导致报警/联锁失效", inherent: "高", existing: "参数修改执行申请-复核双人确认;修改清单与变更单逐项核对", suggest: "联锁测试全覆盖并留存记录;修改期间工艺加强监控", residual: "中" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cid: "MOC-2026-R02-0041", item: "超压与窒息", scene: "氮气减压阀组失效导致下游设备超压、作业人员窒息",
|
||||
method: "检查表", residual: "低", dept: "公用工程车间", adate: "2026-07-29", owner: "周设备",
|
||||
title: '公用工程氮气减压阀组改造', status: '审批中',
|
||||
info: 'MOC-2026-R02-0041 公用工程氮气减压阀组改造', count: 2,
|
||||
jsaRows: [
|
||||
{ item: "下游设备超压", scene: "减压阀失效全开,高压氮气直入低压管网导致下游设备超压", inherent: "高", existing: "下游设安全阀;管网压力高报", suggest: "减压阀双阀串联并定期校验;安全阀起跳压力复核", residual: "低" },
|
||||
{ item: "人员窒息", scene: "阀组间通风不良,氮气泄漏积聚导致氧含量不足", inherent: "中", existing: "阀组间强制通风;进入前测氧", suggest: "增设固定式氧含量报警仪并联动风机", residual: "低" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cid: "MOC-2026-R01-0039", item: "超压保护裕量降低", scene: "安全阀起跳压力临时上调期间,V-102 超压保护裕量降低",
|
||||
method: "AI 预分析", residual: "中", dept: "一车间", adate: "2026-07-15", owner: "王海燕",
|
||||
title: '紧急变更:V-102 安全阀起跳压力临时调整(补办手续)', status: '审批中',
|
||||
info: 'MOC-2026-R01-0039 紧急变更:V-102 安全阀起跳压力临时调整(补办手续)', count: 2,
|
||||
jsaRows: [
|
||||
{ item: "超压保护裕量降低", scene: "起跳压力由 1.0MPa 临时上调至 1.15MPa,V-102 超压工况下保护动作延迟", inherent: "高", existing: "临时期限 30 天;操作压力监控加强至每 2 小时记录", suggest: "恢复前完成新安全阀校验更换;临时期间压力联锁值同步下调验证", residual: "中" },
|
||||
{ item: "紧急变更手续补办遗漏", scene: "紧急变更先行实施,后补手续存在资料缺项风险", inherent: "中", existing: "紧急变更 48 小时内补办审批", suggest: "资料归档清单逐项核对,安全部门复核", residual: "低" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cid: "MOC-2026-R03-0040", item: "原料杂质副反应", scene: "新供应商环己烷杂质谱变化引发副反应,影响产品色相与收率",
|
||||
method: "HAZOP", residual: "中", dept: "供应部", adate: "2026-07-18", owner: "陈质量",
|
||||
title: '原料环己烷供应商变更(新增 B 供应商)', status: '审批中',
|
||||
info: 'MOC-2026-R03-0040 原料环己烷供应商变更(新增 B 供应商)', count: 2,
|
||||
hazopRows: [
|
||||
{ node: "节点1 · 原料接收与储存", dev: "杂质含量高", cause: "B 供应商环己烷杂质谱(苯/硫)高于原供应商,进厂检验项目未覆盖", cons: "副反应加剧,产品色相超标、收率下降,结垢加速", L: 3, S: 3, safety: "进厂检验按原指标执行;供应商 COA 随货同行", suggest: "检验项目增加杂质谱分析;首批三批加严检验" },
|
||||
{ node: "节点2 · 聚合反应", dev: "副反应加剧", cause: "微量苯参与链转移,分子量分布变宽", cons: "产品性能波动,下游加工投诉", L: 2, S: 3, safety: "聚合配方留有调节余量", suggest: "建立 B 供应商原料的配方微调作业指导" },
|
||||
],
|
||||
},
|
||||
{
|
||||
cid: "MOC-2026-R03-0042", item: "回路切换失误", scene: "自动化改造调试期间控制回路切换失误,造成装置非计划停车",
|
||||
method: "JSA", residual: "中", dept: "三车间", adate: "2026-07-31", owner: "王仪表",
|
||||
title: '三车间自动化和安全隐患整改项目变更', status: '审批中',
|
||||
info: 'MOC-2026-R03-0042 三车间自动化和安全隐患整改项目变更', count: 2,
|
||||
jsaRows: [
|
||||
{ item: "回路切换失误", scene: "步骤4:新旧控制系统切换,回路误切导致装置非计划停车", inherent: "高", existing: "切换方案审批;切换前模拟演练", suggest: "切换双人复核逐项签字;保留一键回切手段 72 小时", residual: "中" },
|
||||
{ item: "静电损伤卡件", scene: "步骤2:卡件插拔作业静电防护不到位,DCS 卡件损坏", inherent: "中", existing: "佩戴防静电手环", suggest: "卡件备件现场备妥,作业区铺设防静电垫", residual: "低" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const historyList = [
|
||||
{ id: "HAZOP-2025-018", change: "MOC-2025-R01-0023 常压炉燃料气切断阀改造", method: "HAZOP", unit: "01-常减压装置", scope: "燃料气系统节点", rows: 12, date: "2025-10-20" },
|
||||
{ id: "HAZOP-2024-006", change: "MOC-2024-R01-0003 减一线换热器 E-108 芯子更换", method: "HAZOP", unit: "01-常减压装置", scope: "换热系统节点", rows: 8, date: "2024-03-15" },
|
||||
{ id: "JSA-2025-041", change: "MOC-2025-R02-0014 再生器旋风分离器更换", method: "JSA", unit: "02-催化裂化装置", scope: "受限空间作业", rows: 6, date: "2025-05-08" },
|
||||
{ id: "JSA-2026-012", change: "MOC-2026-R01-0035 搅拌电机更换", method: "JSA", unit: "01-常减压装置", scope: "检维修作业", rows: 5, date: "2026-07-29" },
|
||||
{ id: "SCL-2025-033", change: "MOC-2025-R02-0021 气压机出口压力联锁逻辑修改", method: "检查表", unit: "02-催化裂化装置", scope: "联锁系统", rows: 14, date: "2025-09-18" },
|
||||
{ id: "AI-2026-008", change: "MOC-2026-R03-0008 聚合釜搅拌器密封形式变更", method: "AI 预分析", unit: "03-聚合装置", scope: "—", rows: 4, date: "2026-05-06" },
|
||||
];
|
||||
const templateList = [
|
||||
{ name: "HAZOP 节点划分模板", desc: "含偏差引导词库(过高/过低/无/反向等 18 个)与节点划分示例", tag: "HAZOP" },
|
||||
{ name: "JSA 作业步骤分析模板", desc: "作业步骤分解 → 危害识别 → 控制措施三段式,含检维修示例", tag: "JSA" },
|
||||
{ name: "安全检查表(SCL)模板库", desc: "按设备类型 28 套(机泵 / 换热器 / 塔器 / 储罐 / 电气 / 仪表)", tag: "检查表" },
|
||||
{ name: "风险矩阵 5×5 分级定义", desc: "可能性 × 严重性分级标准(公司 Q/SH 0600),AI 评级引用此定义", tag: "通用" },
|
||||
];
|
||||
const isHazop = computed(() => !!currentRecord.value?.hazopRows);
|
||||
const th = "border-b bg-slate-50 px-3 py-2 text-left text-[11px] font-semibold text-slate-500";
|
||||
const td = "border-b px-3 py-2 align-top text-xs leading-relaxed text-slate-700";
|
||||
|
||||
const hazopNodes = computed(() => {
|
||||
const scene = currentRecord.value;
|
||||
if (!scene?.hazopRows) return [] as { node: string; dev: string; n: number }[];
|
||||
const nodes = Array.from(new Map(scene.hazopRows.map((r:any) => [`${r.node}|${r.dev}`, { node: r.node, dev: r.dev, n: 0 }])).values());
|
||||
scene.hazopRows.forEach((r:any) => { const k:any = nodes.find((n:any) => n.node === r.node && n.dev === r.dev); if (k) k.n += 1; });
|
||||
return nodes;
|
||||
});
|
||||
const sel = ref(0);
|
||||
const selRows = computed(() => {
|
||||
const scene = currentRecord.value;
|
||||
if (!scene?.hazopRows) return [] as any;
|
||||
const n:any = hazopNodes.value[sel.value];
|
||||
return scene.hazopRows.filter((r:any) => r.node === n?.node && r.dev === n?.dev);
|
||||
});
|
||||
// HAZOP 风险矩阵:RR = L × S
|
||||
const rrTag = (L: number, S: number) => {
|
||||
const v = L * S;
|
||||
return v >= 12 ? { label: `较大 (${v})`, cls: "error" }
|
||||
: v >= 8 ? { label: `一般 (${v})`, cls: "warning" }
|
||||
: { label: `低 (${v})`, cls: "success" };
|
||||
};
|
||||
|
||||
// 切换tab
|
||||
const tabChange = (k: string) => {
|
||||
list.value = []
|
||||
currentTab.value = k;
|
||||
loading.value = true;
|
||||
if(k === 'highRisk'){
|
||||
setTimeout(() => {
|
||||
list.value = highRiskList;
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
if(k === 'history'){
|
||||
setTimeout(() => {
|
||||
list.value = historyList;
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
if(k === 'tpl'){
|
||||
setTimeout(() => {
|
||||
list.value = templateList;
|
||||
loading.value = false;
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 高风险场景查看变更
|
||||
const openSceneChange = (record: any) => {
|
||||
routerPushByKey('home_highriskdetail', { query: { params: JSON.stringify(record) } })
|
||||
};
|
||||
// 查看分析记录
|
||||
const viewAnalysis = (record: any) => {
|
||||
currentRecord.value = record;
|
||||
progressDrawer.value = true;
|
||||
};
|
||||
// 查看历史分析检索
|
||||
const viewHistory = (id: string) => {
|
||||
window.$message?.info(`演示:在线查看 ${id} 完整分析记录`)
|
||||
};
|
||||
// 复用到新变更
|
||||
const copyHistory = (id: string) => {
|
||||
window.$message?.success(`演示:${id} 已复用到新变更草稿,分析行已带入待确认`)
|
||||
}
|
||||
// 预览模板
|
||||
const previewTemplate = (name: string) => {
|
||||
window.$message?.info(`演示:在线预览「${name}」`)
|
||||
}
|
||||
// 下载模板
|
||||
const downloadTemplate = (name: string) => {
|
||||
window.$message?.success(`演示:下载「${name}」(Word)`)
|
||||
}
|
||||
// 导出
|
||||
const exportFile = () => {
|
||||
window.$message?.success('分析记录表已导出(Excel)');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tabChange('highRisk');
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<!-- 本专业高风险 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 border border-slate-200 px-3 py-2 mb-3"
|
||||
:style="{ borderRadius: themeStore.themeRadius + 'px' }"
|
||||
>
|
||||
<div v-for="t in TABS" :key="t.k" @click="tabChange(t.k)"
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-xs cursor-pointer select-none rounded-[20px] border"
|
||||
:style="{ '--theme-color': themeStore.themeColor}"
|
||||
:class="currentTab === t.k ? 'bg-[var(--theme-color)] font-medium text-white border-[var(--theme-color)]' : 'text-slate-600 hover:bg-slate-100'"
|
||||
>
|
||||
{{ t.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 高风险场景 -->
|
||||
<div v-if="currentTab === 'highRisk'" class="border border-slate-200" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:shield-check" class="size-16px" :style="{color:themeStore.themeColor}" />
|
||||
<p class="text-base font-semibold">变更新增高风险场景</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 min-h-28px">
|
||||
<NTag size="small" type="primary" class="text-11px font-medium">单笔变更的分析编制在变更上下文内完成</NTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<span class="mr-1 text-xs text-slate-400">时间</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
round
|
||||
:type="range === r ? 'primary' : 'default'"
|
||||
v-for="r in RISK_RANGES"
|
||||
:key="r"
|
||||
@click="range = r"
|
||||
>
|
||||
{{ r }}
|
||||
</NButton>
|
||||
<span class="ml-3 mr-1 text-xs text-slate-400">部门</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
round
|
||||
v-for="d in departments"
|
||||
:key="d.id"
|
||||
:type="dept === d.name ? 'primary' : 'default'"
|
||||
@click="dept = d.name"
|
||||
>
|
||||
{{ d.name }}
|
||||
</NButton>
|
||||
<span class="ml-auto self-center text-[11px] text-slate-400">{{ list.length }} 个高风险场景</span>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="space-y-3 px-4 pb-3">
|
||||
<div v-for="(s, i) in list" :key="`${s.cid ?? 'x'}-${i}`" class="flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<NTag type="info" size="small">{{ s.method }}</NTag>
|
||||
<span class="text-sm font-medium text-slate-800">{{ s.item }}</span>
|
||||
<NTag type="error" size="small">固有风险 · 高</NTag>
|
||||
<NTag v-if="s.residual==='高'" type="error" size="small">剩余风险 · {{ s.residual }}</NTag>
|
||||
<NTag v-if="s.residual==='中'" type="warning" size="small">剩余风险 · {{ s.residual }}</NTag>
|
||||
<NTag v-if="s.residual==='低'" type="info" size="small">剩余风险 · {{ s.residual }}</NTag>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-slate-600">{{ s.scene }}</div>
|
||||
<div class="mt-0.5 text-xs text-slate-400">
|
||||
变更:{{ s.info }} · {{ s.dept }} · 分析日期 {{ s.adate }} · 责任:{{ s.owner }} · 分析记录 {{ s.count }} 行
|
||||
</div>
|
||||
</div>
|
||||
<NButton ghost size="small" class="text-xs" @click="openSceneChange(s)">查看变更</NButton>
|
||||
<NButton size="small" type="primary" class="text-xs" @click="viewAnalysis(s)">查看分析记录</NButton>
|
||||
</div>
|
||||
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">当前筛选条件下无新增高风险场景</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 历史分析检索 -->
|
||||
<div v-if="currentTab === 'history'" class="border border-slate-200" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="mage:book" class="size-18px" :style="{color:themeStore.themeColor}" />
|
||||
<p class="text-base font-semibold">历史分析记录检索(企业风险知识资产)</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 min-h-28px">
|
||||
<NInput v-model:value="keyword" size="small" class="text-xs !w-200px" placeholder="按单号 / 装置 / 关键词检索…">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<NButton
|
||||
size="tiny"
|
||||
round
|
||||
v-for="m in ['全部', 'HAZOP', 'JSA', '检查表', 'AI 预分析']"
|
||||
:key="m"
|
||||
:type="method === m ? 'primary' : 'default'"
|
||||
@click="method = m"
|
||||
>
|
||||
{{ m }}
|
||||
</NButton>
|
||||
<span class="ml-auto self-center text-[11px] text-slate-400">{{ list.length }} 条记录</span>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="space-y-3 px-4 pb-3">
|
||||
<div v-for="h in list" :key="h.id" class="flex flex-wrap items-center gap-3 rounded-lg border px-4 py-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-[11px] text-slate-400">{{ h.id }}</span>
|
||||
<NTag type="info" size="small">{{ h.method }}</NTag>
|
||||
<span class="text-sm text-slate-800">{{ h.change }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-slate-500">{{ h.unit }} · 范围:{{ h.scope }} · {{ h.rows }} 行分析记录 · {{ h.date }}</div>
|
||||
</div>
|
||||
<NButton text type="primary" @click="viewHistory(h.id)">
|
||||
<Icon icon="lucide:eye" class="size-16px" />
|
||||
</NButton>
|
||||
<NButton ghost size="small" class="text-xs" @click="copyHistory(h.id)">复用到新变更</NButton>
|
||||
</div>
|
||||
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配的分析记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 方法模板库 -->
|
||||
<div v-if="currentTab === 'tpl'" class="border border-slate-200" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="akar-icons:file" class="size-16px" :style="{color:themeStore.themeColor}" />
|
||||
<p class="text-base font-semibold">方法模板库(AI 风险预分析的知识来源之一)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="space-y-3 px-4 pb-3">
|
||||
<div class="grid gap-3 grid-cols-2">
|
||||
<div v-for="t in list" :key="t.name" class="rounded-lg border p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-slate-800">{{ t.name }}</span>
|
||||
<NTag size="small" type="info">{{ t.tag }}</NTag>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs leading-relaxed text-slate-500">{{ t.desc }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<NButton ghost size="small" class="text-xs" @click="previewTemplate(t.name)">查看</NButton>
|
||||
<NButton ghost size="small" class="text-xs" @click="downloadTemplate(t.name)">
|
||||
<Icon icon="material-symbols:download" class="size-14px" />下载
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="list.length === 0" class="py-6 text-center text-xs text-slate-400">未找到匹配的方法模板库</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 进度弹框 -->
|
||||
<NDrawer v-model:show="progressDrawer" width="85%" placement="right">
|
||||
<NDrawerContent closable>
|
||||
<template #header>
|
||||
<span class="flex flex-wrap items-center gap-2 text-base">
|
||||
风险分析记录表
|
||||
<NTag size="small" type="info">{{ currentRecord.method }}</NTag>
|
||||
<NTag size="small" type="error">含固有风险 · 高 场景</NTag>
|
||||
</span>
|
||||
</template>
|
||||
<div>
|
||||
<div class="mb-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-slate-500">
|
||||
<span>变更:<span class="font-medium text-slate-700">{{ currentRecord.info }}</span></span>
|
||||
<span>分析日期:{{ currentRecord.adate }}</span>
|
||||
<span>责任:{{ currentRecord.owner }}</span>
|
||||
<span class="ml-auto text-slate-400">分析编制与修改在变更上下文内完成,此处只读</span>
|
||||
</div>
|
||||
<div v-if="!isHazop" class="overflow-x-auto rounded-lg border border-b-0">
|
||||
<table class="w-full min-w-[860px] border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :class="th" class="w-[130px]">风险项</th>
|
||||
<th :class="th">场景描述</th>
|
||||
<th :class="th" class="w-[76px] text-center">固有风险</th>
|
||||
<th :class="th">现有措施</th>
|
||||
<th :class="th">建议措施</th>
|
||||
<th :class="th" class="w-[76px] text-center">剩余风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in currentRecord.jsaRows" :key="i" :class="r.item === currentRecord.item && r.inherent === '高' ? 'bg-red-50/40' : ''">
|
||||
<td :class="td" class="font-medium text-slate-800">{{ r.item }}</td>
|
||||
<td :class="td">{{ r.scene }}</td>
|
||||
<td :class="td" class="text-center">
|
||||
<NTag size="small" type="error" v-if="r.inherent==='高'">{{ r.inherent }}</NTag>
|
||||
<NTag size="small" type="warning" v-if="r.inherent==='中'">{{ r.inherent }}</NTag>
|
||||
<NTag size="small" type="info" v-if="r.inherent==='低'">{{ r.inherent }}</NTag>
|
||||
</td>
|
||||
<td :class="td">{{ r.existing }}</td>
|
||||
<td :class="td">{{ r.suggest }}</td>
|
||||
<td :class="td" class="text-center">
|
||||
<NTag size="small" type="error" v-if="r.residual==='高'">{{ r.residual }}</NTag>
|
||||
<NTag size="small" type="warning" v-if="r.residual==='中'">{{ r.residual }}</NTag>
|
||||
<NTag size="small" type="info" v-if="r.residual==='低'">{{ r.residual }}</NTag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="flex gap-3">
|
||||
<!-- 节点 · 偏离导航 -->
|
||||
<div class="w-48 shrink-0 space-y-1.5">
|
||||
<div v-for="(n, i) in hazopNodes" :key="i" @click="sel = i"
|
||||
class="w-full rounded-lg border px-3 py-2 text-left text-xs cursor-pointer"
|
||||
:class="sel === i ? 'border-[var(--theme-color)] bg-[var(--theme-color)] text-white' : 'hover:border-[var(--theme-color)]'"
|
||||
:style="{'--theme-color': themeStore.themeColor}"
|
||||
>
|
||||
<span class="block truncate font-medium">{{ n.dev }}</span>
|
||||
<span class="mt-0.5 flex items-center justify-between text-[11px]" :class="sel === i ? 'text-white/80' : 'text-slate-400'">
|
||||
<span class="truncate">{{ n.node.replace(/^节点\d+ · /, '') }}</span>
|
||||
<NTag size="tiny" round class="ml-1" :type="sel === i ? 'default' : 'success'">{{ n.n }} 条</NTag>
|
||||
</span>
|
||||
</div>
|
||||
<p class="px-1 pt-1 text-[11px] leading-relaxed text-slate-400">L / S 取值 1–5,RR 按 5×5 风险矩阵实时重算</p>
|
||||
</div>
|
||||
<!-- 偏差分析表 -->
|
||||
<div class="min-w-0 flex-1 overflow-x-auto rounded-lg border border-b-0">
|
||||
<table class="w-full min-w-[760px] border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :class="th">原因</th>
|
||||
<th :class="th">后果</th>
|
||||
<th :class="th" class="w-10 !text-center">L</th>
|
||||
<th :class="th" class="w-10 !text-center">S</th>
|
||||
<th :class="th" class="w-[92px] !text-center">RR</th>
|
||||
<th :class="th">安全措施</th>
|
||||
<th :class="th">建议措施</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in selRows" :key="i">
|
||||
<td :class="td">{{ r.cause }}</td>
|
||||
<td :class="td">{{ r.cons }}</td>
|
||||
<td :class="td" class="text-center">{{ r.L }}</td>
|
||||
<td :class="td" class="text-center">{{ r.S }}</td>
|
||||
<td :class="td" class="text-center">
|
||||
<NTag size="small" :type="rrTag(r.L, r.S).cls">{{ rrTag(r.L, r.S).label }}</NTag>
|
||||
</td>
|
||||
<td :class="td">{{ r.safety }}</td>
|
||||
<td :class="td">{{ r.suggest }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton ghost @click="exportFile"><Icon icon="material-symbols:download" class="size-16px" />导出</NButton>
|
||||
<NButton type="primary" @click="progressDrawer = false">关闭</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NCard>
|
||||
</NSpin>
|
||||
<NCard :bordered="false" size="small" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<Risk />
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 303px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { analysisRecordListApi } from "@/service/api/highRisk";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps(['currentId', 'info', 'fmeaRiskMatrixConfig'])
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 当前偏离
|
||||
const currentDev = ref<any>(null);
|
||||
// 展开收起
|
||||
const collapsed = ref<Record<string, boolean>>({})
|
||||
// 节点列表
|
||||
const nodes = ref<any>([]);
|
||||
|
||||
// 点击偏离
|
||||
const clickDev = (dev:any) => {
|
||||
currentDev.value = dev
|
||||
}
|
||||
|
||||
// 根据l和s计算RR风险等级
|
||||
const riskLevel = (l: number, s: number) => {
|
||||
const num = l * s;
|
||||
for (let i = 0; i < props.fmeaRiskMatrixConfig?.risk_grades.length; i++) {
|
||||
const item = props.fmeaRiskMatrixConfig?.risk_grades[i];
|
||||
if (num >= item.min && num <= item.max) {
|
||||
return item; // 返回整条数据
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 计算RR1风险等级
|
||||
const countRiskLevel = (row: any) => {
|
||||
const l = row.l_value;
|
||||
const s = row.s_value;
|
||||
const newArr = [...row?.safeguards || [],...row?.suggestions || []];
|
||||
const result:any = { L: 0, S: 0 };
|
||||
newArr.forEach(item => {
|
||||
if (!item.value) return; // 跳过空值
|
||||
const parts = item.value.split('_');
|
||||
if (parts.length === 2) {
|
||||
const type = parts[0].toUpperCase(); // 统一大写
|
||||
const num = parseInt(parts[1], 10);
|
||||
if (!isNaN(num) && (type === 'L' || type === 'S')) {
|
||||
result[type] += num;
|
||||
}
|
||||
}
|
||||
});
|
||||
const newL = (l - result.L) > 0 ? l - result.L : 1;
|
||||
const newS = (s - result.S) > 0 ? s - result.S : 1;
|
||||
return riskLevel(newL, newS);
|
||||
}
|
||||
// 展开收起
|
||||
const leftToggle = (id: string) => {
|
||||
collapsed.value = { ...collapsed.value, [id]: !collapsed.value[id] }
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
window.$message?.info('还差接口')
|
||||
}
|
||||
|
||||
// 获取记录
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await analysisRecordListApi(props.currentId);
|
||||
if(!error){
|
||||
nodes.value = data?.fmea?.nodes || [];
|
||||
clickDev(nodes.value[0]?.deviations[0] || null)
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<p v-if="props.info?.change_title" class="text-xs text-slate-500">变更:<span class="text-slate-800">{{ props.info?.change_title }}</span></p>
|
||||
<p v-if="props.info?.created_at" class="text-xs text-slate-500">分析日期:{{ props.info?.created_at?.slice(0,10) }}</p>
|
||||
<p v-if="props.info?.applicant_name" class="text-xs text-slate-500">责任人:{{ props.info?.applicant_name }}</p>
|
||||
<p class="ml-auto text-xs text-slate-500">分析编制与修改在变更上下文内完成,此处只读</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--success-color': themeStore.otherColor.success
|
||||
}"
|
||||
>
|
||||
<!-- 左:节点 / 偏离树 -->
|
||||
<div class="w-240px">
|
||||
<div class="shrink-0 rounded-md border p-2 scroll">
|
||||
<p v-if="nodes.length === 0" class="text-center text-xs text-slate-400 py-10">暂无数据</p>
|
||||
<div v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
|
||||
<div
|
||||
class="cursor-pointer flex items-center select-none"
|
||||
@click="leftToggle(node.id)"
|
||||
>
|
||||
<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>
|
||||
<template v-if="!collapsed[node.id]">
|
||||
<div v-for="(dev, devIndex) in node.deviations" :key="devIndex"
|
||||
@click="clickDev(dev)"
|
||||
class="cursor-pointer flex items-center ml-4 gap-1.5 rounded-md px-2 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>
|
||||
<span class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600">
|
||||
{{ dev.rows.length }}条
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右:FMEA 记录表 -->
|
||||
<div class="min-w-0 flex-1 bg-white">
|
||||
<div class="scroll">
|
||||
<NTable size="small" class="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-60">原因</th>
|
||||
<th class="w-60">后果</th>
|
||||
<th class="w-6">L</th>
|
||||
<th class="w-6">S</th>
|
||||
<th class="w-20">RR</th>
|
||||
<th class="w-60">安全措施</th>
|
||||
<th class="w-60">建议措施</th>
|
||||
<th class="w-20">RR1</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!currentDev || currentDev?.rows?.length === 0">
|
||||
<td colspan="9" class="px-3 py-8 text-center text-xs text-slate-400">
|
||||
当前暂无分析记录
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(row, ri) in currentDev?.rows" :key="ri">
|
||||
<td class="align-top">
|
||||
<p class="text-xs">{{row.cause}}</p>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<p class="text-xs">{{row.consequence}}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-xs text-center w-10">{{row.l_value}}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-xs text-center w-10">{{row.s_value}}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<NTag
|
||||
v-if="row.l_value>0 && row.s_value>0"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
:color="{
|
||||
color: riskLevel(row.l_value, row.s_value)?.bg_color,
|
||||
textColor: riskLevel(row.l_value, row.s_value)?.font_color,
|
||||
borderColor: riskLevel(row.l_value, row.s_value)?.bg_color,
|
||||
}"
|
||||
>
|
||||
{{riskLevel(row.l_value, row.s_value)?.name}}({{row.l_value * row.s_value}})
|
||||
</NTag>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.safeguards && row.safeguards.length" class="flex flex-col gap-3">
|
||||
<div v-for="(sItem, si) in row.safeguards" :key="si">
|
||||
<p class="text-xs">{{Number(si)+1}}、{{sItem.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.suggestions && row.suggestions.length" class="flex flex-col gap-2">
|
||||
<div v-for="(sItem2, si2) in row.suggestions" :key="si2">
|
||||
<p class="text-xs">{{Number(si2)+1}}、{{sItem2.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<NTag
|
||||
v-if="row.l_value>0 && row.s_value>0"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
:color="{
|
||||
color: countRiskLevel(row)?.bg_color,
|
||||
textColor: countRiskLevel(row)?.font_color,
|
||||
borderColor: countRiskLevel(row)?.bg_color,
|
||||
}"
|
||||
>
|
||||
{{countRiskLevel(row)?.name}}
|
||||
</NTag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 pt-3">
|
||||
<NButton size="small" @click="exportData">
|
||||
<Icon icon="material-symbols:download" class="mr-1 size-14px" />
|
||||
导出
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="emit('close')">关闭</NButton>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 155px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { analysisRecordListApi } from "@/service/api/highRisk";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps(['currentId', 'info', 'hazopRiskMatrixConfig'])
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 当前偏离
|
||||
const currentDev = ref<any>(null);
|
||||
// 展开收起
|
||||
const collapsed = ref<Record<string, boolean>>({})
|
||||
// 节点列表
|
||||
const nodes = ref<any>([]);
|
||||
|
||||
// 点击偏离
|
||||
const clickDev = (dev:any) => {
|
||||
currentDev.value = dev
|
||||
}
|
||||
|
||||
// 根据l和s计算RR风险等级
|
||||
const riskLevel = (l: number, s: number) => {
|
||||
const num = l * s;
|
||||
for (let i = 0; i < props.hazopRiskMatrixConfig?.risk_grades.length; i++) {
|
||||
const item = props.hazopRiskMatrixConfig?.risk_grades[i];
|
||||
if (num >= item.min && num <= item.max) {
|
||||
return item; // 返回整条数据
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 计算RR1风险等级
|
||||
const countRiskLevel = (row: any) => {
|
||||
const l = row.l_value;
|
||||
const s = row.s_value;
|
||||
const newArr = [...row?.safeguards || [],...row?.suggestions || []];
|
||||
const result:any = { L: 0, S: 0 };
|
||||
newArr.forEach(item => {
|
||||
if (!item.value) return; // 跳过空值
|
||||
const parts = item.value.split('_');
|
||||
if (parts.length === 2) {
|
||||
const type = parts[0].toUpperCase(); // 统一大写
|
||||
const num = parseInt(parts[1], 10);
|
||||
if (!isNaN(num) && (type === 'L' || type === 'S')) {
|
||||
result[type] += num;
|
||||
}
|
||||
}
|
||||
});
|
||||
const newL = (l - result.L) > 0 ? l - result.L : 1;
|
||||
const newS = (s - result.S) > 0 ? s - result.S : 1;
|
||||
return riskLevel(newL, newS);
|
||||
}
|
||||
// 展开收起
|
||||
const leftToggle = (id: string) => {
|
||||
collapsed.value = { ...collapsed.value, [id]: !collapsed.value[id] }
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
window.$message?.info('还差接口')
|
||||
}
|
||||
|
||||
// 获取记录
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await analysisRecordListApi(props.currentId);
|
||||
if(!error){
|
||||
nodes.value = data?.hazop?.nodes || [];
|
||||
clickDev(nodes.value[0]?.objects[0]?.deviations[0] || null)
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<p v-if="props.info?.change_title" class="text-xs text-slate-500">变更:<span class="text-slate-800">{{ props.info?.change_title }}</span></p>
|
||||
<p v-if="props.info?.created_at" class="text-xs text-slate-500">分析日期:{{ props.info?.created_at?.slice(0,10) }}</p>
|
||||
<p v-if="props.info?.applicant_name" class="text-xs text-slate-500">责任人:{{ props.info?.applicant_name }}</p>
|
||||
<p class="ml-auto text-xs text-slate-500">分析编制与修改在变更上下文内完成,此处只读</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3"
|
||||
:style="{
|
||||
'--theme-color': themeStore.themeColor,
|
||||
'--success-color': themeStore.otherColor.success
|
||||
}"
|
||||
>
|
||||
<!-- 左:节点 / 偏离树 -->
|
||||
<div class="w-240px">
|
||||
<div class="shrink-0 rounded-md border p-2 scroll">
|
||||
<p v-if="nodes.length === 0" class="text-center text-xs text-slate-400 py-10">暂无节点数据</p>
|
||||
<div v-for="(node, nodeIndex) in nodes" :key="nodeIndex">
|
||||
<div
|
||||
class="cursor-pointer flex items-center select-none"
|
||||
@click="leftToggle(node.id)"
|
||||
>
|
||||
<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>
|
||||
<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)"
|
||||
>
|
||||
<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-700">{{ obj.obj_name }}</p>
|
||||
</div>
|
||||
<template v-if="!collapsed[node.id+'_'+obj.id]">
|
||||
<div v-for="(dev, devIndex) in obj.deviations" :key="devIndex"
|
||||
@click="clickDev(dev)"
|
||||
class="cursor-pointer flex items-center ml-4 gap-1.5 rounded-md px-2 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>
|
||||
<span class="rounded-full bg-emerald-100 px-1.5 text-[10px] text-emerald-600">
|
||||
{{ dev.rows.length }}条
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右:HAZOP 记录表 -->
|
||||
<div class="min-w-0 flex-1 bg-white">
|
||||
<div class="scroll">
|
||||
<NTable size="small" class="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-60">原因</th>
|
||||
<th class="w-60">后果</th>
|
||||
<th class="w-6">L</th>
|
||||
<th class="w-6">S</th>
|
||||
<th class="w-20">RR</th>
|
||||
<th class="w-60">安全措施</th>
|
||||
<th class="w-60">建议措施</th>
|
||||
<th class="w-20">RR1</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!currentDev || currentDev?.rows?.length === 0">
|
||||
<td colspan="9" class="px-3 py-8 text-center text-xs text-slate-400">
|
||||
当前暂无分析记录
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(row, ri) in currentDev?.rows" :key="ri">
|
||||
<td class="align-top">
|
||||
<p class="text-xs">{{row.cause}}</p>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<p class="text-xs">{{row.consequence}}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-xs text-center w-10">{{row.l_value}}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-xs text-center w-10">{{row.s_value}}</p>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<NTag
|
||||
v-if="row.l_value>0 && row.s_value>0"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
:color="{
|
||||
color: riskLevel(row.l_value, row.s_value)?.bg_color,
|
||||
textColor: riskLevel(row.l_value, row.s_value)?.font_color,
|
||||
borderColor: riskLevel(row.l_value, row.s_value)?.bg_color,
|
||||
}"
|
||||
>
|
||||
{{riskLevel(row.l_value, row.s_value)?.name}}({{row.l_value * row.s_value}})
|
||||
</NTag>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.safeguards && row.safeguards.length" class="flex flex-col gap-3">
|
||||
<div v-for="(sItem, si) in row.safeguards" :key="si">
|
||||
<p class="text-xs">{{Number(si)+1}}、{{sItem.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.suggestions && row.suggestions.length" class="flex flex-col gap-2">
|
||||
<div v-for="(sItem2, si2) in row.suggestions" :key="si2">
|
||||
<p class="text-xs">{{Number(si2)+1}}、{{sItem2.name}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<NTag
|
||||
v-if="row.l_value>0 && row.s_value>0"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
:color="{
|
||||
color: countRiskLevel(row)?.bg_color,
|
||||
textColor: countRiskLevel(row)?.font_color,
|
||||
borderColor: countRiskLevel(row)?.bg_color,
|
||||
}"
|
||||
>
|
||||
{{countRiskLevel(row)?.name}}
|
||||
</NTag>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</NTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 pt-3">
|
||||
<NButton size="small" @click="exportData">
|
||||
<Icon icon="material-symbols:download" class="mr-1 size-14px" />
|
||||
导出
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="emit('close')">关闭</NButton>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 155px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, h } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getTimeRangeStamp, formatTimestamp } from '@/utils/common';
|
||||
import { matrixConfigApi } from '@/service/api/system';
|
||||
import { orgListApi } from '@/service/api/user';
|
||||
import { highRiskListApi } from '@/service/api/highRisk';
|
||||
|
||||
import RiskPre from "./riskPre.vue";
|
||||
import Hazop from "./hazop.vue";
|
||||
import Fmea from "./fmea.vue";
|
||||
import RiskCheck from "./riskCheck.vue";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const loading = ref<boolean>(false);
|
||||
// 当前记录
|
||||
const currentRecord = ref<any>(null);
|
||||
const RISK_RANGES = [
|
||||
{ label: "近一个月", value: "month" },
|
||||
{ label: "近三个月", value: "quarter" },
|
||||
{ label: "近半年", value: "half_year" },
|
||||
{ label: "全部", value: 'all' }
|
||||
];
|
||||
// 风险矩阵配置
|
||||
const hazopMatrix = ref<any>(null);
|
||||
const fmeaMatrix = ref<any>(null);
|
||||
// 插件抽屉
|
||||
const pluginDrawer = ref<boolean>(false);
|
||||
// 高风险场景列表
|
||||
const list = ref<any[]>([]);
|
||||
|
||||
// 查看变更
|
||||
const jumpTo = (r: any) => {
|
||||
routerPushByKey("details_index",{ query: { id: r?.change_id?.toString(),key:'my_highrisk' } });
|
||||
};
|
||||
// 查看分析记录
|
||||
const viewAnalysis = (record: any) => {
|
||||
currentRecord.value = record;
|
||||
pluginDrawer.value = true;
|
||||
};
|
||||
// 时间范围
|
||||
const timeRange = ref<string>('month');
|
||||
// 组织树
|
||||
const orgTree = ref<any[]>([]);
|
||||
// 筛选条件
|
||||
const formInfo = ref<any>({
|
||||
kw: '',
|
||||
org_id: 1,
|
||||
from: getTimeRangeStamp(timeRange.value)?.from ?? null,
|
||||
to: getTimeRangeStamp(timeRange.value)?.to ?? null,
|
||||
});
|
||||
const pagination = ref<{page: number, pageSize: number, itemCount: number}>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const searchHandle = () => {
|
||||
pagination.value.page = 1;
|
||||
getList();
|
||||
}
|
||||
// 组织选择改变
|
||||
const orgChange = (val:any) => {
|
||||
formInfo.value.org_id = val;
|
||||
searchHandle();
|
||||
}
|
||||
// 时间范围改变
|
||||
const timeRangeChange = (val: string) => {
|
||||
timeRange.value = val;
|
||||
if(val === 'all'){
|
||||
formInfo.value.from = null;
|
||||
formInfo.value.to = null;
|
||||
}else{
|
||||
const timeRange = getTimeRangeStamp(val);
|
||||
formInfo.value.from = timeRange?.from ?? null;
|
||||
formInfo.value.to = timeRange?.to ?? null;
|
||||
}
|
||||
searchHandle();
|
||||
}
|
||||
|
||||
// 重置
|
||||
const reset = () => {
|
||||
timeRange.value = 'month';
|
||||
formInfo.value.kw = '';
|
||||
formInfo.value.org_id = 1;
|
||||
formInfo.value.from = getTimeRangeStamp('month')?.from ?? null;
|
||||
formInfo.value.to = getTimeRangeStamp('month')?.to ?? null;
|
||||
searchHandle();
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const pageChange = (page: number) => {
|
||||
pagination.value.page = page;
|
||||
getList();
|
||||
}
|
||||
// 获取列表
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await highRiskListApi({
|
||||
kw: formInfo.value.kw,
|
||||
org_id: formInfo.value.org_id,
|
||||
from: formatTimestamp(formInfo.value.from),
|
||||
to: formatTimestamp(formInfo.value.to),
|
||||
page_num: pagination.value.page,
|
||||
page_size: pagination.value.pageSize,
|
||||
});
|
||||
if(!error){
|
||||
list.value = data?.list ?? [];
|
||||
pagination.value.itemCount = data?.total ?? 0;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取组织列表
|
||||
const getOrgList = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await orgListApi();
|
||||
if(!error){
|
||||
orgTree.value = data;
|
||||
if(orgTree.value.length > 0){
|
||||
getList();
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 获取风险矩阵配置
|
||||
const matrixConfig = async () => {
|
||||
const {data,error} = await matrixConfigApi();
|
||||
if(!error){
|
||||
hazopMatrix.value = data?.hazop || null;
|
||||
fmeaMatrix.value = data?.fmea || null;
|
||||
}
|
||||
}
|
||||
|
||||
// 风险矩阵等级匹配
|
||||
const matchRiskLevel = (configList:any,name: string) => {
|
||||
if(!configList){
|
||||
return null;
|
||||
}
|
||||
const target = configList.find((item:any) => item.name === name);
|
||||
return target ?? null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
matrixConfig();
|
||||
getOrgList();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 高风险场景 -->
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="border" :style="{ borderRadius: themeStore.themeRadius + 'px' }">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="lucide:shield-check" class="size-16px" :style="{color:themeStore.themeColor}" />
|
||||
<p class="text-base font-semibold">变更新增高风险场景</p>
|
||||
<span class="text-xs text-slate-400 mt-2px">{{ list.length }} 个高风险场景</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 min-h-28px">
|
||||
<NTag size="small" type="primary" class="text-11px font-medium">单笔变更的分析编制在变更上下文内完成</NTag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 mb-3 flex flex-wrap items-center gap-1.5">
|
||||
<NTreeSelect
|
||||
class="!w-200px"
|
||||
:value="formInfo.org_id"
|
||||
:options="orgTree"
|
||||
size="small"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
placeholder="请选择组织层级"
|
||||
@update:value="orgChange"
|
||||
/>
|
||||
<span class="mr-1 text-xs text-slate-400">时间</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
round
|
||||
v-for="r in RISK_RANGES"
|
||||
:key="r.value"
|
||||
:type="timeRange === r.value ? 'primary' : 'default'"
|
||||
@click="timeRangeChange(r.value)"
|
||||
>
|
||||
{{ r.label }}
|
||||
</NButton>
|
||||
<NInput v-model:value="formInfo.kw" size="small" class="text-xs !w-200px ml-auto" clearable placeholder="关键词检索…" @clear="formInfo.kw = ''; searchHandle()">
|
||||
<template #suffix>
|
||||
<Icon icon="akar-icons:search" class="size-14px text-slate-400 cursor-pointer" @click="searchHandle" />
|
||||
</template>
|
||||
</NInput>
|
||||
<NButton size="small" class="text-sm" @click="reset">
|
||||
<Icon icon="system-uicons:reset" class="size-12px" />重置
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div v-if="list.length" class="space-y-3 px-4 pb-3">
|
||||
<div v-for="(s, i) in list" :key="`${s.cid ?? 'x'}-${i}`" class="flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<NTag size="small" type="info" class="text-11px">{{ s.source==='RISK'?'风险预分析':s.source==='RISK_CHECK'?'风险检查': s.source }}</NTag>
|
||||
<span class="text-sm font-medium text-slate-800">{{ s.item }}</span>
|
||||
<!-- 固有风险等级 -->
|
||||
<template v-if="s.source==='RISK'">
|
||||
<NTag
|
||||
v-if="s.inherent_level && s.inherent_level === '低'"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
type="success"
|
||||
>
|
||||
{{s.inherent_level}}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="s.inherent_level && s.inherent_level === '中'"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
type="warning"
|
||||
>
|
||||
{{s.inherent_level}}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="s.inherent_level && s.inherent_level === '高'"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
type="error"
|
||||
>
|
||||
{{s.inherent_level}}
|
||||
</NTag>
|
||||
</template>
|
||||
<template v-if="s.source==='HAZOP'">
|
||||
<NTag
|
||||
v-if="s.inherent_level"
|
||||
size="small"
|
||||
:color="{
|
||||
color: matchRiskLevel(hazopMatrix?.risk_grades,s.inherent_level)?.bg_color,
|
||||
textColor: matchRiskLevel(hazopMatrix?.risk_grades,s.inherent_level)?.font_color,
|
||||
borderColor: matchRiskLevel(hazopMatrix?.risk_grades,s.inherent_level)?.bg_color,
|
||||
}"
|
||||
>
|
||||
固有风险 · {{s.inherent_level}}
|
||||
</NTag>
|
||||
</template>
|
||||
<template v-if="s.source==='FMEA'">
|
||||
<NTag
|
||||
v-if="s.inherent_level"
|
||||
size="small"
|
||||
:color="{
|
||||
color: matchRiskLevel(fmeaMatrix?.risk_grades,s.inherent_level)?.bg_color,
|
||||
textColor: matchRiskLevel(fmeaMatrix?.risk_grades,s.inherent_level)?.font_color,
|
||||
borderColor: matchRiskLevel(fmeaMatrix?.risk_grades,s.inherent_level)?.bg_color,
|
||||
}"
|
||||
>
|
||||
固有风险 · {{s.inherent_level}}
|
||||
</NTag>
|
||||
</template>
|
||||
<template v-if="s.source==='RISK_CHECK'">
|
||||
<span>
|
||||
{{s.inherent_level ?? '-'}}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 剩余风险等级 -->
|
||||
<template v-if="s.source==='RISK'">
|
||||
<NTag
|
||||
v-if="s.residual_level && s.residual_level === '低'"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
type="success"
|
||||
>
|
||||
{{s.residual_level}}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="s.residual_level && s.residual_level === '中'"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
type="warning"
|
||||
>
|
||||
{{s.residual_level}}
|
||||
</NTag>
|
||||
<NTag
|
||||
v-if="s.residual_level && s.residual_level === '高'"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
type="error"
|
||||
>
|
||||
{{s.residual_level}}
|
||||
</NTag>
|
||||
</template>
|
||||
<template v-if="s.source==='HAZOP'">
|
||||
<NTag
|
||||
v-if="s.residual_level"
|
||||
size="small"
|
||||
:color="{
|
||||
color: matchRiskLevel(hazopMatrix?.risk_grades,s.residual_level)?.bg_color,
|
||||
textColor: matchRiskLevel(hazopMatrix?.risk_grades,s.residual_level)?.font_color,
|
||||
borderColor: matchRiskLevel(hazopMatrix?.risk_grades,s.residual_level)?.bg_color,
|
||||
}"
|
||||
>
|
||||
剩余风险 · {{s.residual_level}}
|
||||
</NTag>
|
||||
</template>
|
||||
<template v-if="s.source==='FMEA'">
|
||||
<NTag
|
||||
v-if="s.residual_level"
|
||||
size="small"
|
||||
:color="{
|
||||
color: matchRiskLevel(fmeaMatrix?.risk_grades,s.residual_level)?.bg_color,
|
||||
textColor: matchRiskLevel(fmeaMatrix?.risk_grades,s.residual_level)?.font_color,
|
||||
borderColor: matchRiskLevel(fmeaMatrix?.risk_grades,s.residual_level)?.bg_color,
|
||||
}"
|
||||
>
|
||||
剩余风险 · {{s.residual_level}}
|
||||
</NTag>
|
||||
</template>
|
||||
<template v-if="s.source==='RISK_CHECK'">
|
||||
<span>
|
||||
{{s.residual_level ?? '-'}}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-600">{{ s.scene }}</div>
|
||||
<div class="mt-0.5 text-xs text-slate-400">
|
||||
变更:{{ s?.change_title }} · {{ s?.org_name }} · 分析日期 {{ s?.created_at?.slice(0, 10) }} · 分析记录 {{ s?.record_count }} 行
|
||||
</div>
|
||||
</div>
|
||||
<NButton ghost size="small" class="text-xs" @click="jumpTo(s)">查看变更</NButton>
|
||||
<NButton size="small" type="primary" class="text-xs" @click="viewAnalysis(s)">查看分析记录</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-100% flex items-center justify-center">
|
||||
<NEmpty description="暂无数据"></NEmpty>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-3 px-4 py-2 border-t">
|
||||
<NPagination
|
||||
v-model:page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:item-count="pagination.itemCount"
|
||||
:on-update:page="pageChange"
|
||||
>
|
||||
<template #prefix="{ itemCount }">
|
||||
共 {{ itemCount }} 条
|
||||
</template>
|
||||
</NPagination>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 插件抽屉 -->
|
||||
<NDrawer v-model:show="pluginDrawer" width="80%" placement="right" :auto-focus="false">
|
||||
<NDrawerContent closable>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>风险分析记录表</span>
|
||||
<NTag v-if="currentRecord?.source" size="small" type="info">
|
||||
<span v-if="currentRecord?.source === 'RISK'">风险预分析</span>
|
||||
<span v-else-if="currentRecord?.source === 'HAZOP'">HAZOP</span>
|
||||
<span v-else-if="currentRecord?.source === 'FMEA'">FMEA</span>
|
||||
<span v-else-if="currentRecord?.source === 'RISK_CHECK'">风险检查</span>
|
||||
<span v-else>未知分析记录</span>
|
||||
</NTag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="currentRecord?.source === 'RISK'">
|
||||
<RiskPre :currentId="currentRecord?.change_id" :info="currentRecord" @close="pluginDrawer = false" />
|
||||
</template>
|
||||
<template v-if="currentRecord?.source === 'HAZOP'">
|
||||
<Hazop :currentId="currentRecord?.change_id" :info="currentRecord" :hazopRiskMatrixConfig="hazopMatrix" @close="pluginDrawer = false" />
|
||||
</template>
|
||||
<template v-if="currentRecord?.source === 'FMEA'">
|
||||
<Fmea :currentId="currentRecord?.change_id" :info="currentRecord" :fmeaRiskMatrixConfig="fmeaMatrix" @close="pluginDrawer = false" />
|
||||
</template>
|
||||
<template v-if="currentRecord?.source === 'RISK_CHECK'">
|
||||
<RiskCheck :currentId="currentRecord?.change_id" :info="currentRecord" @close="pluginDrawer = false" />
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 310px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { analysisRecordListApi } from '@/service/api/highRisk';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['isAiOpen','currentId'])
|
||||
const emit = defineEmits(['close','toTable']);
|
||||
// 表格样式
|
||||
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 loading = ref<boolean>(false);
|
||||
// 风险分析记录
|
||||
const riskRecords = ref<any[]>([]);
|
||||
|
||||
// 获取详情
|
||||
const getDetail = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await analysisRecordListApi(props.currentId);
|
||||
if(!error){
|
||||
riskRecords.value = data?.risk_check || [];
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDetail();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3" :style="{'--theme-color': themeStore.themeColor}">
|
||||
<div class="bg-white">
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-600 border border-b-0 rounded-tl-lg rounded-tr-lg">
|
||||
风险检查记录表
|
||||
</div>
|
||||
<p v-if="riskRecords.length===0" class="border py-5 text-center text-xs text-slate-400">暂无风险检查记录</p>
|
||||
<div v-else class="scroll">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :class="TH" class="w-30">风险项</th>
|
||||
<th :class="TH" class="w-60">场景描述</th>
|
||||
<th :class="TH" class="w-20">固有风险</th>
|
||||
<th :class="TH" class="w-50">现有措施</th>
|
||||
<th :class="TH" class="w-60">建议措施</th>
|
||||
<th :class="TH" class="w-20">剩余风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in riskRecords" :key="rowIndex">
|
||||
<td :class="TD">
|
||||
<span>{{row.risk_item}}</span>
|
||||
</td>
|
||||
<td :class="TD">
|
||||
<span>{{row.scene_desc}}</span>
|
||||
</td>
|
||||
<td :class="TD" class="text-center">
|
||||
<span>{{row.risk_value}}</span>
|
||||
</td>
|
||||
<td :class="TD">
|
||||
<span>{{row.safeguards}}</span>
|
||||
</td>
|
||||
<td :class="TD">
|
||||
<span>{{row.suggestions}}</span>
|
||||
</td>
|
||||
<td :class="TD" class="text-center">
|
||||
<span>{{row.residual_risk_value}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll{
|
||||
height: calc(100vh - 135px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { analysisRecordListApi } from '@/service/api/highRisk';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['isAiOpen','currentId'])
|
||||
const emit = defineEmits(['close','toTable']);
|
||||
// 表格样式
|
||||
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 loading = ref<boolean>(false);
|
||||
// 风险分析记录
|
||||
const riskRecords = ref<any[]>([]);
|
||||
|
||||
// 获取详情
|
||||
const getDetail = async () => {
|
||||
loading.value = true;
|
||||
const {data,error} = await analysisRecordListApi(props.currentId);
|
||||
if(!error){
|
||||
riskRecords.value = data?.risk_summary?.rows || [];
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDetail();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading" size="small">
|
||||
<div class="space-y-3" :style="{'--theme-color': themeStore.themeColor}">
|
||||
<div class="bg-white">
|
||||
<div class="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-slate-600 border border-b-0 rounded-tl-lg rounded-tr-lg">
|
||||
风险分析记录表
|
||||
</div>
|
||||
<p v-if="riskRecords.length===0" class="border py-5 text-center text-xs text-slate-400">暂无风险分析记录</p>
|
||||
<div v-else class="scroll">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :class="TH" class="w-30">风险项</th>
|
||||
<th :class="TH" class="w-60">场景描述</th>
|
||||
<th :class="TH" class="w-20">固有风险</th>
|
||||
<th :class="TH" class="w-50">现有措施</th>
|
||||
<th :class="TH" class="w-60">建议措施</th>
|
||||
<th :class="TH" class="w-20">剩余风险</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in riskRecords" :key="rowIndex">
|
||||
<td :class="TD">
|
||||
<span>{{row.risk_item}}</span>
|
||||
</td>
|
||||
<td :class="TD">
|
||||
<span>{{row.scene_desc}}</span>
|
||||
</td>
|
||||
<td :class="TD" class="text-center">
|
||||
<span>{{row.risk_value}}</span>
|
||||
</td>
|
||||
<td :class="TD">
|
||||
<span>{{row.safeguards}}</span>
|
||||
</td>
|
||||
<td :class="TD">
|
||||
<span>{{row.suggestions}}</span>
|
||||
</td>
|
||||
<td :class="TD" class="text-center">
|
||||
<span>{{row.residual_risk_value}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll{
|
||||
height: calc(100vh - 135px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
+485
-1618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { NButton, NDataTable, NInput, NPopconfirm } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import RichTextInput from './RichTextInput.vue'
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
interface ChangeItem {
|
||||
item: string
|
||||
before_text: string
|
||||
after_text: string
|
||||
}
|
||||
const props = defineProps<{
|
||||
data: ChangeItem[]
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:data', data: ChangeItem[]): void
|
||||
}>()
|
||||
|
||||
const localData = ref<ChangeItem[]>([])
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(val: ChangeItem[]) => {
|
||||
// 深度拷贝以避免直接修改 props,并确保响应式
|
||||
localData.value = val ? JSON.parse(JSON.stringify(val)) : []
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
function addRow() {
|
||||
localData.value.push({ item: '新项目', before_text: '', after_text: '' })
|
||||
emit('update:data', localData.value)
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
localData.value.splice(idx, 1)
|
||||
emit('update:data', localData.value)
|
||||
}
|
||||
|
||||
function onCellChange() {
|
||||
emit('update:data', localData.value)
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<ChangeItem>>(() => {
|
||||
const cols: DataTableColumns<ChangeItem> = [
|
||||
{
|
||||
title: '变更项目',
|
||||
key: 'project',
|
||||
width: 100,
|
||||
render: (row: ChangeItem) =>
|
||||
props.editable
|
||||
? h(NInput, {
|
||||
'value': row.item,
|
||||
'size': 'small',
|
||||
'placeholder': '项目名称',
|
||||
'onUpdate:value': (v: string) => {
|
||||
row.item = v
|
||||
onCellChange()
|
||||
},
|
||||
})
|
||||
: h('span', null, row.item),
|
||||
},
|
||||
{
|
||||
title: '变更前',
|
||||
key: 'before_text',
|
||||
render: (row: ChangeItem) =>
|
||||
props.editable
|
||||
? h(RichTextInput, {
|
||||
'modelValue': row.before_text,
|
||||
'minRows': 1,
|
||||
'placeholder': '变更前内容',
|
||||
'onUpdate:modelValue': (v: string) => {
|
||||
row.before_text = v
|
||||
onCellChange()
|
||||
},
|
||||
})
|
||||
: h('div', { class: 'cell-content', innerHTML: row.before_text }),
|
||||
},
|
||||
{
|
||||
title: '变更后',
|
||||
key: 'after_text',
|
||||
render: (row: ChangeItem) =>
|
||||
props.editable
|
||||
? h(RichTextInput, {
|
||||
'modelValue': row.after_text,
|
||||
'minRows': 1,
|
||||
'placeholder': '变更后内容',
|
||||
'onUpdate:modelValue': (v: string) => {
|
||||
row.after_text = v
|
||||
onCellChange()
|
||||
},
|
||||
})
|
||||
: h('div', { class: 'cell-content', innerHTML: row.after_text }),
|
||||
},
|
||||
]
|
||||
if (props.editable) {
|
||||
cols.push({
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 50,
|
||||
align: 'center',
|
||||
render: (_row: ChangeItem, rowIndex: number) =>
|
||||
h(NPopconfirm, {
|
||||
// 组件 Props
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
removeRow(rowIndex)
|
||||
},
|
||||
// 可选:自定义确认/取消按钮的样式
|
||||
positiveButtonProps: { size: 'tiny', type: 'primary' },
|
||||
negativeButtonProps: { size: 'tiny' }
|
||||
}, {
|
||||
// 插槽 (Slots)
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
size: 'tiny',
|
||||
type: 'error',
|
||||
},
|
||||
() => h(Icon, { icon: 'ci:close-circle', class: 'size-16px' })
|
||||
),
|
||||
default: () => '确定要删除吗?' // 弹出框的提示内容
|
||||
}),
|
||||
})
|
||||
}
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">{{data.length ? `变更识别结果(${data.length}项)` : '变更识别结果(空表 · 可手动录入,或由 AI 识别生成)'}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="change-compare-table p-4">
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="localData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
/>
|
||||
<div v-if="editable" class="add-row-btn">
|
||||
<NButton block @click="addRow">
|
||||
<Icon icon="ic:round-plus" class="size-16px" /> 添加行
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.change-compare-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.n-data-table-td) {
|
||||
padding: 8px !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cell-content {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #1d2129;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 覆盖编辑器内部的一些基础背景避免在表格里太突兀 */
|
||||
:deep(.rich-text-input) {
|
||||
background-color: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.add-row-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 变更后单元格蓝色高亮
|
||||
import { computed } from "vue";
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps<{ text: string; hl?: string[] }>();
|
||||
interface Seg { t: string; hit: boolean }
|
||||
const segs = computed<Seg[]>(() => {
|
||||
const text = props.text;
|
||||
if (!props.hl || props.hl.length === 0) return [{ t: text, hit: false }];
|
||||
const out: Seg[] = [];
|
||||
let rest = text;
|
||||
const hls = [...props.hl].sort((a, b) => b.length - a.length);
|
||||
while (rest.length) {
|
||||
let idx = -1, kw = "";
|
||||
for (const h of hls) {
|
||||
const i = rest.indexOf(h);
|
||||
if (i >= 0 && (idx < 0 || i < idx)) { idx = i; kw = h; }
|
||||
}
|
||||
if (idx < 0) { out.push({ t: rest, hit: false }); break; }
|
||||
if (idx > 0) out.push({ t: rest.slice(0, idx), hit: false });
|
||||
out.push({ t: kw, hit: true });
|
||||
rest = rest.slice(idx + kw.length);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<span class="whitespace-pre-line"><template v-for="(s, i) in segs" :key="i">
|
||||
<mark
|
||||
v-if="s.hit" class="rounded bg-blue-100 px-0.5"
|
||||
:style="{color: themeStore.themeColor}"
|
||||
>{{ s.t }}</mark>
|
||||
<template v-else>{{ s.t }}</template></template></span>
|
||||
</template>
|
||||
@@ -0,0 +1,476 @@
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
minRows?: number
|
||||
maxRows?: number
|
||||
disabled?: boolean
|
||||
bordered?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
minRows: 1,
|
||||
maxRows: 100,
|
||||
disabled: false,
|
||||
bordered: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const editorRef = ref<HTMLDivElement | null>(null)
|
||||
const isFocused = ref(false)
|
||||
const showColorPicker = ref(false)
|
||||
|
||||
const LINE_HEIGHT = 22 // 单行高度(px)
|
||||
|
||||
const colorOptions = [
|
||||
{ label: '红色', value: '#e8364e', bg: '#e8364e' },
|
||||
{ label: '蓝色', value: '#1890ff', bg: '#1890ff' },
|
||||
{ label: '橙色', value: '#fa8c16', bg: '#fa8c16' },
|
||||
{ label: '绿色', value: '#52c41a', bg: '#52c41a' },
|
||||
{ label: '紫色', value: '#722ed1', bg: '#722ed1' },
|
||||
{ label: '默认', value: '', bg: '#333333' },
|
||||
]
|
||||
|
||||
// 获取编辑器中去掉 HTML 标签后的纯文本,用于判断是否为空
|
||||
function getPlainText(): string {
|
||||
if (!editorRef.value)
|
||||
return ''
|
||||
return editorRef.value.innerText?.trim() || ''
|
||||
}
|
||||
|
||||
const hasContent = ref(false)
|
||||
|
||||
// 设置编辑器内容(仅在内容确实不同时才更新,防止光标重置)
|
||||
function setEditorHtml(html: string) {
|
||||
if (!editorRef.value)
|
||||
return
|
||||
if (editorRef.value.innerHTML !== html)
|
||||
editorRef.value.innerHTML = html || ''
|
||||
}
|
||||
|
||||
// 输入事件:同步内容到 modelValue
|
||||
function onInput() {
|
||||
if (!editorRef.value)
|
||||
return
|
||||
const html = editorRef.value.innerHTML || ''
|
||||
// 如果内容只是 <br> 或空 div 则视为空
|
||||
const plain = getPlainText()
|
||||
hasContent.value = !!plain
|
||||
emit('update:modelValue', plain ? html : '')
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
isFocused.value = true
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// 延迟关闭,让工具栏按钮的 mousedown 有时间执行
|
||||
setTimeout(() => {
|
||||
isFocused.value = false
|
||||
showColorPicker.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// =============== 工具栏操作 ===============
|
||||
|
||||
// 保存和恢复选区
|
||||
let savedSelection: Range | null = null
|
||||
|
||||
function saveSelection() {
|
||||
const sel = window.getSelection()
|
||||
if (sel && sel.rangeCount > 0)
|
||||
savedSelection = sel.getRangeAt(0).cloneRange()
|
||||
}
|
||||
|
||||
function restoreSelection() {
|
||||
if (savedSelection) {
|
||||
const sel = window.getSelection()
|
||||
if (sel) {
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(savedSelection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行粗体
|
||||
function execBold(e: Event) {
|
||||
e.preventDefault()
|
||||
restoreSelection()
|
||||
document.execCommand('bold', false)
|
||||
editorRef.value?.focus()
|
||||
onInput()
|
||||
}
|
||||
|
||||
// 执行文字颜色
|
||||
function execColor(color: string, e: Event) {
|
||||
e.preventDefault()
|
||||
restoreSelection()
|
||||
if (color) {
|
||||
document.execCommand('foreColor', false, color)
|
||||
}
|
||||
else {
|
||||
// 移除颜色:先获取选区文本,删除后用无格式文本替换
|
||||
document.execCommand('removeFormat', false)
|
||||
}
|
||||
showColorPicker.value = false
|
||||
editorRef.value?.focus()
|
||||
onInput()
|
||||
}
|
||||
|
||||
function toggleColorPicker(e: Event) {
|
||||
e.preventDefault()
|
||||
saveSelection()
|
||||
showColorPicker.value = !showColorPicker.value
|
||||
}
|
||||
|
||||
// 工具栏按钮 mousedown 时保存选区
|
||||
function onToolbarMousedown(e: Event) {
|
||||
e.preventDefault()
|
||||
saveSelection()
|
||||
}
|
||||
|
||||
// 监听粘贴事件,只粘贴纯文本(保留已有的 HTML 标记,但不引入外部格式)
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
e.preventDefault()
|
||||
// 优先粘贴 HTML(保留粗体/颜色),fallback 纯文本
|
||||
const html = e.clipboardData?.getData('text/html')
|
||||
const text = e.clipboardData?.getData('text/plain') || ''
|
||||
if (html) {
|
||||
// 清理外部 HTML:只保留简单的格式标签
|
||||
const cleanHtml = sanitizeHtml(html)
|
||||
document.execCommand('insertHTML', false, cleanHtml)
|
||||
}
|
||||
else {
|
||||
document.execCommand('insertText', false, text)
|
||||
}
|
||||
onInput()
|
||||
}
|
||||
|
||||
// 简易 HTML 清洗:只保留 b/strong/span/em/i/br 标签
|
||||
function sanitizeHtml(html: string): string {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
// 递归清洗
|
||||
function cleanNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE)
|
||||
return node.textContent || ''
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE)
|
||||
return ''
|
||||
|
||||
const el = node as HTMLElement
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const allowedTags = ['b', 'strong', 'span', 'em', 'i', 'br', 'u']
|
||||
let inner = ''
|
||||
el.childNodes.forEach((child) => {
|
||||
inner += cleanNode(child)
|
||||
})
|
||||
|
||||
if (tag === 'br')
|
||||
return '<br>'
|
||||
|
||||
if (allowedTags.includes(tag)) {
|
||||
// 保留 span 的 style 中的 color
|
||||
if (tag === 'span' && el.style.color)
|
||||
return `<span style="color:${el.style.color}">${inner}</span>`
|
||||
|
||||
if (tag === 'b' || tag === 'strong')
|
||||
return `<b>${inner}</b>`
|
||||
|
||||
if (tag === 'em' || tag === 'i')
|
||||
return `<em>${inner}</em>`
|
||||
|
||||
return inner
|
||||
}
|
||||
// 块级元素转换为换行
|
||||
const blockTags = ['div', 'p', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']
|
||||
if (blockTags.includes(tag))
|
||||
return `${inner}<br>`
|
||||
|
||||
return inner
|
||||
}
|
||||
return cleanNode(div)
|
||||
}
|
||||
|
||||
// 监听外部 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal:string) => {
|
||||
if (!editorRef.value)
|
||||
return
|
||||
// 仅在编辑器不聚焦或内容确实不同时更新
|
||||
if (!isFocused.value || editorRef.value.innerHTML !== newVal) {
|
||||
setEditorHtml(newVal || '')
|
||||
hasContent.value = !!getPlainText()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setEditorHtml(props.modelValue || '')
|
||||
hasContent.value = !!getPlainText()
|
||||
})
|
||||
})
|
||||
|
||||
// 点击编辑器外部关闭颜色选择器
|
||||
function handleDocClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.rich-text-input'))
|
||||
showColorPicker.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleDocClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleDocClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rich-text-input"
|
||||
:class="{ 'is-focused': isFocused, 'is-disabled': disabled, 'bordered': bordered }"
|
||||
>
|
||||
<!-- 工具栏 -->
|
||||
<div v-show="isFocused && !disabled" class="rti-toolbar">
|
||||
<button
|
||||
class="rti-btn"
|
||||
title="粗体 (Ctrl+B)"
|
||||
@mousedown="onToolbarMousedown"
|
||||
@click="execBold"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<div class="rti-color-wrap">
|
||||
<button
|
||||
class="rti-btn rti-btn-color"
|
||||
title="文字颜色"
|
||||
@mousedown="toggleColorPicker"
|
||||
>
|
||||
<span class="rti-color-icon">A</span>
|
||||
<span class="rti-color-bar" />
|
||||
</button>
|
||||
<div v-show="showColorPicker" class="rti-color-dropdown">
|
||||
<button
|
||||
v-for="c in colorOptions"
|
||||
:key="c.value"
|
||||
class="rti-color-option"
|
||||
:title="c.label"
|
||||
@mousedown="(e: Event) => execColor(c.value, e)"
|
||||
>
|
||||
<span class="rti-color-dot" :style="{ background: c.bg }" />
|
||||
<span class="rti-color-label">{{ c.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 编辑区域 -->
|
||||
<div class="rti-editor-wrap">
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="rti-editor"
|
||||
:contenteditable="!disabled"
|
||||
:style="{
|
||||
maxHeight: `${maxRows * LINE_HEIGHT}px`,
|
||||
}"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@paste="onPaste"
|
||||
@mouseup="saveSelection"
|
||||
@keyup="saveSelection"
|
||||
></div>
|
||||
<div v-if="!hasContent && !isFocused" class="rti-placeholder">
|
||||
{{ placeholder }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-input {
|
||||
position: relative;
|
||||
background: #fafbfc;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
.bordered {
|
||||
border: 1px solid #e5e8ef;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rich-text-input:hover {
|
||||
border-color: #2080f0;
|
||||
}
|
||||
|
||||
.rich-text-input.is-focused {
|
||||
border-color: #2080f0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.rich-text-input.is-disabled {
|
||||
opacity: 0.8;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.rich-text-input.is-disabled:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.rti-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafbfc;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.rti-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #4e5969;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.rti-btn:hover {
|
||||
background: #e8f4ff;
|
||||
color: #2080f0;
|
||||
}
|
||||
|
||||
.rti-btn b {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 颜色按钮 */
|
||||
.rti-color-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rti-btn-color {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.rti-color-icon {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rti-color-bar {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #e8364e, #2080f0, #fa8c16, #52c41a);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 颜色下拉 */
|
||||
.rti-color-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.rti-color-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #4e5969;
|
||||
transition: background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rti-color-option:hover {
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.rti-color-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rti-color-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 编辑区域 */
|
||||
.rti-editor-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rti-editor {
|
||||
padding: 2px 10px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: #1d2129;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.rti-editor:empty::before {
|
||||
content: '';
|
||||
}
|
||||
|
||||
/* 粗体和颜色在编辑器内的渲染 */
|
||||
.rti-editor :deep(b),
|
||||
.rti-editor :deep(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rti-placeholder {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 10px;
|
||||
color: #c9cdd4;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,861 @@
|
||||
<!-- 变更申请表 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, watch, nextTick } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { tagTextColor, tagBgColor, tagBorderColor, downloadFileFun } from '@/utils/common';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { AiError, aiTypeJudge, aiLevelScore, aiApplication } from "@/service/api/ai";
|
||||
import { uploadFileApi, deleteFileApi } from "@/service/api/file";
|
||||
import Footer from './footer.vue'
|
||||
import previewComponent from '@/components/previewFile/index.vue'
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps([
|
||||
'type','orgTree','templateList','typeList','levelList','isAiOpen',
|
||||
'title','currentId','formInfo', 'currentForm', 'confirmed','members','steps'
|
||||
])
|
||||
const emit = defineEmits(['save','submit','confirmTab','update:title','update:steps']);
|
||||
|
||||
const isSyncingFromParent = ref(false);
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
|
||||
const uploadFileRef = ref<any>(null);
|
||||
const footerRef = ref<any>(null);
|
||||
const uploadLoading = ref<boolean>(false);
|
||||
const specialties = ['生产','技术', '仪表', '电气', '设备', '安全', '质量'];
|
||||
// 总分数
|
||||
const totalScore = ref<number>(0);
|
||||
// 当前等级
|
||||
const currentLevel = computed(() => props.levelList.find((x:{value:number | null, label:string, color:string}) => x.value === form.value.change_level));
|
||||
// 当前用户
|
||||
const userInfo = ref<any>(localStg.get('userInfo'));
|
||||
// 预览文件
|
||||
const previewFile = ref<any>(null);
|
||||
const sheet = ref<string>('');
|
||||
// 上传文件
|
||||
const uploadFile = ref<{id:number, file_name:string, file_url:string, uploader_name:string, file_type:string, created_at:string, version:string | null}[]>([]);
|
||||
// 表单
|
||||
const form = ref<{
|
||||
change_type: number | null,
|
||||
change_level: number | null,
|
||||
duration_type: number | null,
|
||||
restore_deadline: number | null,
|
||||
org_id: number | null,
|
||||
urgent: number,
|
||||
purposes: string[],
|
||||
effect: string,
|
||||
main_changes: string[],
|
||||
related_changes: string[],
|
||||
materials: string,
|
||||
update_docs: string[],
|
||||
disciplines: string[],
|
||||
plan_use_date: number | null,
|
||||
risk_tools: string[],
|
||||
level_score: any[],
|
||||
}>({
|
||||
change_type: null,
|
||||
change_level: null,
|
||||
duration_type: null,
|
||||
restore_deadline: null,
|
||||
org_id: null,
|
||||
urgent: 0,
|
||||
purposes: [],
|
||||
effect: '',
|
||||
main_changes: [''],
|
||||
related_changes: [''],
|
||||
materials: '',
|
||||
update_docs: [],
|
||||
disciplines: [],
|
||||
plan_use_date: null,
|
||||
risk_tools: [],
|
||||
level_score: [],
|
||||
});
|
||||
// 变更目的选项
|
||||
const PURPOSE_OPTIONS = [
|
||||
"满足市场需求", "提高生产效能", "改善操作条件", "增加安全性能",
|
||||
"达到环保要求", "法律合规整改", "减少能耗物损", "稳定系统运行",
|
||||
"完善管理制度", "达到质量改进", "设备可靠性升级",
|
||||
];
|
||||
// 需更新的资料选项
|
||||
const UPDATE_DOC_OPTIONS = ['PID图纸', '操作规程', '总图', '设备台账', '工艺卡片', '联锁台账', '应急预案'];
|
||||
// 风险分析选项
|
||||
const RISK_TOOLS_FORM = [
|
||||
{ key: "RISK", label: "风险预分析" },
|
||||
{ key: "HAZOP", label: "HAZOP" },
|
||||
{ key: "FMEA", label: "FMEA" },
|
||||
{ key: "RISK_CHECK", label: "风险检查表" },
|
||||
];
|
||||
const questions = ref<any>([
|
||||
{
|
||||
question_id: 1,
|
||||
name: "1. 设备影响",
|
||||
note: "不合适的材质将会导致",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "对设备带来一定的损害及腐蚀", score: 1 },
|
||||
{ label: "对设备带来中等的损害及腐蚀", score: 2 },
|
||||
{ label: "对设备带来明显的损害及腐蚀", score: 3 },
|
||||
{ label: "对设备带来重大的损害及腐蚀", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 2,
|
||||
name: "2. 财务影响",
|
||||
note: "工艺设计错误将会导致",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "一定损失,a<1万人民币", score: 1 },
|
||||
{ label: "中等损失,直接损失1万≤a<10万人民币", score: 2 },
|
||||
{ label: "大量损失,直接损失10万≤a<100万人民币", score: 3 },
|
||||
{ label: "巨额损失,直接损失100万人民币≤a", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 3,
|
||||
name: "3. 质量影响",
|
||||
note: "",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "对质量带来一定的风险(非关键工席、非关键设备设施的变更)", score: 1 },
|
||||
{ label: "对质量带来中等风险(B类原料替代、生产辅助设备变更)", score: 2 },
|
||||
{ label: "对质量带来明显的风险(主要工艺参数、中间体生产工艺变更)", score: 3 },
|
||||
{ label: "对质量有很高风险(主要工艺路线、关键工艺参数变更)", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 4,
|
||||
name: "4. 设计的复杂程度",
|
||||
note: "工艺设计包含了",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "少量的简单系统(单个设备或单个部件)", score: 1 },
|
||||
{ label: "大量的简单系统(多个单类设备或部件)", score: 2 },
|
||||
{ label: "至少一个复杂系统(涉及多设备/工艺相应调整)", score: 3 },
|
||||
{ label: "大量的复杂系统(需要装置配套改造调整)", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 5,
|
||||
name: "5. 工艺设计的成熟度",
|
||||
note: "",
|
||||
score: 0,
|
||||
options: [
|
||||
{ label: "被频繁使用,且被证明为专利方设计", score: 1 },
|
||||
{ label: "被少量使用,或被证明为专利方设计", score: 2 },
|
||||
{ label: "被相关工业设计方法或参考文献认可(非专利方)", score: 3 },
|
||||
{ label: "新的,未发表的", score: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
question_id: 6,
|
||||
name: "6. 工艺危害",
|
||||
note: "物料性质和工艺条件",
|
||||
score: 0,
|
||||
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 currentSteps = ref<any>(null);
|
||||
|
||||
watch(() => props.currentForm, (newVal:any) => {
|
||||
if (!newVal) return;
|
||||
isSyncingFromParent.value = true;
|
||||
// 过滤出需要的字段
|
||||
const {
|
||||
approval_flow,
|
||||
attachments,
|
||||
change_id,
|
||||
created_at,
|
||||
id,
|
||||
updated_at,
|
||||
...rest
|
||||
} = newVal;
|
||||
form.value = rest;
|
||||
// 用 Map 建立 question_id -> score 的映射,查找 O(1)
|
||||
const scoreMap = new Map(form.value?.level_score?.map((item:any) => [item.question_id, item.score]) || []);
|
||||
questions.value = questions.value.map((item:any) => ({
|
||||
...item,
|
||||
score: scoreMap.has(item.question_id) ? scoreMap.get(item.question_id) : item.score
|
||||
}));
|
||||
totalScore.value = questions.value.reduce((sum:number, item:any) => sum + (item.score || 0), 0);
|
||||
nextTick(() => { // ★ form 的 watch 跑完后再释放
|
||||
isSyncingFromParent.value = false;
|
||||
});
|
||||
},{ immediate: true })
|
||||
// 监听总分数变化,自动更新等级
|
||||
watch(() => totalScore.value, (v:number) => {
|
||||
form.value.change_level = v >= 20 ? 2 : 1;
|
||||
});
|
||||
// 变更类型 / 变更等级 / 变更时限,任一改变 → 更新模板
|
||||
watch(
|
||||
() => [
|
||||
form.value?.change_type,
|
||||
form.value?.change_level,
|
||||
form.value?.duration_type,
|
||||
],
|
||||
() => {
|
||||
if (isSyncingFromParent.value) return;
|
||||
updateCurrentTemplate()
|
||||
}
|
||||
);
|
||||
// 监听步骤变化,更新本地步骤
|
||||
watch(() => props.steps, (v:any) => {
|
||||
currentSteps.value = v;
|
||||
},{ immediate: true })
|
||||
|
||||
// 筛选涉及专业的人员和部门
|
||||
const filterMembersBySpecialties = (steps: any, specialties: string[]) => {
|
||||
const result: any[] = [];
|
||||
steps.forEach((step: any) => {
|
||||
step.members.forEach((member: any) => {
|
||||
if (member.specialty && specialties.includes(member.specialty)) {
|
||||
result.push({
|
||||
stepName: step.name, // 上级部门名称
|
||||
member: member // 成员对象
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// 更新当前模板步骤
|
||||
const updateCurrentTemplate = () => {
|
||||
let template = props.templateList.find((item: any) =>
|
||||
item.level === form.value?.change_level &&
|
||||
item.category === form.value?.change_type &&
|
||||
item.duration === form.value?.duration_type
|
||||
) || null;
|
||||
currentSteps.value = template?.steps || [];
|
||||
emit('update:steps', currentSteps.value);
|
||||
}
|
||||
// 联动专业会签人员
|
||||
const signers = computed(() => {
|
||||
if(currentSteps.value){
|
||||
const matchedMembers = filterMembersBySpecialties(currentSteps.value || [], form.value.disciplines);
|
||||
return matchedMembers
|
||||
}
|
||||
return null
|
||||
})
|
||||
// 按等级推荐
|
||||
const recommendTools = () => {
|
||||
const lv = totalScore.value >= 20 ? "重要" : "一般";
|
||||
form.value.risk_tools = totalScore.value >= 20 ? ["HAZOP"] : ["RISK_CHECK"];
|
||||
window.$message?.success(`已按「${lv}变更」规则匹配风险分析工具,可继续手动重选`);
|
||||
};
|
||||
|
||||
// 风险分析插件选择
|
||||
const toggleTool = (t: {key:string,label:string}) => {
|
||||
const { risk_tools } = form.value;
|
||||
form.value.risk_tools = risk_tools.includes(t.key) ? risk_tools.filter((key:string) => key !== t.key) : [...risk_tools, t.key];
|
||||
};
|
||||
|
||||
// 变更类型判断
|
||||
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 () => {
|
||||
let form = props.formInfo;
|
||||
if (!form?.description) {
|
||||
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
|
||||
}
|
||||
sheet.value = "type";
|
||||
if (typeAiLoading.value) return;
|
||||
typeAiLoading.value = true;
|
||||
try {
|
||||
const res = await aiTypeJudge(form.description, (form.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
|
||||
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 () => {
|
||||
let form = props.formInfo;
|
||||
if (!form?.description && props.isAiOpen) {
|
||||
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
|
||||
}
|
||||
sheet.value = "level";
|
||||
if (levelAiLoading.value) return;
|
||||
};
|
||||
// 使用AI 评分
|
||||
const useAiLevel = async () => {
|
||||
let form = props.formInfo;
|
||||
if (levelAiLoading.value) return;
|
||||
levelAiLoading.value = true;
|
||||
try {
|
||||
const dims = questions.value.map((d:any, i:any) => ({ dim: `DIM_${i + 1}`, name: d.name, max: Math.max(...d.options.map((o:any) => o.score)) }));
|
||||
const res = await aiLevelScore(form.description, (form.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })), dims);
|
||||
// 重新评分前清零,避免多次点击分数累加
|
||||
totalScore.value = 0;
|
||||
levelAiBasis.value = [];
|
||||
res?.dims?.forEach((d, i) => {
|
||||
totalScore.value += d.score ?? 0;
|
||||
questions.value[i].score = d.score ?? 0;
|
||||
levelAiBasis.value[i] = d.basis ?? "";
|
||||
});
|
||||
levelAiText.value = res?.analysis_text ?? "";
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
levelAiLoading.value = false;
|
||||
}
|
||||
}
|
||||
// AI 申请单结果回填(生成变更申请单 / 重新生成变更申请表共用)
|
||||
const applyAiResult = (res: any) => {
|
||||
if (!res) return;
|
||||
const f = form.value;
|
||||
const main = (res.main_changes ?? []).filter((x: string) => x && x.trim());
|
||||
if (main.length) f.main_changes = main;
|
||||
const related = (res.related_changes ?? []).filter((x: string) => x && x.trim());
|
||||
if (related.length) f.related_changes = related;
|
||||
const purposes = (res.purposes ?? []).filter((x: string) => PURPOSE_OPTIONS.includes(x));
|
||||
if (purposes.length) f.purposes = purposes;
|
||||
if (res.effect) f.effect = res.effect;
|
||||
// 变更类型:AI 输出 MOC 中文编码 → typeList 数值
|
||||
if (res.change_type) {
|
||||
const t = props.typeList.find((x: any) => x.label === res.change_type || x.label.includes(res.change_type) || res.change_type.includes(x.label));
|
||||
if (t) f.change_type = t.value;
|
||||
}
|
||||
// 变更时限:永久→1,临时→2
|
||||
if (res.duration_suggestion === '永久') f.duration_type = 1;
|
||||
else if (res.duration_suggestion === '临时') f.duration_type = 2;
|
||||
if (res.materials_text) f.materials = res.materials_text;
|
||||
const docs = (res.update_docs ?? []).filter((x: string) => UPDATE_DOC_OPTIONS.includes(x));
|
||||
if (docs.length) f.update_docs = docs;
|
||||
const discs = (res.disciplines ?? []).filter((x: string) => specialties.includes(x));
|
||||
if (discs.length) f.disciplines = discs;
|
||||
const aiTools = (res.risk_tools ?? [])
|
||||
.map((x: string) => RISK_TOOLS_FORM.find((t) => t.key === x || t.label === x)?.key)
|
||||
.filter((x: string | undefined): x is string => !!x);
|
||||
if (aiTools.length) f.risk_tools = aiTools;
|
||||
}
|
||||
// 重新生成变更申请表(footer 派发)
|
||||
const onRegenerate = async () => {
|
||||
const info = props.formInfo;
|
||||
if (!info?.description) {
|
||||
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
|
||||
}
|
||||
footerRef.value?.setRegenerateLoading(true);
|
||||
try {
|
||||
const res = await aiApplication(info.description, (info.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
|
||||
applyAiResult(res);
|
||||
if (res?.name) emit('update:title', res.name);
|
||||
window.$message?.success("变更申请表已由 AI 重新生成,内容均可手动修改");
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
footerRef.value?.setRegenerateLoading(false);
|
||||
}
|
||||
}
|
||||
// 处理等级判定表评分变化
|
||||
const handleScoreChange = (val:number,index:number) => {
|
||||
questions.value[index].score = val;
|
||||
let score = 0;
|
||||
questions.value.forEach((d:any) => {
|
||||
score += d.score;
|
||||
});
|
||||
totalScore.value = score;
|
||||
}
|
||||
|
||||
// 下载
|
||||
const downloadFile = (file:any) => {
|
||||
downloadFileFun(file?.file_url, file?.doc_name || file?.file_name);
|
||||
};
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
|
||||
// 上传资料
|
||||
const onUploadFile = async (e:any) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('step', '1');
|
||||
uploadLoading.value = true;
|
||||
const {data,error} = await uploadFileApi(props.currentId, formData);
|
||||
if(!error){
|
||||
window.$message?.success('上传成功');
|
||||
uploadFile.value = [...uploadFile.value, data];
|
||||
}
|
||||
uploadLoading.value = false;
|
||||
}
|
||||
// 删除资料
|
||||
const removeFile = async (index:number) => {
|
||||
const {error} = await deleteFileApi(props.currentId, uploadFile.value[index].id);
|
||||
if(!error){
|
||||
window.$message?.success('删除成功');
|
||||
uploadFile.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
// 更新审批流程
|
||||
const updateSteps = (steps:any[]) => {
|
||||
emit('update:steps', steps);
|
||||
}
|
||||
|
||||
defineExpose({form, questions, applyAiResult, generateLevel: useAiLevel})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<!-- 申请部门 / 申请人 -->
|
||||
<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>
|
||||
<NTreeSelect
|
||||
v-model:value="form.org_id"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:options="props.orgTree"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
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 :value="userInfo?.real_name" 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 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="(_, i) in form.main_changes" :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="form.main_changes[i]" type="text" placeholder="" />
|
||||
<NPopconfirm
|
||||
v-if="form.main_changes.length > 1"
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="form.main_changes.splice(i, 1)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error">
|
||||
<Icon icon="ci:close-circle" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<NButton text type="primary" class="text-xs" @click="form.main_changes = [...form.main_changes, '']">
|
||||
<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="(_, i) in form.related_changes" :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="form.related_changes[i]" placeholder="连带变更点(如:更新操作规程)" />
|
||||
<NPopconfirm
|
||||
v-if="form.related_changes.length > 1"
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="form.related_changes.splice(i, 1)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error">
|
||||
<Icon icon="ci:close-circle" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<NButton text type="primary" class="text-xs" @click="form.related_changes = [...form.related_changes, '']">
|
||||
<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.change_type">
|
||||
<div class="flex gap-x-2">
|
||||
<NRadio v-for="t in props.typeList" :key="t.value" :value="t.value">{{t.label}}</NRadio>
|
||||
</div>
|
||||
</NRadioGroup>
|
||||
<NButton v-if="props.isAiOpen" 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.change_level" disabled>
|
||||
<div class="flex gap-x-2">
|
||||
<NRadio v-for="t in props.levelList" :key="t.value" :value="t.value">{{t.label}}</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_type">
|
||||
<div class="flex gap-x-2">
|
||||
<NRadio v-for="t in [1, 2]" :key="t" :value="t">
|
||||
{{ t === 1 ? '永久' : '临时' }}
|
||||
</NRadio>
|
||||
</div>
|
||||
</NRadioGroup>
|
||||
<NCheckbox v-model:checked="form.urgent" :checked-value="1" :unchecked-value="0">紧急</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)" title="点击选用 / 取消;AI 辅助分析在各工具插件内使用"
|
||||
class="text-sm"
|
||||
:ghost="form.risk_tools?.includes(t.key) ? false : true"
|
||||
:type="form.risk_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 v-if="form.urgent" class="text-xs text-red-500 ml-27">紧急变更为单独标记:走快速通道,事后限期补办风险分析与完整审批</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.plan_use_date" type="date" value-format="yyyy-MM-dd" class="!max-w-[220px]" />
|
||||
<!-- 临时 -->
|
||||
<template v-if="form.duration_type === 2">
|
||||
<span class="whitespace-nowrap text-sm text-slate-500">计划恢复时间 <span class="text-red-500">*</span></span>
|
||||
<NDatePicker v-model:value="form.restore_deadline" type="date" value-format="yyyy-MM-dd" 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 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="form.materials" 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">
|
||||
<div
|
||||
class="relative"
|
||||
:style="{'--theme-color':themeStore.themeColor}"
|
||||
v-for="(f,index) in uploadFile" :key="f.id"
|
||||
>
|
||||
<div
|
||||
class="text-xs cursor-pointer flex items-center border py-1.5 px-2 rounded-md hover:border-[var(--theme-color)] hover:text-[var(--theme-color)]"
|
||||
title="点击在线预览"
|
||||
@click="previewFile = f"
|
||||
>
|
||||
<Icon icon="akar-icons:file" class="size-14px mr-1" />{{ f.file_name }}
|
||||
</div>
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="removeFile(index)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="absolute top-[-8px] right-[-8px] bg-[#fff]">
|
||||
<Icon icon="ci:close-circle" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<NButton :disabled="uploadLoading" @click="uploadFileRef?.click()">
|
||||
<Icon v-if="uploadLoading" icon="ri:loader-4-fill" class="size-16px animate-spin" />
|
||||
<Icon v-else icon="material-symbols:upload" class="size-16px" />
|
||||
资料上传
|
||||
</NButton>
|
||||
<input ref="uploadFileRef" type="file" accept="image/*,.pdf,.docx,.xlsx" class="hidden" @change="onUploadFile" />
|
||||
<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="form.update_docs">
|
||||
<div class="flex flex-wrap gap-y-2 gap-x-4">
|
||||
<NCheckbox v-for="p in UPDATE_DOC_OPTIONS" :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="form.disciplines">
|
||||
<div class="flex flex-wrap gap-y-2 gap-x-4">
|
||||
<NCheckbox v-for="p in specialties" :key="p" :value="p" :label="p" />
|
||||
</div>
|
||||
</NCheckboxGroup>
|
||||
<span class="self-center text-xs text-slate-400">(多选,决定审批流中专业会签/审批的路由范围)</span>
|
||||
</div>
|
||||
<div v-if="signers && signers.length" 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">{{ p?.member?.specialty }} · {{ p?.member?.user_name }}{{p?.stepName ? ` (${p.stepName})` : ''}}</NTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
:currentForm="form"
|
||||
:confirmed="props.confirmed"
|
||||
:members="props.members"
|
||||
:steps="currentSteps"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@regenerate="onRegenerate"
|
||||
@update:steps="updateSteps"
|
||||
/>
|
||||
<!-- 侧边抽屉:AI 类型判定 / 等级判定表 -->
|
||||
<NDrawer :show="sheet !== ''" @update:show="(v) => !v && (sheet = '')" placement="right" :width="sheet === 'level' ? '45rem' : '28rem'" :auto-focus="false">
|
||||
<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>
|
||||
</template>
|
||||
<div class="h-full space-y-4 overflow-y-auto">
|
||||
<!-- AI 变更类型判定说明 -->
|
||||
<template v-if="sheet === 'type'">
|
||||
<NTag v-if="typeAiLoading" type="info" size="small">
|
||||
<template #icon>
|
||||
<Icon icon="ri:loader-4-fill" class="size-12px animate-spin" />
|
||||
</template>
|
||||
AI 判定中…
|
||||
</NTag>
|
||||
<div v-else class="flex items-center gap-2 max-h-[22px]">
|
||||
<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="typeAi?.type" type="info" size="small">判定:{{ typeAi?.type }}</NTag>
|
||||
<NTag v-if="typeAi" size="small">置信度:{{ `${Math.round(typeAi.confidence * 100)}%` }}</NTag>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<!-- 变更等级判定表 -->
|
||||
<template v-if="sheet === 'level'">
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton v-if="props.isAiOpen" size="small" type="primary">
|
||||
<div v-if="levelAiLoading" class="flex items-center gap-1 text-xs">
|
||||
<Icon icon="ri:loader-4-fill" class="size-14px animate-spin" />AI 评分中
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-xs" @click="useAiLevel">
|
||||
使用AI 评分
|
||||
</div>
|
||||
</NButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag
|
||||
v-if="currentLevel"
|
||||
size="small"
|
||||
:color="{
|
||||
color: tagBgColor(currentLevel?.color),
|
||||
textColor: tagTextColor(currentLevel?.color),
|
||||
borderColor: tagBorderColor(currentLevel?.color),
|
||||
}"
|
||||
>
|
||||
{{ currentLevel?.label ? currentLevel.label+'变更' : '' }}
|
||||
</NTag>
|
||||
<span class="text-xs text-slate-400">合计得分 <b class="text-[#17407F]">{{ totalScore }}</b> 分(<20 分为一般,≥20 分为重要)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-3 grid-cols-2">
|
||||
<div v-for="(dim, di) in questions" :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]">{{ questions[di].score }}</b></span>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<NRadioGroup v-model:value="questions[di].score" size="small" :disabled="levelAiLoading" name="radiogroup" :on-update:value="(v)=>handleScoreChange(v,di)">
|
||||
<div class="flex flex-col justify-center gap-2">
|
||||
<NRadio v-for="op in dim.options" :key="op.score" :value="op.score">
|
||||
<p class="text-xs">{{ op.label }}({{ op.score }}分)</p>
|
||||
</NRadio>
|
||||
</div>
|
||||
</NRadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.isAiOpen && (levelAiText || levelAiBasis.some((x) => x))" class="rounded-lg border border-violet-200 bg-violet-50/50 p-4">
|
||||
<div class="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 class="mt-2 space-y-3 text-xs leading-relaxed text-slate-600">
|
||||
<p v-if="levelAiText">{{ levelAiText }}</p>
|
||||
<p v-for="(dim, di) in questions" :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>
|
||||
</template>
|
||||
</div>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
|
||||
<!-- 附件在线预览弹窗 -->
|
||||
<NModal
|
||||
:show="previewFile !== null"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '80%', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
@update:show="(v) => !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?.file_name }}
|
||||
</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>上传人:{{ previewFile?.uploader_name }}</span>
|
||||
<span>上传时间:{{ previewFile?.created_at }}</span>
|
||||
<span v-if="previewFile?.version">{{ `版本:${previewFile?.version}(受控)` }}</span>
|
||||
</div>
|
||||
<previewComponent v-if="previewFile" :previewFile="previewFile" />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-[10px]">
|
||||
<NButton ghost size="small" @click="downloadFile(previewFile)">
|
||||
<Icon icon="material-symbols:download" class="size-16px" /> 下载
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="previewFile = null">关闭</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
:deep(.n-radio) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.scroll {
|
||||
height: calc(100vh - 379px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!-- 变更预识别 -->
|
||||
<script setup lang="ts">
|
||||
import { inject, watch, ref, computed } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { AiError, aiApplication, aiRecognize, aiRiskPreAnalysis } from "@/service/api/ai";
|
||||
import ChangeCompareTable from "./ChangeCompareTable.vue";
|
||||
import PreRisk from "./preRisk.vue";
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps(['title','currentId','currentForm'])
|
||||
const emit = defineEmits(['save', 'apply-generated'])
|
||||
const generalLoading = inject('generalLoading', {saveLoading: ref<boolean>(false),btnDisabled: ref<boolean>(false)})
|
||||
const {saveLoading,btnDisabled} = generalLoading
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
// 变更识别loading
|
||||
const preLoading = ref<boolean>(false);
|
||||
// 生成变更申请单loading
|
||||
const generateLoading = ref<boolean>(false);
|
||||
// 风险预分析loading
|
||||
const analyzing = ref<boolean>(false);
|
||||
// 是否显示险预分析
|
||||
const preRiskShow = computed(() => form.value.severity || form.value.probability || form.value.protection);
|
||||
// 是否显示变更识别结果
|
||||
const identifyShow = computed(() => form.value.compare_rows.length > 0);
|
||||
// 表单
|
||||
const form = ref<{
|
||||
description: string,
|
||||
compare_rows: {item: string, before_text: string, after_text: string}[],
|
||||
severity: string,
|
||||
probability: string,
|
||||
protection: string
|
||||
}>({
|
||||
description: '',
|
||||
compare_rows: [],
|
||||
severity: "",
|
||||
probability: "",
|
||||
protection: "",
|
||||
});
|
||||
// AI 变更识别
|
||||
const runIdentify = async () => {
|
||||
if (!form.value.description.trim()) {
|
||||
return window.$message?.warning("请先输入变更内容描述");
|
||||
}
|
||||
form.value.compare_rows = [];
|
||||
btnDisabled.value = true;
|
||||
preLoading.value = true;
|
||||
try {
|
||||
const res = await aiRecognize(form.value.description);
|
||||
form.value.compare_rows = (res?.rows ?? []).map((x, i) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text }));
|
||||
window.$message?.success(`AI 变更识别完成,共 ${form.value.compare_rows.length} 项,请逐项人工确认(可直接修改)`);
|
||||
} catch (e: any) {
|
||||
form.value.compare_rows = [];
|
||||
aiFail(e);
|
||||
} finally {
|
||||
preLoading.value = false;
|
||||
btnDisabled.value = false;
|
||||
}
|
||||
};
|
||||
// 运行风险预分析
|
||||
const runRiskAnalysis = async () => {
|
||||
if(form.value.description.trim() === '') {
|
||||
return window.$message?.warning('请先填写变更内容描述');
|
||||
}
|
||||
analyzing.value = true;
|
||||
btnDisabled.value = true;
|
||||
try {
|
||||
const res = await aiRiskPreAnalysis(form.value.description, form.value.compare_rows);
|
||||
form.value = {
|
||||
...form.value,
|
||||
severity: res?.severity ?? form.value.severity,
|
||||
probability: res?.probability ?? form.value.probability,
|
||||
protection: res?.protection ?? form.value.protection,
|
||||
};
|
||||
window.$message?.success("AI 风险预分析报告已生成(危害严重性 / 事件概率 / 保护措施影响),内容支持手动修改");
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
analyzing.value = false;
|
||||
btnDisabled.value = false;
|
||||
}
|
||||
};
|
||||
// 生成变更申请单
|
||||
const genForm = async () => {
|
||||
if (!form.value.description.trim()) {
|
||||
return window.$message?.warning("请先输入变更内容描述");
|
||||
}
|
||||
generateLoading.value = true;
|
||||
btnDisabled.value = true;
|
||||
try {
|
||||
const res = await aiApplication(form.value.description, form.value.compare_rows);
|
||||
// 由父组件把 AI 结果回填到「变更申请表」并切换标签
|
||||
emit('apply-generated', res);
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
generateLoading.value = false;
|
||||
btnDisabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
|
||||
defineExpose({form})
|
||||
|
||||
watch(() => props.currentForm, (newVal:any) => {
|
||||
const {
|
||||
attachments,
|
||||
change_id,
|
||||
created_at,
|
||||
id,
|
||||
updated_at,
|
||||
...rest
|
||||
} = newVal;
|
||||
form.value = rest;
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<!-- 变更内容描述 -->
|
||||
<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 ghost type="primary" @click="runIdentify" :disabled="btnDisabled || !form.description?.trim()">
|
||||
<Icon v-if="preLoading" icon="ri:loader-4-fill" class="size-16px animate-spin" />
|
||||
<Icon v-else icon="fluent:wand-24-regular" class="size-16px" />
|
||||
变更识别
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<NInput type="textarea" v-model:value="form.description" rows="4" placeholder="请输入变更内容描述,例如:将反应釜R-101的搅拌速度从120rpm提高至180rpm,同时将反应温度从80℃提高至95℃…" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 变更识别结果 -->
|
||||
<template v-if="identifyShow">
|
||||
<ChangeCompareTable
|
||||
:data="form.compare_rows"
|
||||
:editable="true"
|
||||
@update:data="(d) => form.compare_rows = d"
|
||||
/>
|
||||
</template>
|
||||
<!-- 风险预分析报告 -->
|
||||
<template v-if="preRiskShow">
|
||||
<PreRisk :formInfo="form" @update:data="(key:'severity' | 'probability' | 'protection',val:string) => form[key] = val" />
|
||||
</template>
|
||||
</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)]">
|
||||
<span class="text-xs text-slate-400">预识别阶段不设审批人与提交:完成识别与预分析后,请切换至「变更申请表」选择审批人并提交</span>
|
||||
<div class="ml-auto flex shrink-0 gap-2">
|
||||
<NButton ghost type="success" size="small" :loading="generateLoading" :disabled="btnDisabled" @click="genForm">
|
||||
<Icon icon="akar-icons:file" class="size-14px" /> 生成变更申请单
|
||||
</NButton>
|
||||
<NButton ghost type="warning" size="small" @click="runRiskAnalysis" :disabled="btnDisabled">
|
||||
<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" :disabled="btnDisabled" :loading="saveLoading" @click="saveDraft">保存</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 326px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!-- 变更培训内容 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { AiError, aiTraining } from "@/service/api/ai";
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const props = defineProps(['type','isAiOpen','title','currentId','formInfo', 'currentForm', 'confirmed','members','steps'])
|
||||
const emit = defineEmits(['save','submit','confirmTab','update:steps']);
|
||||
|
||||
watch(() => props.currentForm, (newVal:any) => {
|
||||
// 过滤出需要的字段
|
||||
const {
|
||||
attachments,
|
||||
change_id,
|
||||
created_at,
|
||||
id,
|
||||
updated_at,
|
||||
...rest
|
||||
} = newVal;
|
||||
form.value = rest;
|
||||
})
|
||||
|
||||
const footerRef = ref<any>(null);
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
// 是否是ai生成
|
||||
const isAiGenerated = ref<boolean>(false);
|
||||
const form = ref<{ content: string }>({
|
||||
content: ''
|
||||
});
|
||||
|
||||
// 重新生成变更培训内容(footer 派发)
|
||||
const onRegenerate = async () => {
|
||||
const info = props.formInfo;
|
||||
if (!info?.description) {
|
||||
return window.$message?.warning("缺少变更描述,请先在 [变更预识别] 里面输入变更描述");
|
||||
}
|
||||
footerRef.value?.setRegenerateLoading(true);
|
||||
try {
|
||||
const res = await aiTraining(info.description, (info.compare_rows ?? []).map((x: any) => ({ item: x.item, before_text: x.before_text, after_text: x.after_text })));
|
||||
if (res?.training_text) {
|
||||
form.value.content = res.training_text;
|
||||
isAiGenerated.value = true;
|
||||
window.$message?.success("变更培训内容已由 AI 生成,可直接修改");
|
||||
} else {
|
||||
window.$message?.warning("AI 未返回培训内容,请手动填写");
|
||||
}
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
footerRef.value?.setRegenerateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
// 更新审批流程
|
||||
const updateSteps = (steps:any[]) => {
|
||||
emit('update:steps', steps);
|
||||
}
|
||||
|
||||
defineExpose({form, onRegenerate})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3 min-h-[22px]">
|
||||
<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 v-if="isAiGenerated" 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 v-if="isAiGenerated" class="ml-auto text-xs text-slate-400">AI 生成内容均可点击修改自定义</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="form.content" type="textarea" :bordered="false" :autosize="{minRows: 4}" class="font-mono text-sm" placeholder="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
:confirmed="props.confirmed"
|
||||
:members="props.members"
|
||||
:steps="props.steps"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@regenerate="onRegenerate"
|
||||
@update:steps="updateSteps"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 379px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<!-- 变更关闭确认表 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['type','isAiOpen','title','currentId','members'])
|
||||
const emit = defineEmits(['save','submit','confirmTab']);
|
||||
|
||||
const form = ref<{ content: string }>({
|
||||
content: ''
|
||||
});
|
||||
|
||||
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 closeFold = ref<Record<string, boolean>>({});
|
||||
const uploadDoc = ref<string | null>(null);
|
||||
const uploadSel = ref<string | null>(null);
|
||||
|
||||
const updateDocs = ref<string[]>([]);
|
||||
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 saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// // 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
|
||||
defineExpose({form})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
:members="props.members"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
/>
|
||||
<!-- 资料上传弹窗 -->
|
||||
<NModal
|
||||
:show="uploadDoc !== null"
|
||||
preset="card"
|
||||
:auto-focus="false"
|
||||
:style="{ width: '550px', height: 'auto' }"
|
||||
:segmented="{ content: true, footer: true }"
|
||||
@update:show="(v) => !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>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 393px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,314 @@
|
||||
<!-- 验收评价 -->
|
||||
<script setup lang="ts">
|
||||
import { h, ref, watch } from 'vue';
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getColorWithOpacity } from "@/utils/common";
|
||||
import { AiError, aiAcceptance } from "@/service/api/ai";
|
||||
import Footer from './footer.vue';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const props = defineProps(['type','orgTree','isAiOpen','title','currentId','formInfo','currentForm','confirmed','members','steps'])
|
||||
const emit = defineEmits(['save','submit','confirmTab','update:steps']);
|
||||
|
||||
watch(() => props.currentForm, (newVal:any) => {
|
||||
const omit = (obj:any, fields: string[]) => Object.fromEntries(Object.entries(obj).filter(([k]) => !fields.includes(k)));
|
||||
const result = newVal?.items.map((item:any) => omit(item, [
|
||||
'created_at', 'updated_at', 'change_id', 'org_name'
|
||||
]));
|
||||
list.value = result || [];
|
||||
})
|
||||
|
||||
// 是否是ai生成
|
||||
const isAiGenerated = ref<boolean>(false);
|
||||
const footerRef = ref<any>(null);
|
||||
const editing = ref<any>(null);
|
||||
const list = ref<{id:number,item:string,basis:string,standard:string,method:string,org_id:number}[]>([]);
|
||||
|
||||
const aiFail = (e: any) => {
|
||||
if (e instanceof AiError) window.$message?.warning(`AI 分析失败:${e.message},可手动填写`);
|
||||
else window.$message?.warning("AI 分析失败,可手动填写");
|
||||
};
|
||||
|
||||
// 重新生成验收评价(footer 派发):依据申请表的变更目的与预期效果生成量化验收标准
|
||||
const onRegenerate = async () => {
|
||||
const purpose = (props.formInfo?.purposes ?? []).filter((x: string) => x && x.trim()).join('、');
|
||||
const effect = props.formInfo?.effect ?? '';
|
||||
if (!purpose && !effect.trim()) {
|
||||
return window.$message?.warning("缺少变更目的与预期效果,请先在 [变更申请表] 填写");
|
||||
}
|
||||
footerRef.value?.setRegenerateLoading(true);
|
||||
try {
|
||||
const res = await aiAcceptance({ purpose, effect });
|
||||
const rows = res?.rows ?? [];
|
||||
if (!rows.length) {
|
||||
return window.$message?.warning("AI 未生成验收项,可手动添加");
|
||||
}
|
||||
list.value = rows.map((r: any) => ({
|
||||
id: 0,
|
||||
item: r.item ?? '',
|
||||
basis: r.basis ?? '',
|
||||
standard: r.standard ?? '',
|
||||
method: r.method ?? '',
|
||||
// 建议责任方文本 → 组织树节点(按名称模糊匹配,匹配不到留空人工选择)
|
||||
org_id: matchOrgId(r.owner_suggest),
|
||||
}));
|
||||
isAiGenerated.value = true;
|
||||
window.$message?.success("验收量化标准已由 AI 生成,均可手动修改");
|
||||
} catch (e: any) {
|
||||
aiFail(e);
|
||||
} finally {
|
||||
footerRef.value?.setRegenerateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 按名称在组织树中查找节点 id
|
||||
const matchOrgId = (name: string): number => {
|
||||
if (!name || !name.trim()) return 0;
|
||||
const hit = findNodeByLabel(props.orgTree ?? [], name.trim());
|
||||
return hit?.id ?? 0;
|
||||
}
|
||||
const findNodeByLabel = (tree: any[], name: string): any => {
|
||||
for (const node of tree) {
|
||||
if (node.label && (name.includes(node.label) || node.label.includes(name))) return node;
|
||||
if (node.children && node.children.length) {
|
||||
const found: any = findNodeByLabel(node.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 添加验收项
|
||||
const addRow = () => {
|
||||
list.value.push({ id: 0, item: '', basis: '', standard: '', method: '',org_id: 0 });
|
||||
}
|
||||
// 删除验收项
|
||||
const deleteRow = (i:number) => {
|
||||
list.value.splice(i, 1);
|
||||
}
|
||||
|
||||
// 自定义渲染展开图标
|
||||
const renderSwitcherIcon = (node:any) => {
|
||||
// 如果节点没有 children 或 children 为空数组,则不显示图标
|
||||
if (!node.option.children || node.option.children.length === 0) {
|
||||
return null; // 返回 null 表示不渲染任何图标
|
||||
}
|
||||
// 有子节点:返回 undefined 表示使用默认展开图标,也可自定义
|
||||
return h(Icon,{
|
||||
icon: node.option.expanded ? 'bi:caret-down-fill' : 'bi:caret-right-fill',
|
||||
class: 'size-12px',
|
||||
});
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
const saveDraft = () => {
|
||||
emit('save');
|
||||
}
|
||||
// 提交
|
||||
const submitConfirm = () => {
|
||||
emit('submit', props.type);
|
||||
}
|
||||
// 确认标签内容
|
||||
const confirmTab = (tab:string, val:boolean) => {
|
||||
emit('confirmTab', tab, val);
|
||||
}
|
||||
// 更新审批流程
|
||||
const updateSteps = (steps:any[]) => {
|
||||
emit('update:steps', steps);
|
||||
}
|
||||
|
||||
const findNodeById = (tree:any,id:number) => {
|
||||
for (const node of tree) {
|
||||
if (node.id === id) return node;
|
||||
if (node.children && node.children.length) {
|
||||
const found:any = findNodeById(node.children,id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null; // 未找到
|
||||
}
|
||||
|
||||
defineExpose({list, onRegenerate})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div class="space-y-4 scroll">
|
||||
<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 v-if="isAiGenerated" 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>{{ formInfo?.purposes?.join('、') || '' }}</div>
|
||||
<div><b :style="{color: themeStore.themeColor}">预期效果:</b>{{ formInfo?.effect || '' }}</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-12 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-46 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-if="list.length===0">
|
||||
<td colspan="6" class="py-4 px-2 text-center text-slate-400">暂无验收项目</td>
|
||||
</tr>
|
||||
<tr v-for="(row, i) in list" :key="i" 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 === `item-${i}-${row.item}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.item"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `item-${i}-${row.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 === `basis-${i}-${row.basis}`"
|
||||
class="text-[11px]"
|
||||
size="small"
|
||||
v-model:value="row.basis"
|
||||
autofocus
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `basis-${i}-${row.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 class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `standard-${i}-${row.standard}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.standard"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `standard-${i}-${row.standard}`" 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.standard || '点击填写量化验收标准' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NInput
|
||||
v-if="editing === `method-${i}-${row.method}`"
|
||||
class="text-xs"
|
||||
v-model:value="row.method"
|
||||
type="textarea"
|
||||
autofocus
|
||||
rows="2"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `method-${i}-${row.method}`" 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.method || '点击填写验收方式' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-3 py-2.5">
|
||||
<NTreeSelect
|
||||
v-if="editing === `org-${i}-${row.org_id}`"
|
||||
size="small"
|
||||
v-model:value="row.org_id"
|
||||
default-expand-all
|
||||
label-field="label"
|
||||
key-field="id"
|
||||
:options="props.orgTree"
|
||||
:render-switcher-icon="renderSwitcherIcon"
|
||||
placeholder="请选择组织层级"
|
||||
@blur="editing = null"
|
||||
/>
|
||||
<button v-else @click="editing = `org-${i}-${row.org_id}`" 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]"
|
||||
>
|
||||
{{ findNodeById(orgTree,row.org_id)?.label || '点击选择组织层级' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="border-t px-2 py-2.5 text-center">
|
||||
<NPopconfirm
|
||||
positive-text="确定"
|
||||
:positiveButtonProps="{ size: 'tiny' }"
|
||||
:negativeButtonProps="{ size: 'tiny' }"
|
||||
@positive-click="deleteRow(i)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton text type="error" class="mt-1.5" title="删除本验收项">
|
||||
<Icon icon="iconamoon:trash" class="size-16px" />
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton ghost class="text-xs" @click="addRow">
|
||||
<Icon icon="ic:round-plus" class="size-16px" />添加验收项
|
||||
</NButton>
|
||||
<span class="text-xs text-slate-400">评估结论在验收阶段由责任单位填写:点击选择「完全达到 / 基本达到 / 未达成」,再次点击取消;行尾可删除验收项</span>
|
||||
</div>
|
||||
<div class="rounded-lg bg-emerald-50/80 px-4 py-2.5 text-xs leading-5 text-emerald-700">
|
||||
流程说明:投用运行满验收周期后,系统向车间(属地)与涉及专业推送验收任务;各责任单位按上表逐项给出评估结论并上传佐证(数据报表 / 化验报告 / 现场照片),全部完成后汇总至综合验收结论,结论与佐证资料一并纳入变更关闭校验,验收不通过时退回整改或评估恢复原状。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer
|
||||
ref="footerRef"
|
||||
:type="props.type"
|
||||
:currentId="props.currentId"
|
||||
:title="props.title"
|
||||
:isAiOpen="props.isAiOpen"
|
||||
:confirmed="props.confirmed"
|
||||
:members="props.members"
|
||||
:steps="props.steps"
|
||||
@save="saveDraft"
|
||||
@submit="submitConfirm"
|
||||
@confirmTab="confirmTab"
|
||||
@regenerate="onRegenerate"
|
||||
@update:steps="updateSteps"
|
||||
/>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.scroll {
|
||||
height: calc(100vh - 379px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
<!-- 通用底部操作 -->
|
||||
<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.id ? null : s.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" :auto-focus="false">
|
||||
<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.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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user