xiaoyi
2026-10-30 f56d41d87f6b126812bd8eb08be9e662a2834871
feat(dashboard): BI 看板新增出勤指标与库存采购,优化全看板自适应

## 新增两个看板模块

### 1. 出勤指标(本月考勤汇总)
数据源 GET /hrm/attendance/summary/page(按当前年月聚合全部员工)

展示:
- 出勤率环形进度条(实际出勤天数 / 应出勤天数)
- 在册人数、人均出勤天数
- 迟到次数、缺卡次数、请假工时(h)、出勤天数 四个指标块

### 2. 库存采购
数据源 /mes/wm/material-stock/page、/erp/purchase-order/page

展示:
- 库存品种(台账行数)、可用数量(扣除冻结与占用)
- 采购订单笔数、待审批笔数(status=10)
- 库存与采购卡片可点击跳转对应模块

两个模块均按权限控制显示,无权限不渲染。

## 自适应优化

统一断点策略 xs(手机) / sm(平板竖) / md(平板横) / lg(小屏) / xl(中大屏) / xxl(宽屏):

| 模块 | 优化前 | 优化后 |
|---|---|---|
| KPI 卡片 | sm 就两列,xl 只 4 列 | 手机单列、平板 3 列、中大屏 6 列,宽屏利用更充分 |
| 业务卡片 | sm 两列易挤压 | 补齐 xxl 断点 |
| 快捷操作 | 手机固定 4 列偏挤 | 手机 3 列、平板 4 列、lg 8 列,图标小屏缩小 |
| 欢迎栏 | 固定横排 | 手机纵向堆叠、sm 起横排 |
| 生产趋势/预警 | 缺 xxl | 补齐断点 |

## 验证
- vue-tsc:无新增错误(基线 318 不变)
- 构建通过,已部署生产
- 前端 HTTP 200
已添加1个文件
已修改4个文件
271 ■■■■■ 文件已修改
src/views/dashboard/analytics/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/biz-cards.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/hr-stock-cards.vue 243 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/kpi-cards.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/modules/quick-actions.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/dashboard/analytics/index.vue
@@ -13,6 +13,7 @@
import AiInsights from './modules/ai-insights.vue';
import AlertPanel from './modules/alert-panel.vue';
import BizCards from './modules/biz-cards.vue';
import HrStockCards from './modules/hr-stock-cards.vue';
import KpiCards from './modules/kpi-cards.vue';
import ProductionTrend from './modules/production-trend.vue';
import QuickActions from './modules/quick-actions.vue';
@@ -58,7 +59,7 @@
<template>
  <Page auto-content-height>
    <!-- æ¬¢è¿Žæ  -->
    <div class="glass-card mb-6 flex items-center justify-between rounded-2xl p-6">
    <div class="glass-card mb-6 flex flex-col gap-3 rounded-2xl p-5 sm:flex-row sm:items-center sm:justify-between sm:p-6">
      <div>
        <h1 class="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-500 bg-clip-text text-2xl font-bold text-transparent dark:from-blue-400 dark:via-purple-400 dark:to-pink-400">
          {{ greeting }},欢迎回来
@@ -82,6 +83,9 @@
    <!-- ERP/WMS/BPM ç»è¥ KPI -->
    <BizCards @navigate="handleNavigate" />
    <!-- å‡ºå‹¤æŒ‡æ ‡ + åº“存采购 -->
    <HrStockCards class="mb-4 block" @navigate="handleNavigate" />
    <!-- AI æ™ºèƒ½åˆ†æž -->
    <AiInsights
      v-if="canViewHome && canNavigate('MesProWorkOrder') && isAiEnable()"
@@ -91,7 +95,7 @@
    <!-- ç”Ÿäº§è¶‹åŠ¿ + é¢„警面板 -->
    <Row v-if="canViewHome" :gutter="16" class="mb-4">
      <Col :lg="14" :md="24" :sm="24" :xl="14" :xs="24" class="mb-4">
      <Col :xs="24" :sm="24" :md="24" :lg="14" :xl="14" :xxl="14" class="mb-4">
        <div class="glass-card rounded-2xl p-5">
          <h3 class="mb-4 text-base font-semibold text-gray-800 dark:text-gray-100">
            ç”Ÿäº§è¶‹åŠ¿ï¼ˆè¿‘7天)
@@ -99,7 +103,7 @@
          <ProductionTrend />
        </div>
      </Col>
      <Col :lg="10" :md="24" :sm="24" :xl="10" :xs="24" class="mb-4">
      <Col :xs="24" :sm="24" :md="24" :lg="10" :xl="10" :xxl="10" class="mb-4">
        <AlertPanel
          :repair-active-count="summary.repairActiveCount"
          @navigate="handleNavigate"
src/views/dashboard/analytics/modules/biz-cards.vue
@@ -96,7 +96,7 @@
<template>
  <Row v-if="canViewPurchase || canViewSale || canViewStock || canViewBpmTodo" :gutter="16">
    <!-- é‡‡è´­è®¢å• -->
    <Col v-if="canViewPurchase" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewPurchase" :xs="24" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('purchase')"
@@ -123,7 +123,7 @@
    </Col>
    <!-- é”€å”®è®¢å• -->
    <Col v-if="canViewSale" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewSale" :xs="24" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('sale')"
@@ -150,7 +150,7 @@
    </Col>
    <!-- ç‰©æ–™åº“å­˜ -->
    <Col v-if="canViewStock" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewStock" :xs="24" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('stock')"
@@ -177,7 +177,7 @@
    </Col>
    <!-- BPM å¾…办 -->
    <Col v-if="canViewBpmTodo" :lg="6" :md="12" :sm="12" :xl="6" :xs="24" class="mb-4">
    <Col v-if="canViewBpmTodo" :xs="24" :sm="12" :md="12" :lg="6" :xl="6" :xxl="6" class="mb-4">
      <div
        class="glass-tilt group cursor-pointer overflow-hidden rounded-2xl border-0"
        :style="tiltStyle('bpm')"
src/views/dashboard/analytics/modules/hr-stock-cards.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,243 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { CountTo } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Col, Progress, Row, Tooltip } from 'ant-design-vue';
import { getAttendanceSummaryPage } from '#/api/hrm/attendance/summary';
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import { getPurchaseOrderPage } from '#/api/erp/purchase/order';
import { useHomeAccess } from '../use-home-access';
defineOptions({ name: 'DashboardHrStockCards' });
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 hrLoading = ref(false);
const headCount = ref(0);
const actualDaysAvg = ref(0);
const attendanceRate = ref(0);
const lateCount = ref(0);
const absentCount = ref(0);
const leaveHours = ref(0);
function currentYearMonth() {
  const d = new Date();
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
async function loadAttendance() {
  if (!canViewHr.value) return;
  hrLoading.value = true;
  try {
    const res = await getAttendanceSummaryPage({
      pageNo: 1,
      pageSize: 500,
      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) {
        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;
      lateCount.value = late;
      absentCount.value = absent;
      leaveHours.value = Number(leave.toFixed(1));
    }
  } catch { /* å¿½ç•¥ */ } finally { hrLoading.value = false; }
}
// ========== åº“存采购 ==========
const stockLoading = ref(false);
const stockKinds = ref(0);
const stockAvailable = ref(0);
const purchaseCount = ref(0);
const purchasePending = ref(0);
async function loadStock() {
  if (!canViewStock.value) return;
  stockLoading.value = true;
  try {
    const res = await getMaterialStockPage({ pageNo: 1, pageSize: 500 } 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; }
}
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 çº¦å®šï¼‰
    const pending = await getPurchaseOrderPage({ pageNo: 1, pageSize: 1, status: 10 } as any);
    purchasePending.value = pending?.total ?? 0;
  } catch { /* å¿½ç•¥ */ }
}
function goto(name: string) {
  if (canNavigate(name)) emit('navigate', name);
}
onMounted(() => {
  loadAttendance();
  loadStock();
  loadPurchase();
});
</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>
          </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>
          </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>
    </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" />
            </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>
          </div>
          <a-spin v-if="stockLoading" :size="16" />
        </div>
        <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>
            </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>
          </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>
          </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>
          </Col>
        </Row>
      </div>
    </Col>
  </Row>
</template>
src/views/dashboard/analytics/modules/kpi-cards.vue
@@ -111,7 +111,7 @@
<template>
  <Row :gutter="16">
    <!-- ç”Ÿäº§è®¢å• -->
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
    <Col :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"
        :class="{ 'cursor-pointer': canNavigate('MesProWorkOrder') }"
@@ -143,7 +143,7 @@
    </Col>
    <!-- ä»Šæ—¥äº§é‡ -->
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
    <Col :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"
        :class="{ 'cursor-pointer': canNavigate('MesProFeedback') }"
@@ -225,7 +225,7 @@
    </Col>
    <!-- è®¾å¤‡è¿è¡Œçއ -->
    <Col :lg="6" :md="12" :sm="12" :xl="4" :xs="24" class="mb-4">
    <Col :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"
        :class="{ 'cursor-pointer': canNavigate('MesDvMachinery') }"
src/views/dashboard/analytics/modules/quick-actions.vue
@@ -37,7 +37,7 @@
<template>
  <div v-if="accessibleActions.length" class="glass-card rounded-2xl p-5">
    <h3 class="mb-4 text-sm font-semibold text-gray-800 dark:text-gray-100">快捷操作</h3>
    <div class="grid grid-cols-4 gap-3 sm:grid-cols-8">
    <div class="grid grid-cols-3 gap-2 sm:grid-cols-4 sm:gap-3 md:grid-cols-6 lg:grid-cols-8">
      <div
        v-for="action in accessibleActions"
        :key="action.route"
@@ -45,7 +45,7 @@
        @click="emit('navigate', action.route)"
      >
        <div
          class="flex size-10 items-center justify-center rounded-xl bg-gradient-to-br transition-shadow duration-300 group-hover:shadow-lg"
          class="flex size-9 items-center justify-center rounded-xl bg-gradient-to-br transition-shadow duration-300 group-hover:shadow-lg sm:size-10"
          :class="[action.gradient, action.shadow]"
        >
          <IconifyIcon :icon="action.icon" class="size-5 text-white" />