xiaoyi
9 天以前 8a1e3bc7ea351d4fceac4185c5fe7f7f32a76974
style(dashboard): 出勤指标与库存采购改为标准 KPI 卡片,与首页统一

## 问题
之前这两项用的是「一整块玻璃面板 + 内部小方块」的形态,
和首页其它卡片(生产订单、合格率、采购订单等)的「单卡单指标」风格不一致。

## 改动
把 2 个大面板拆成 4 张标准 KPI 卡片,完全复用首页既有卡片规范:

本月出勤率 紫色 user-check 环形数据 + 在册人数/人均出勤
本月考勤异常 橙色 alert 迟到/缺卡 分项
库存可用量 青色 package 品种数 + 扣除冻结占用
采购订单 靛蓝 cart 笔数 + 待审批

统一的部分:
- 容器:glass-tilt + rounded-2xl + h-full
- 布局:图标块 size-12 + 标题/数值/副行三行结构
- 动效:3D 倾斜(perspective 600px, ±6deg)
- 悬浮:渐变遮罩 + 右上光晕 scale-150
- 栅格:xs24 / sm12 / md8 / lg8 / xl6 / xxl6
- 数字:CountTo 动画,无数据时出勤率显示「—」而非 0%

## 验证
vue-tsc 无新增错误(基线 318 不变),构建通过,已部署生产。
已修改2个文件
369 ■■■■■ 文件已修改
src/views/dashboard/analytics/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/hr-stock-cards.vue 367 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue
@@ -84,7 +84,7 @@
    <BizCards @navigate="handleNavigate" />
    <!-- 出勤指标 + 库存采购 -->
    <HrStockCards class="mb-4 block" @navigate="handleNavigate" />
    <HrStockCards @navigate="handleNavigate" />
    <!-- AI 智能分析 -->
    <AiInsights
src/views/dashboard/analytics/modules/hr-stock-cards.vue
@@ -4,7 +4,7 @@
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Col, Progress, Row, Tooltip } from 'ant-design-vue';
import { Col, Row } from 'ant-design-vue';
import { getAttendanceSummaryPage } from '#/api/hrm/attendance/summary';
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
@@ -17,15 +17,19 @@
const emit = defineEmits<{ navigate: [name: string] }>();
const { canNavigate, canQuery } = useHomeAccess();
const canViewHr = computed(() => canQuery('hrm:attendance-summary:query') || canQuery('hrm:attendance-record:query'));
const canViewStock = computed(() => canQuery('mes:wm-material-stock:query') && canNavigate('MesWmMaterialStock'));
const canViewPurchase = computed(() => canQuery('erp:purchase-order:query') && canNavigate('ErpPurchaseOrder'));
const canViewAttendance =
  computed(() => canQuery('hrm:attendance-summary:query') || canQuery('hrm:attendance-record:query'));
const canViewStock =
  computed(() => canQuery('mes:wm-material-stock:query') && canNavigate('MesWmMaterialStock'));
const canViewPurchase =
  computed(() => canQuery('erp:purchase-order:query') && canNavigate('ErpPurchaseOrder'));
// ========== 出勤指标 ==========
const hrLoading = ref(false);
const headCount = ref(0);
const actualDaysAvg = ref(0);
const attendanceRate = ref(0);
const attendanceRate = ref<number | null>(null);
const averageDays = ref(0);
const abnormalCount = ref(0);
const lateCount = ref(0);
const absentCount = ref(0);
const leaveHours = ref(0);
@@ -36,66 +40,107 @@
}
async function loadAttendance() {
  if (!canViewHr.value) return;
  hrLoading.value = true;
  if (!canViewAttendance.value) return;
  try {
    const res = await getAttendanceSummaryPage({
      pageNo: 1,
      pageSize: 200,
      yearMonth: currentYearMonth(),
    } as any);
    const list = res?.list ?? [];
    headCount.value = res?.total ?? list.length;
    if (list.length > 0) {
      let workSum = 0, actualSum = 0, late = 0, absent = 0, leave = 0;
      for (const r of list) {
    const rows = res?.list ?? [];
    headCount.value = res?.total ?? rows.length;
    if (rows.length > 0) {
      let workSum = 0;
      let actualSum = 0;
      let late = 0;
      let absent = 0;
      let leave = 0;
      for (const r of rows) {
        workSum += Number(r.workDays ?? 0);
        actualSum += Number(r.actualDays ?? 0);
        late += Number(r.lateCount ?? 0);
        absent += Number(r.absentCount ?? 0);
        leave += Number(r.leaveHours ?? 0);
      }
      actualDaysAvg.value = workSum > 0 ? Number((actualSum / list.length).toFixed(1)) : 0;
      attendanceRate.value = workSum > 0 ? Math.min(100, Math.round((actualSum / workSum) * 100)) : 0;
      averageDays.value = Number((actualSum / rows.length).toFixed(1));
      attendanceRate.value = workSum > 0 ? Number(((actualSum / workSum) * 100).toFixed(1)) : null;
      lateCount.value = late;
      absentCount.value = absent;
      leaveHours.value = Number(leave.toFixed(1));
      abnormalCount.value = late + absent;
    }
  } catch { /* 忽略 */ } finally { hrLoading.value = false; }
  } catch {
    /* 忽略 */
  }
}
// ========== 库存采购 ==========
const stockLoading = ref(false);
const stockKinds = ref(0);
// ========== 库存 / 采购 ==========
const stockSkuCount = ref(0);
const stockAvailable = ref(0);
const purchaseCount = ref(0);
const purchaseTotal = ref(0);
const purchasePending = ref(0);
async function loadStock() {
  if (!canViewStock.value) return;
  stockLoading.value = true;
  try {
    const res = await getMaterialStockPage({ pageNo: 1, pageSize: 200 } as any);
    const list = res?.list ?? [];
    stockKinds.value = res?.total ?? list.length;
    stockAvailable.value = Number(list.reduce((s: number, r: any) => s + Number(r.availableQuantity ?? 0), 0).toFixed(0));
  } catch { /* 忽略 */ } finally { stockLoading.value = false; }
    const rows = res?.list ?? [];
    stockSkuCount.value = res?.total ?? rows.length;
    stockAvailable.value = Math.round(
      rows.reduce((sum: number, r: any) => sum + Number(r.availableQuantity ?? 0), 0),
    );
  } catch {
    /* 忽略 */
  }
}
async function loadPurchase() {
  if (!canViewPurchase.value) return;
  try {
    const res = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1 } as any);
    purchaseCount.value = res?.total ?? 0;
    // 待审批数量(状态 10 = 审批中,按 yudao ERP 约定)
    purchaseTotal.value = res?.total ?? 0;
    const pending = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1, status: 10 } as any);
    purchasePending.value = pending?.total ?? 0;
  } catch { /* 忽略 */ }
  } catch {
    /* 忽略 */
  }
}
function goto(name: string) {
  if (canNavigate(name)) emit('navigate', name);
}
/** 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',
  };
}
/** 出勤率展示:无数据用「—」 */
const attendanceRateText = computed(() =>
  attendanceRate.value === null ? '—' : attendanceRate.value.toFixed(1),
);
onMounted(() => {
  loadAttendance();
@@ -105,138 +150,172 @@
</script>
<template>
  <Row :gutter="[16, 16]">
    <!-- ============ 出勤指标 ============ -->
    <Col v-if="canViewHr" :xs="24" :sm="24" :md="24" :lg="12" :xl="12" :xxl="12">
      <div class="glass-card h-full overflow-hidden rounded-2xl p-5">
        <div class="mb-4 flex items-center justify-between">
          <div class="flex items-center gap-2">
            <div class="flex size-9 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-400 shadow-md shadow-violet-500/25">
              <IconifyIcon class="size-5 text-white" icon="lucide:user-check" />
            </div>
            <div>
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">出勤指标</h3>
              <p class="text-xs text-gray-400">本月考勤汇总</p>
            </div>
  <Row :gutter="16">
    <!-- 本月出勤率 -->
    <Col v-if="canViewAttendance" :xs="24" :sm="12" :md="8" :lg="8" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('attendance')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'attendance')"
        @mouseleave="onMouseLeave('attendance')"
      >
        <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:user-check" />
          </div>
          <a-spin v-if="hrLoading" :size="16" />
        </div>
        <div class="mb-4 flex items-center gap-5">
          <Progress
            type="circle"
            :percent="attendanceRate"
            :size="88"
            :stroke-color="{ '0%': '#8b5cf6', '100%': '#c084fc' }"
          />
          <div class="min-w-0 flex-1">
            <div class="text-xs text-gray-500 dark:text-gray-400">出勤率</div>
            <div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
              在册 <span class="font-semibold text-violet-600 dark:text-violet-400">{{ headCount }}</span> 人
              · 人均出勤 <span class="font-semibold text-violet-600 dark:text-violet-400">{{ actualDaysAvg }}</span> 天
            <div class="mb-1 text-xs font-medium text-gray-500 dark:text-gray-400">本月出勤率</div>
            <div class="flex items-baseline gap-1">
              <CountTo
                v-if="attendanceRate !== null"
                class="text-2xl font-bold leading-tight text-violet-600 dark:text-violet-400"
                :end-val="attendanceRate"
                :duration="1200"
                :decimals="1"
              />
              <span
                v-else
                class="text-2xl font-bold leading-tight text-gray-400 dark:text-gray-500"
                >{{ attendanceRateText }}</span
              >
              <span v-if="attendanceRate !== null" 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-violet-400" /> 在册 {{ headCount }}
              <span class="mx-1">|</span>
              <span class="inline-block size-1.5 rounded-full bg-emerald-400" /> 人均
              {{ averageDays }} 天
            </div>
          </div>
        </div>
        <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
          <Tooltip title="本月迟到次数合计">
            <div class="rounded-xl bg-amber-50/70 p-3 text-center dark:bg-amber-500/10">
              <div class="text-lg font-bold text-amber-600 dark:text-amber-400">
                <CountTo :end-val="lateCount" :duration="1000" />
              </div>
              <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">迟到</div>
            </div>
          </Tooltip>
          <Tooltip title="本月缺卡次数合计">
            <div class="rounded-xl bg-rose-50/70 p-3 text-center dark:bg-rose-500/10">
              <div class="text-lg font-bold text-rose-600 dark:text-rose-400">
                <CountTo :end-val="absentCount" :duration="1000" />
              </div>
              <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">缺卡</div>
            </div>
          </Tooltip>
          <Tooltip title="本月请假工时合计">
            <div class="rounded-xl bg-sky-50/70 p-3 text-center dark:bg-sky-500/10">
              <div class="text-lg font-bold text-sky-600 dark:text-sky-400">
                <CountTo :end-val="leaveHours" :duration="1000" :decimals="1" />
              </div>
              <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">请假(h)</div>
            </div>
          </Tooltip>
          <Tooltip title="人均出勤天数">
            <div class="rounded-xl bg-emerald-50/70 p-3 text-center dark:bg-emerald-500/10">
              <div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">
                <CountTo :end-val="actualDaysAvg" :duration="1000" :decimals="1" />
              </div>
              <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">出勤(天)</div>
            </div>
          </Tooltip>
        </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>
    <!-- ============ 库存采购 ============ -->
    <Col v-if="canViewStock || canViewPurchase" :xs="24" :sm="24" :md="24" :lg="12" :xl="12" :xxl="12">
      <div class="glass-card h-full overflow-hidden rounded-2xl p-5">
        <div class="mb-4 flex items-center justify-between">
          <div class="flex items-center gap-2">
            <div class="flex size-9 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-400 shadow-md shadow-cyan-500/25">
              <IconifyIcon class="size-5 text-white" icon="lucide:package" />
    <!-- 本月考勤异常 -->
    <Col v-if="canViewAttendance" :xs="24" :sm="12" :md="8" :lg="8" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0 cursor-pointer"
        :style="tiltStyle('attendance-abnormal')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'attendance-abnormal')"
        @mouseleave="onMouseLeave('attendance-abnormal')"
        @click="goto('HrmAttendanceRecord')"
      >
        <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-amber-500 to-orange-400 shadow-lg shadow-amber-500/25 transition-transform duration-300 group-hover:scale-110"
          >
            <IconifyIcon class="size-6 text-white" icon="lucide:alert-triangle" />
          </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-amber-600 dark:text-amber-400"
                :end-val="abnormalCount"
                :duration="1200"
              />
              <span class="text-xs text-gray-400">次</span>
            </div>
            <div>
              <h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">库存采购</h3>
              <p class="text-xs text-gray-400">仓储与采购概览</p>
            <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" /> 迟到 {{ lateCount }}
              <span class="mx-1">|</span>
              <span class="inline-block size-1.5 rounded-full bg-rose-400" /> 缺卡 {{ absentCount }}
            </div>
          </div>
          <a-spin v-if="stockLoading" :size="16" />
        </div>
        <div
          class="absolute inset-0 -z-0 bg-gradient-to-br from-amber-500/5 to-orange-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
        />
        <div
          class="absolute -right-6 -top-6 size-24 rounded-full bg-amber-400/10 blur-xl transition-all duration-300 group-hover:scale-150"
        />
      </div>
    </Col>
        <Row :gutter="[12, 12]">
          <Col v-if="canViewStock" :xs="12" :sm="12" :md="12" :lg="12" :xl="12" class="cursor-pointer" @click="goto('MesWmMaterialStock')">
            <div class="rounded-xl bg-cyan-50/70 p-4 transition hover:bg-cyan-100/70 dark:bg-cyan-500/10">
              <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
                <IconifyIcon class="size-4 text-cyan-500" icon="lucide:boxes" /> 库存品种
              </div>
              <div class="mt-1 text-2xl font-bold text-cyan-600 dark:text-cyan-400">
                <CountTo :end-val="stockKinds" :duration="1000" />
              </div>
              <div class="text-xs text-gray-400">累计在库物料行</div>
    <!-- 库存可用量 -->
    <Col v-if="canViewStock" :xs="24" :sm="12" :md="8" :lg="8" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0 cursor-pointer"
        :style="tiltStyle('stock')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'stock')"
        @mouseleave="onMouseLeave('stock')"
        @click="goto('MesWmMaterialStock')"
      >
        <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-cyan-500 to-teal-400 shadow-lg shadow-cyan-500/25 transition-transform duration-300 group-hover:scale-110"
          >
            <IconifyIcon class="size-6 text-white" icon="lucide:package" />
          </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-cyan-600 dark:text-cyan-400"
                :end-val="stockAvailable"
                :duration="1200"
              />
            </div>
          </Col>
          <Col v-if="canViewStock" :xs="12" :sm="12" :md="12" :lg="12" :xl="12">
            <div class="rounded-xl bg-teal-50/70 p-4 dark:bg-teal-500/10">
              <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
                <IconifyIcon class="size-4 text-teal-500" icon="lucide:warehouse" /> 可用数量
              </div>
              <div class="mt-1 text-2xl font-bold text-teal-600 dark:text-teal-400">
                <CountTo :end-val="stockAvailable" :duration="1000" />
              </div>
              <div class="text-xs text-gray-400">扣除冻结与占用</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-cyan-400" /> 品种 {{ stockSkuCount }}
              <span class="mx-1">|</span> 扣除冻结占用
            </div>
          </Col>
          <Col v-if="canViewPurchase" :xs="12" :sm="12" :md="12" :lg="12" :xl="12" class="cursor-pointer" @click="goto('ErpPurchaseOrder')">
            <div class="rounded-xl bg-indigo-50/70 p-4 transition hover:bg-indigo-100/70 dark:bg-indigo-500/10">
              <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
                <IconifyIcon class="size-4 text-indigo-500" icon="lucide:shopping-cart" /> 采购订单
              </div>
              <div class="mt-1 text-2xl font-bold text-indigo-600 dark:text-indigo-400">
                <CountTo :end-val="purchaseCount" :duration="1000" />
              </div>
              <div class="text-xs text-gray-400">累计订单笔数</div>
          </div>
        </div>
        <div
          class="absolute inset-0 -z-0 bg-gradient-to-br from-cyan-500/5 to-teal-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
        />
        <div
          class="absolute -right-6 -top-6 size-24 rounded-full bg-cyan-400/10 blur-xl transition-all duration-300 group-hover:scale-150"
        />
      </div>
    </Col>
    <!-- 采购订单 -->
    <Col v-if="canViewPurchase" :xs="24" :sm="12" :md="8" :lg="8" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group h-full overflow-hidden rounded-2xl border-0 cursor-pointer"
        :style="tiltStyle('purchase')"
        @mousemove="(e: MouseEvent) => onMouseMove(e, 'purchase')"
        @mouseleave="onMouseLeave('purchase')"
        @click="goto('ErpPurchaseOrder')"
      >
        <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-indigo-500 to-blue-400 shadow-lg shadow-indigo-500/25 transition-transform duration-300 group-hover:scale-110"
          >
            <IconifyIcon class="size-6 text-white" icon="lucide:shopping-cart" />
          </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-indigo-600 dark:text-indigo-400"
                :end-val="purchaseTotal"
                :duration="1200"
              />
              <span class="text-xs text-gray-400">笔</span>
            </div>
          </Col>
          <Col v-if="canViewPurchase" :xs="12" :sm="12" :md="12" :lg="12" :xl="12">
            <div class="rounded-xl bg-orange-50/70 p-4 dark:bg-orange-500/10">
              <div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
                <IconifyIcon class="size-4 text-orange-500" icon="lucide:clock" /> 待审批
              </div>
              <div class="mt-1 text-2xl font-bold text-orange-600 dark:text-orange-400">
                <CountTo :end-val="purchasePending" :duration="1000" />
              </div>
              <div class="text-xs text-gray-400">审批中的采购单</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-orange-400" /> 待审批
              {{ purchasePending }}
            </div>
          </Col>
        </Row>
          </div>
        </div>
        <div
          class="absolute inset-0 -z-0 bg-gradient-to-br from-indigo-500/5 to-blue-500/5 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
        />
        <div
          class="absolute -right-6 -top-6 size-24 rounded-full bg-indigo-400/10 blur-xl transition-all duration-300 group-hover:scale-150"
        />
      </div>
    </Col>
  </Row>