Files
moc/src/views/home/modules/template8.vue
T
2026-10-08 14:33:48 +08:00

455 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { 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>