<script lang="ts" setup>
|
import type { MesHomeApi } from '#/api/mes/home';
|
|
import { computed, ref } from 'vue';
|
|
import { CountTo } from '@vben/common-ui';
|
import { IconifyIcon } from '@vben/icons';
|
|
import { Col, Row } from 'ant-design-vue';
|
|
import { useHomeAccess } from '../use-home-access';
|
|
defineOptions({ name: 'DashboardKpiCards' });
|
|
const { canNavigate } = useHomeAccess();
|
|
const props = defineProps<{
|
summary: MesHomeApi.Summary;
|
}>();
|
|
const emit = defineEmits<{
|
navigate: [name: string];
|
}>();
|
|
/**
|
* 质量合格率:来料 / 过程 / 出货三个检验环节,各自一张卡片
|
*
|
* 由后端按「合格数量 / 检验数量」计算(不过滤单据状态,无时间限制),
|
* 该环节没有检验数据时后端返回 null。
|
*/
|
const qualityRates = computed(() => [
|
{
|
key: 'quality-iqc',
|
label: '来料合格率',
|
rate: props.summary.iqcQualifiedRate,
|
checkQuantity: props.summary.iqcCheckQuantity,
|
routeName: 'MesQcIqc',
|
icon: 'lucide:package-check',
|
iconClass: 'bg-gradient-to-br from-orange-500 to-amber-400 shadow-lg shadow-orange-500/25',
|
textClass: 'text-orange-600 dark:text-orange-400',
|
washClass: 'bg-gradient-to-br from-orange-500/5 to-amber-500/5',
|
glowClass: 'bg-orange-400/10',
|
},
|
{
|
key: 'quality-ipqc',
|
label: '过程合格率',
|
rate: props.summary.ipqcQualifiedRate,
|
checkQuantity: props.summary.ipqcCheckQuantity,
|
routeName: 'MesQcIpqc',
|
icon: 'lucide:workflow',
|
iconClass: 'bg-gradient-to-br from-cyan-500 to-teal-400 shadow-lg shadow-cyan-500/25',
|
textClass: 'text-cyan-600 dark:text-cyan-400',
|
washClass: 'bg-gradient-to-br from-cyan-500/5 to-teal-500/5',
|
glowClass: 'bg-cyan-400/10',
|
},
|
{
|
key: 'quality-oqc',
|
label: '出货合格率',
|
rate: props.summary.oqcQualifiedRate,
|
checkQuantity: props.summary.oqcCheckQuantity,
|
routeName: 'MesQcOqc',
|
icon: 'lucide:truck',
|
iconClass: 'bg-gradient-to-br from-pink-500 to-rose-400 shadow-lg shadow-pink-500/25',
|
textClass: 'text-pink-600 dark:text-pink-400',
|
washClass: 'bg-gradient-to-br from-pink-500/5 to-rose-500/5',
|
glowClass: 'bg-pink-400/10',
|
},
|
]);
|
|
/** 合格率展示文案(一位小数,与原「质量合格率」卡片一致),无数据时用「—」而不是 0% */
|
function formatRate(rate: null | number | undefined) {
|
return rate === null || rate === undefined ? '—' : rate.toFixed(1);
|
}
|
|
/** 无检验数据时不渲染百分号,避免出现「—%」 */
|
function hasRate(rate: null | number | undefined) {
|
return rate !== null && rate !== undefined;
|
}
|
|
const equipmentRate = computed(() => {
|
if (props.summary.machineryTotal === 0) return 0;
|
return (props.summary.machineryProducing / props.summary.machineryTotal) * 100;
|
});
|
|
/** 3D 倾斜效果 */
|
interface TiltState { x: number; y: number }
|
|
const tilts = ref<Record<string, TiltState>>({});
|
|
function onMouseMove(e: MouseEvent, id: string) {
|
const el = e.currentTarget as HTMLElement;
|
const rect = el.getBoundingClientRect();
|
const x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
|
const y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
|
tilts.value[id] = { x: x * -6, y: y * 6 };
|
}
|
|
function onMouseLeave(id: string) {
|
tilts.value[id] = { x: 0, y: 0 };
|
}
|
|
function tiltStyle(id: string) {
|
const t = tilts.value[id] ?? { x: 0, y: 0 };
|
return {
|
transform: `perspective(600px) rotateX(${t.y}deg) rotateY(${t.x}deg)`,
|
transition: t.x === 0 && t.y === 0 ? 'transform 0.5s ease' : 'transform 0.1s ease',
|
};
|
}
|
</script>
|
|
<template>
|
<Row :gutter="16">
|
<!-- 生产订单 -->
|
<Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
|
<div
|
class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
|
:class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }"
|
:style="tiltStyle('order')"
|
@mousemove="(e: MouseEvent) => onMouseMove(e, 'order')"
|
@mouseleave="onMouseLeave('order')"
|
@click="canNavigate('MesProWorkOrder') && emit('navigate', 'MesProWorkOrder')"
|
>
|
<div class="relative z-10 flex items-center gap-4 p-5">
|
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-cyan-400 shadow-lg shadow-blue-500/25 transition-transform duration-300 group-hover:scale-110">
|
<IconifyIcon class="size-6 text-white" icon="lucide:clipboard-list" />
|
</div>
|
<div class="min-w-0 flex-1">
|
<div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">生产订单</div>
|
<div class="flex items-baseline gap-1">
|
<CountTo class="text-2xl font-bold leading-tight text-blue-600 dark:text-blue-400" :end-val="summary.workOrderActiveCount" :duration="1200" />
|
<span class="text-xs text-gray-400">进行中</span>
|
</div>
|
<div class="mt-1 flex items-center gap-1 text-xs text-gray-400">
|
<span class="inline-block size-1.5 rounded-full bg-amber-400" /> 待排产 {{ summary.workOrderPrepareCount }}
|
<span class="mx-1">|</span>
|
<span class="inline-block size-1.5 rounded-full bg-emerald-400" /> 已完成 {{ summary.workOrderFinishedCount }}
|
</div>
|
</div>
|
</div>
|
<div class="absolute inset-0 -z-0 bg-gradient-to-br from-blue-500/5 to-cyan-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
<div class="absolute -right-6 -top-6 size-24 rounded-full bg-blue-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
|
</div>
|
</Col>
|
|
<!-- 今日产量 -->
|
<Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
|
<div
|
class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
|
:class="{ 'cursor-pointer': canNavigate('MesProFeedback') }"
|
:style="tiltStyle('output')"
|
@mousemove="(e: MouseEvent) => onMouseMove(e, 'output')"
|
@mouseleave="onMouseLeave('output')"
|
@click="canNavigate('MesProFeedback') && emit('navigate', 'MesProFeedback')"
|
>
|
<div class="relative z-10 flex items-center gap-4 p-5">
|
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-green-400 shadow-lg shadow-emerald-500/25 transition-transform duration-300 group-hover:scale-110">
|
<IconifyIcon class="size-6 text-white" icon="lucide:trending-up" />
|
</div>
|
<div class="min-w-0 flex-1">
|
<div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">今日产量</div>
|
<div class="flex items-baseline gap-1">
|
<CountTo class="text-2xl font-bold leading-tight text-emerald-600 dark:text-emerald-400" :end-val="summary.todayOutput" :duration="1200" />
|
<span class="text-xs text-gray-400">件</span>
|
</div>
|
<div class="mt-1 text-xs text-gray-400">
|
昨日 {{ summary.yesterdayOutput }} 件
|
<span v-if="summary.yesterdayOutput > 0 && summary.todayOutput >= summary.yesterdayOutput" class="text-emerald-500">↑</span>
|
<span v-else-if="summary.yesterdayOutput > 0" class="text-red-500">↓</span>
|
</div>
|
</div>
|
</div>
|
<div class="absolute inset-0 -z-0 bg-gradient-to-br from-emerald-500/5 to-green-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
<div class="absolute -right-6 -top-6 size-24 rounded-full bg-emerald-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
|
</div>
|
</Col>
|
|
<!-- 质量合格率:来料 / 过程 / 出货,各一张卡片 -->
|
<Col
|
v-for="item in qualityRates"
|
:key="item.key"
|
:lg="6"
|
:md="12"
|
:sm="12"
|
:xl="4"
|
:xs="24"
|
class="mb-4"
|
>
|
<div
|
class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
|
:class="{ 'cursor-pointer': canNavigate(item.routeName) }"
|
:style="tiltStyle(item.key)"
|
@mousemove="(e: MouseEvent) => onMouseMove(e, item.key)"
|
@mouseleave="onMouseLeave(item.key)"
|
@click="canNavigate(item.routeName) && emit('navigate', item.routeName)"
|
>
|
<div class="relative z-10 flex items-center gap-4 p-5">
|
<div
|
class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl transition-transform duration-300 group-hover:scale-110"
|
:class="item.iconClass"
|
>
|
<IconifyIcon class="size-6 text-white" :icon="item.icon" />
|
</div>
|
<div class="min-w-0 flex-1">
|
<div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">{{ item.label }}</div>
|
<div class="flex items-baseline gap-1">
|
<span class="text-2xl font-bold leading-tight" :class="item.textClass">
|
{{ formatRate(item.rate) }}
|
</span>
|
<span v-if="hasRate(item.rate)" class="text-xs text-gray-400">%</span>
|
</div>
|
<div class="mt-1 flex items-center gap-1 text-xs text-gray-400">
|
<span>检验 {{ item.checkQuantity }}</span>
|
</div>
|
</div>
|
</div>
|
<div
|
class="absolute inset-0 -z-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
:class="item.washClass"
|
/>
|
<div
|
class="absolute -right-6 -top-6 size-24 rounded-full blur-xl transition-all duration-300 group-hover:scale-150"
|
:class="item.glowClass"
|
/>
|
</div>
|
</Col>
|
|
<!-- 设备运行率 -->
|
<Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
|
<div
|
class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
|
:class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }"
|
:style="tiltStyle('equip')"
|
@mousemove="(e: MouseEvent) => onMouseMove(e, 'equip')"
|
@mouseleave="onMouseLeave('equip')"
|
@click="canNavigate('MesDvMachinery') && emit('navigate', 'MesDvMachinery')"
|
>
|
<div class="relative z-10 flex items-center gap-4 p-5">
|
<div class="flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-purple-400 shadow-lg shadow-violet-500/25 transition-transform duration-300 group-hover:scale-110">
|
<IconifyIcon class="size-6 text-white" icon="lucide:cpu" />
|
</div>
|
<div class="min-w-0 flex-1">
|
<div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">设备运行率</div>
|
<div class="flex items-baseline gap-1">
|
<CountTo class="text-2xl font-bold leading-tight text-violet-600 dark:text-violet-400" :decimals="1" :end-val="equipmentRate" :duration="1200" />
|
<span class="text-xs text-gray-400">%</span>
|
</div>
|
<div class="mt-1 flex items-center gap-1 text-xs text-gray-400">
|
<span class="text-red-400">停机 {{ summary.machineryStop }}</span>
|
<span class="mx-1">|</span>
|
<span class="text-amber-400">维护 {{ summary.machineryMaintenance }}</span>
|
</div>
|
</div>
|
</div>
|
<div class="absolute inset-0 -z-0 bg-gradient-to-br from-violet-500/5 to-purple-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
<div class="absolute -right-6 -top-6 size-24 rounded-full bg-violet-400/10 blur-xl transition-all duration-300 group-hover:scale-150" />
|
</div>
|
</Col>
|
</Row>
|
</template>
|
|
<style scoped>
|
.glass-tilt {
|
background: linear-gradient(135deg, rgba(255,255,255,0.85), rgba(255,255,255,0.55));
|
backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
border: 1px solid rgba(255,255,255,0.6);
|
box-shadow:
|
0 4px 24px rgba(0,0,0,0.04),
|
0 1px 4px rgba(0,0,0,0.02),
|
inset 0 1px 0 rgba(255,255,255,0.8);
|
transform-style: preserve-3d;
|
}
|
|
.dark .glass-tilt {
|
background: linear-gradient(135deg, rgba(30,30,55,0.75), rgba(20,20,40,0.5));
|
border: 1px solid rgba(255,255,255,0.08);
|
box-shadow:
|
0 4px 24px rgba(0,0,0,0.3),
|
0 1px 4px rgba(0,0,0,0.2),
|
inset 0 1px 0 rgba(255,255,255,0.04);
|
}
|
</style>
|