Compare commits
184
Commits
master
...
161354c584
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
161354c584 | ||
|
|
2d6973147b | ||
|
|
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
|
||||
});
|
||||
}
|
||||
+19
-19
@@ -1,19 +1,19 @@
|
||||
import { request } from '../request';
|
||||
|
||||
/** 获取角色列表
|
||||
*
|
||||
*/
|
||||
export function roleListApi() {
|
||||
return request({ url: '/api/role' });
|
||||
}
|
||||
/**
|
||||
* 编辑角色
|
||||
*
|
||||
*/
|
||||
export function editRoleApi(params: { id: number, intro: string, name: string, perms: string[] }) {
|
||||
return request({
|
||||
url: '/api/role',
|
||||
method: 'post',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
import { request } from '../request';
|
||||
|
||||
/** 获取角色列表
|
||||
*
|
||||
*/
|
||||
export function roleListApi() {
|
||||
return request({ url: '/api/role' });
|
||||
}
|
||||
/**
|
||||
* 编辑角色
|
||||
*
|
||||
*/
|
||||
export function editRoleApi(params: { id: number, intro: string, name: string, perms: string[] }) {
|
||||
return request({
|
||||
url: '/api/role',
|
||||
method: 'post',
|
||||
data: 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();
|
||||
|
||||
@@ -99,7 +106,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
startLoading();
|
||||
|
||||
const { data: loginToken, error } = await fetchLogin(userName, password);
|
||||
|
||||
|
||||
if (!error) {
|
||||
const pass = await loginByToken(loginToken);
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -95,4 +95,178 @@ export function formatTimestamp(timestamp: number | string,format: string = 'yyy
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
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>
|
||||
|
||||
<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>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
|
||||
<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
+545
-1678
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: 1,
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user