zhangwencui
6 天以前 4d62184a9b98a65c78c3aac586fcda2855cf87f4
Merge branch 'master' into dev_新高电力

# Conflicts:
# src/config/company.ts
# src/manifest.json
# src/pages/hrm/attendance-clock/index.vue
# src/pages/workbench/index.vue
已添加4个文件
已修改20个文件
1908 ■■■■■ 文件已修改
src/api/hrm.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/company.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/process-instance/create/form.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/process-instance/create/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/changhui-power.vue 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/kangyi-intelligent.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/lanyu-paper.vue 1083 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/use-company-dashboard.ts 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/home/zhongyi-assembly.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-clock/index.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-record/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/form.scss 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/leave/form.vue 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/leave/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/list.scss 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/resignation/form.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/process-design/components/PickerSheet.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/qc/ipqc/form.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/qc/iqc/form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/qc/oqc/form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/qc/rqc/form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/workbench/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/theme.ts 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm.ts
@@ -59,6 +59,7 @@
  id?: number; userId?: number; userName?: string; deptId?: number; deptName?: string; phone?: string; date?: string;
  clockInTime?: string; clockOutTime?: string; clockInType?: number; clockInTypeName?: string; clockOutType?: number;
  clockOutTypeName?: string; workHours?: number; overtimeHours?: number; location?: string; dataSource?: number;
  leaveFlag?: boolean; leaveHours?: number; leaveTypes?: string;
  dataSourceName?: string; remark?: string; createTime?: string;
}
export interface AttendanceClockable {
@@ -67,6 +68,7 @@
export interface AttendanceMyStatus {
  userId?: number; employeeId?: number; employeeName?: string; deptId?: number; deptName?: string;
  date?: string; serverTime?: string; workDay?: boolean; restDayReason?: string | null;
  onLeave?: boolean; leaveHours?: number; leaveTypeNames?: string;
  rule?: AttendanceRule | null; record?: AttendanceRecord | null; clockable?: AttendanceClockable;
}
export interface AttendanceClockRequest {
src/config/company.ts
@@ -1,7 +1,7 @@
export type HomeVariant = "classic" | "north-tech" | "xingao-power" | "zhongyi-assembly";
export type HomeVariant = "classic" | "changhui-power" | "kangyi-intelligent" | "lanyu-paper" | "north-tech" | "xingao-power" | "zhongyi-assembly";
/** æ‰“包目标:改 `ACTIVE_COMPANY` ä¸€è¡Œï¼Œé¦–页版本与后端地址同时切换。 */
export type CompanyCode = "main" | "trt" | "beifang" | "xingao" | "zhongyi";
export type CompanyCode = "main" | "trt" | "changhui" | "kangyi" | "lanyu" | "beifang" | "xingao" | "zhongyi";
export interface AppCompanyConfig {
  /** å…¬å¸æ ‡è¯†ï¼Œä¸Ž CompanyCode ä¸€è‡´ï¼Œä¾¿äºŽæ‰“日志 / ç¼“存命名空间 */
@@ -16,7 +16,7 @@
}
/**
 * äº”套环境对照表。
 * å…«å¥—环境对照表。
 * - apiBaseUrl ç»Ÿä¸€ä»¥ `/admin-api` ç»“尾(yudao åŽå°æŽ¥å£å‰ç¼€ï¼‰ã€‚
 * - webBaseUrl:只有主分支(MOM æ™ºèƒ½æ™ºé€ ï¼‰æ˜¯æ—¢æœ‰é…ç½®ï¼ˆ9501);其余按主分支(MOM æ™ºèƒ½æ™ºé€ ï¼‰â€œæŽ¥å£ç«¯å£ +1”的部署规律
 *   æŽ¨å¯¼ï¼ˆ9500 â†’ 9501)。9017 / 9027 / 9025 å®žæµ‹èƒ½æ‰“开后台,1.95.78.124:9007 ç›®å‰è¿žä¸ä¸Š
@@ -38,6 +38,27 @@
    apiBaseUrl: "http://36.213.150.237:9016/admin-api",
    webBaseUrl: "http://36.213.150.237:9017",
  },
  changhui: {
    key: "changhui",
    name: "新疆长辉电力",
    homeVariant: "changhui-power",
    apiBaseUrl: "http://101.245.92.159:9006/admin-api",
    webBaseUrl: "http://101.245.92.159:9006",
  },
  kangyi: {
    key: "kangyi",
    name: "康意智能",
    homeVariant: "kangyi-intelligent",
    apiBaseUrl: "http://101.245.92.159:9006/admin-api",
    webBaseUrl: "http://101.245.92.159:9008",
  },
  lanyu: {
    key: "lanyu",
    name: "蓝羽纸业",
    homeVariant: "lanyu-paper",
    apiBaseUrl: "http://36.213.150.237:9020/admin-api",
    webBaseUrl: "http://36.213.150.237:9019",
  },
  beifang: {
    key: "beifang",
    name: "北方科技",
src/pages/bpm/process-instance/create/form.vue
@@ -34,6 +34,8 @@
  import type { SheetOption } from "../../../mes/process-design/shared";
  const { state } = useAuth();
  const SELECTED_DEFINITION_KEY = "bpm_create_selected_definition";
  const selectedDefinition = ref<BpmProcessDefinition>();
  const definitionId = ref("");
  /** è°ƒç”¨æ–¹æ˜¾å¼ä¼ å…¥çš„æµç¨‹å®šä¹‰ id(从列表页选择 / é‡æ–°å‘起时解析出的最新版本) */
@@ -367,8 +369,8 @@
      // å®šä¹‰ä¼˜å…ˆçº§ï¼šè°ƒç”¨æ–¹æ˜¾å¼æŒ‡å®šçš„ id(已是最新版本)
      //   > å®žä¾‹ key å¯¹åº”的最新版本 > å®žä¾‹ç»‘定的定义 id
      let def: BpmProcessDefinition | undefined;
      if (explicitDefinitionId.value) {
      let def: BpmProcessDefinition | undefined = selectedDefinition.value;
      if (!def?.id && explicitDefinitionId.value) {
        def = await getProcessDefinition(state.accessToken, { id: explicitDefinitionId.value });
      }
      if (!def?.id && instanceKey) {
@@ -492,6 +494,15 @@
    explicitDefinitionId.value = query?.id ? String(query.id) : "";
    definitionId.value = explicitDefinitionId.value;
    processInstanceId.value = query?.processInstanceId ? String(query.processInstanceId) : "";
    try {
      const cached = uni.getStorageSync(SELECTED_DEFINITION_KEY) as BpmProcessDefinition | undefined;
      if (cached?.id && String(cached.id) === explicitDefinitionId.value) {
        selectedDefinition.value = cached;
      }
      uni.removeStorageSync(SELECTED_DEFINITION_KEY);
    } catch {
      selectedDefinition.value = undefined;
    }
    initForm();
  });
src/pages/bpm/process-instance/create/index.vue
@@ -11,6 +11,7 @@
  import { toast } from "../../shared";
  const { state } = useAuth();
  const SELECTED_DEFINITION_KEY = "bpm_create_selected_definition";
  const categories = ref<BpmCategory[]>([]);
  const definitions = ref<BpmProcessDefinition[]>([]);
@@ -82,6 +83,9 @@
      toast("流程定义信息不完整");
      return;
    }
    // ä¸Ž Web ç«¯ä¸€è‡´ï¼Œç›´æŽ¥å¤ç”¨åˆ—表接口已经返回的表单配置。
    // æµç¨‹å®šä¹‰è¯¦æƒ…接口异常时,也不应阻断填写表单。
    uni.setStorageSync(SELECTED_DEFINITION_KEY, definition);
    uni.navigateTo({
      url: `/pages/bpm/process-instance/create/form?id=${encodeURIComponent(String(definition.id))}`,
    });
src/pages/home/changhui-power.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { APP_COMPANY } from "../../config/company";
import { useCompanyDashboard } from "./use-company-dashboard";
const {
  attendanceOverview, equipmentRate, formatDate, goLogin, goMessages, greeting, isLoggedIn, loadDashboard,
  loadError, loading, notifyState, openRoute, outputChange, qualityRate, statusPercent,
  summary, supplyOverview, today, totalWorkOrders, trend, trendMax, userName, visibleActions, workOrderStatus,
} = useCompanyDashboard();
</script>
<template>
  <view class="changhui-page">
    <template v-if="isLoggedIn">
      <view class="page-header">
        <view class="header-bar">
          <view class="company-brand">
            <view class="brand-mark">CH</view>
            <view class="brand-copy">
              <text>{{ APP_COMPANY.name }}</text>
              <text>生产管理工作台</text>
            </view>
          </view>
          <view class="message-button" @click="goMessages">
            <text>消息</text>
            <view v-if="notifyState.unreadCount" class="message-badge">{{ notifyState.unreadCount > 99 ? '99+' : notifyState.unreadCount }}</view>
          </view>
        </view>
        <view class="welcome-row">
          <view>
            <text class="welcome-title">{{ greeting }},{{ userName }}</text>
            <text class="welcome-date">{{ today }}</text>
          </view>
          <view class="running-state"><i />生产运行中</view>
        </view>
      </view>
      <view class="page-body">
        <view v-if="loadError" class="error-card" @click="loadDashboard">
          <text>{{ loadError }}</text><text>点击重新加载</text>
        </view>
        <template v-else>
          <view class="overview-card">
            <view class="card-heading">
              <view><text>今日概况</text><text>{{ loading ? '数据更新中' : '实时数据' }}</text></view>
              <text>›</text>
            </view>
            <view class="overview-grid">
              <view class="overview-item" @click="openRoute('/pages/mes/pro/workorder/index')">
                <strong>{{ summary?.workOrderActiveCount ?? '--' }}</strong><text>进行中工单</text>
              </view>
              <view class="overview-item" @click="openRoute('/pages/mes/pro/feedback/index')">
                <strong>{{ summary?.todayOutput ?? '--' }}</strong><text>今日产量</text>
              </view>
              <view class="overview-item">
                <strong>{{ qualityRate }}<small>%</small></strong><text>质量合格率</text>
              </view>
              <view class="overview-item">
                <strong>{{ equipmentRate }}<small>%</small></strong><text>设备运行率</text>
              </view>
            </view>
            <view class="overview-note">
              <text>待排产 {{ summary?.workOrderPrepareCount ?? 0 }} å•</text>
              <text :class="outputChange >= 0 ? 'positive' : 'negative'">较昨日 {{ outputChange >= 0 ? '+' : '' }}{{ outputChange }}</text>
            </view>
          </view>
          <view class="section-heading">
            <text>快捷功能</text>
            <text @click="openRoute('/pages/workbench/index')">全部功能</text>
          </view>
          <view class="quick-card">
            <view v-for="item in visibleActions" :key="item.component" class="quick-item" @click="openRoute(item.route)">
              <view :class="['quick-icon', `tone-${item.tone}`]">{{ item.mark }}</view>
              <text>{{ item.name }}</text>
            </view>
          </view>
          <view v-if="attendanceOverview" class="section-heading"><text>本月出勤</text><text @click="openRoute('/pages/hrm/attendance-summary/index')">查看汇总</text></view>
          <view v-if="attendanceOverview" class="metric-card">
            <view><strong>{{ attendanceOverview.headCount }}</strong><text>统计人数</text></view>
            <view><strong>{{ attendanceOverview.attendanceRate }}<small>%</small></strong><text>出勤率</text></view>
            <view><strong>{{ attendanceOverview.averageActualDays }}</strong><text>人均出勤天数</text></view>
            <view><strong>{{ attendanceOverview.lateCount }}</strong><text>迟到次数</text></view>
            <view><strong>{{ attendanceOverview.absentCount }}</strong><text>缺勤次数</text></view>
            <view><strong>{{ attendanceOverview.leaveHours }}</strong><text>请假小时</text></view>
          </view>
          <view v-if="supplyOverview" class="section-heading"><text>库存与采购</text><text>实时汇总</text></view>
          <view v-if="supplyOverview" class="metric-card supply-card">
            <view @click="openRoute('/pages/wls/material-stock/index')"><strong>{{ supplyOverview.stockQuantity }}</strong><text>在库总量</text></view>
            <view @click="openRoute('/pages/wls/material-stock/index')"><strong>{{ supplyOverview.availableQuantity }}</strong><text>可用库存</text></view>
            <view @click="openRoute('/pages/purchase/order/index')"><strong>{{ supplyOverview.purchaseTotal }}</strong><text>采购订单</text></view>
            <view @click="openRoute('/pages/purchase/order/index')"><strong>{{ supplyOverview.pendingPurchaseTotal }}</strong><text>待处理采购</text></view>
          </view>
          <view class="section-heading"><text>生产趋势</text><text>近七日</text></view>
          <view class="trend-card">
            <view v-if="trend.length" class="trend-chart">
              <view v-for="item in trend" :key="item.date" class="trend-column">
                <text>{{ item.quantity }}</text>
                <view class="trend-track"><view :style="{ height: `${Math.max(item.quantity / trendMax * 100, 4)}%` }" /></view>
                <text>{{ formatDate(item.date) }}</text>
              </view>
            </view>
            <view v-else class="empty-state">暂无生产趋势数据</view>
          </view>
          <view class="section-heading"><text>工单状态</text><text>共 {{ totalWorkOrders }} å•</text></view>
          <view class="status-card">
            <view v-for="(item, index) in workOrderStatus" :key="item.status" class="status-row">
              <view class="status-name"><i :class="`dot-${index % 4}`" />{{ item.statusName }}</view>
              <view class="status-track"><view :class="`bar-${index % 4}`" :style="{ width: `${statusPercent(item.count)}%` }" /></view>
              <strong>{{ item.count }}</strong>
            </view>
            <view v-if="!workOrderStatus.length" class="empty-state">暂无工单状态数据</view>
          </view>
          <view class="repair-card" @click="openRoute('/pages/mes/dv/repair/index')">
            <view class="repair-icon">ä¿®</view>
            <view><text>设备维修任务</text><text>查看待处理的设备保障任务</text></view>
            <view class="repair-count"><strong>{{ summary?.repairActiveCount ?? 0 }}</strong><text>项待处理</text></view>
            <text class="repair-arrow">›</text>
          </view>
        </template>
      </view>
    </template>
    <view v-else class="login-page">
      <view class="login-mark">CH</view>
      <text>{{ APP_COMPANY.name }}</text>
      <text>生产管理移动工作台</text>
      <button @click="goLogin">登录系统</button>
    </view>
  </view>
</template>
<style scoped lang="scss">
.changhui-page{min-height:100vh;padding-bottom:150rpx;background:#f3f6fa;color:#1d2b3c}.page-header{padding:calc(var(--status-bar-height) + 22rpx) 30rpx 34rpx;background:linear-gradient(135deg,#1264b4,#2485d5);color:#fff}.header-bar,.company-brand,.welcome-row,.overview-note,.section-heading,.status-row,.repair-card{display:flex;align-items:center}.header-bar,.welcome-row,.section-heading{justify-content:space-between}.company-brand{gap:15rpx}.brand-mark{width:60rpx;height:60rpx;display:flex;align-items:center;justify-content:center;border-radius:12rpx;background:#fff;color:#176dbc;font-size:21rpx;font-weight:800}.brand-copy text{display:block}.brand-copy text:first-child{font-size:29rpx;font-weight:700}.brand-copy text:last-child{margin-top:2rpx;color:rgba(255,255,255,.72);font-size:19rpx}.message-button{position:relative;padding:13rpx 18rpx;border-radius:24rpx;background:rgba(255,255,255,.15);font-size:20rpx}.message-badge{position:absolute;right:-8rpx;top:-9rpx;min-width:25rpx;height:25rpx;padding:0 5rpx;box-sizing:border-box;border-radius:14rpx;background:#ff6b59;color:#fff;font-size:15rpx;line-height:25rpx;text-align:center}.welcome-row{margin-top:42rpx}.welcome-title,.welcome-date{display:block}.welcome-title{font-size:35rpx;font-weight:700}.welcome-date{margin-top:9rpx;color:rgba(255,255,255,.76);font-size:21rpx}.running-state{padding:11rpx 15rpx;border-radius:8rpx;background:rgba(255,255,255,.14);font-size:18rpx}.running-state i{display:inline-block;width:9rpx;height:9rpx;margin-right:8rpx;border-radius:50%;background:#7ef0bb}
.page-body{padding:24rpx 26rpx}.overview-card,.quick-card,.trend-card,.status-card,.repair-card{border:1rpx solid #e4ebf2;border-radius:18rpx;background:#fff;box-shadow:0 6rpx 22rpx rgba(34,70,105,.05)}.overview-card{padding:24rpx}.card-heading{display:flex;align-items:center;justify-content:space-between}.card-heading>view text{display:block}.card-heading>view text:first-child{font-size:27rpx;font-weight:700}.card-heading>view text:last-child{margin-top:4rpx;color:#94a2b2;font-size:18rpx}.card-heading>text{color:#9aa8b8;font-size:34rpx}.overview-grid{display:grid;grid-template-columns:1fr 1fr;margin-top:23rpx;border-top:1rpx solid #edf1f5;border-left:1rpx solid #edf1f5}.overview-item{padding:23rpx 20rpx;border-right:1rpx solid #edf1f5;border-bottom:1rpx solid #edf1f5}.overview-item strong,.overview-item text{display:block}.overview-item strong{color:#1d5f9e;font-size:40rpx;line-height:1.15}.overview-item small{margin-left:3rpx;font-size:18rpx}.overview-item text{margin-top:7rpx;color:#758495;font-size:19rpx}.overview-note{justify-content:space-between;padding-top:18rpx;color:#8492a2;font-size:18rpx}.positive{color:#1aa578}.negative{color:#e06055}
.section-heading{margin:31rpx 3rpx 14rpx}.section-heading>text:first-child{font-size:27rpx;font-weight:700}.section-heading>text:last-child{color:#8997a7;font-size:19rpx}.quick-card{display:grid;grid-template-columns:repeat(3,1fr);padding:12rpx 8rpx}.quick-item{padding:17rpx 4rpx;display:flex;align-items:center;flex-direction:column}.quick-icon{width:68rpx;height:68rpx;display:flex;align-items:center;justify-content:center;border-radius:18rpx;color:#2875bd;background:#eaf4ff;font-size:24rpx;font-weight:700}.quick-item>text{margin-top:11rpx;color:#3b4857;font-size:20rpx}.tone-teal,.tone-green{color:#178b76;background:#e7f7f2}.tone-violet{color:#6955ba;background:#f0edff}.tone-amber{color:#ad7723;background:#fff4df}.tone-cyan{color:#1c8199;background:#e7f6fa}.tone-slate{color:#5c7085;background:#edf2f6}
.trend-card{padding:24rpx 18rpx}.trend-chart{height:235rpx;display:flex;align-items:flex-end;gap:9rpx}.trend-column{height:100%;min-width:0;display:flex;flex:1;align-items:center;flex-direction:column;justify-content:flex-end}.trend-column>text:first-child{height:30rpx;color:#3e668a;font-size:16rpx}.trend-column>text:last-child{margin-top:9rpx;color:#98a5b3;font-size:16rpx}.trend-track{width:25rpx;height:145rpx;display:flex;align-items:flex-end;border-radius:6rpx;background:#eef3f7}.trend-track view{width:100%;border-radius:6rpx;background:linear-gradient(180deg,#62afea,#267bc3)}.status-card{padding:16rpx 22rpx}.status-row{gap:16rpx;min-height:68rpx}.status-name{width:145rpx;color:#526273;font-size:19rpx}.status-name i{display:inline-block;width:9rpx;height:9rpx;margin-right:9rpx;border-radius:50%}.dot-0,.bar-0{background:#3288cf}.dot-1,.bar-1{background:#35aa86}.dot-2,.bar-2{background:#e2a547}.dot-3,.bar-3{background:#8d79c8}.status-track{height:8rpx;flex:1;overflow:hidden;border-radius:5rpx;background:#edf1f5}.status-track view{height:100%;border-radius:inherit}.status-row strong{width:42rpx;color:#30465b;font-size:20rpx;text-align:right}.empty-state{padding:43rpx 0;color:#98a5b3;font-size:20rpx;text-align:center}
.repair-card{position:relative;margin-top:22rpx;padding:22rpx}.repair-icon{width:62rpx;height:62rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:16rpx;color:#b5761f;background:#fff2db;font-size:22rpx;font-weight:700}.repair-card>view:nth-child(2){margin-left:16rpx}.repair-card>view:nth-child(2) text{display:block}.repair-card>view:nth-child(2) text:first-child{font-size:23rpx;font-weight:650}.repair-card>view:nth-child(2) text:last-child{margin-top:5rpx;color:#8b98a6;font-size:18rpx}.repair-count{margin-left:auto;text-align:right}.repair-count strong,.repair-count text{display:block}.repair-count strong{color:#cb7b16;font-size:29rpx}.repair-count text{color:#9b8b75;font-size:16rpx}.repair-arrow{margin-left:13rpx;color:#a6b1bd;font-size:32rpx}.error-card{padding:25rpx;border-radius:15rpx;background:#fff1ef;color:#ca5548}.error-card text{display:block;font-size:21rpx}.error-card text:last-child{margin-top:5rpx;color:#a87973;font-size:18rpx}
.login-page{min-height:calc(100vh - 150rpx);display:flex;align-items:center;flex-direction:column;justify-content:center;background:#f3f6fa}.login-mark{width:112rpx;height:112rpx;display:flex;align-items:center;justify-content:center;border-radius:26rpx;color:#fff;background:linear-gradient(135deg,#176abb,#42a0e5);box-shadow:0 14rpx 30rpx rgba(30,111,181,.2);font-size:33rpx;font-weight:800}.login-page>text:nth-child(2){margin-top:26rpx;color:#23384d;font-size:34rpx;font-weight:700}.login-page>text:nth-child(3){margin-top:7rpx;color:#8a99a8;font-size:21rpx}.login-page button{width:360rpx;margin-top:45rpx;border:0;border-radius:40rpx;color:#fff;background:#2178c5;font-size:24rpx}.login-page button::after{border:0}
.metric-card{display:grid;grid-template-columns:repeat(3,1fr);overflow:hidden;border:1rpx solid #e4ebf2;border-radius:18rpx;background:#fff;box-shadow:0 6rpx 22rpx rgba(34,70,105,.05)}.metric-card>view{padding:22rpx 10rpx;border-right:1rpx solid #edf1f5;border-bottom:1rpx solid #edf1f5;text-align:center}.metric-card>view:nth-child(3n){border-right:0}.metric-card strong,.metric-card text{display:block}.metric-card strong{color:#1d5f9e;font-size:31rpx}.metric-card small{font-size:17rpx}.metric-card text{margin-top:7rpx;color:#7d8c9d;font-size:18rpx}.supply-card{grid-template-columns:repeat(2,1fr)}.supply-card>view:nth-child(3n){border-right:1rpx solid #edf1f5}.supply-card>view:nth-child(2n){border-right:0}
</style>
src/pages/home/index.vue
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { APP_COMPANY } from "../../config/company";
import ChanghuiPowerHome from "./changhui-power.vue";
import ClassicHome from "./classic.vue";
import KangyiIntelligentHome from "./kangyi-intelligent.vue";
import LanyuPaperHome from "./lanyu-paper.vue";
import NorthTechHome from "./north-tech.vue";
import XingaoPowerHome from "./xingao-power.vue";
import ZhongyiAssemblyHome from "./zhongyi-assembly.vue";
@@ -8,7 +11,10 @@
<template>
  <XingaoPowerHome v-if="APP_COMPANY.homeVariant === 'xingao-power'" />
  <ChanghuiPowerHome v-else-if="APP_COMPANY.homeVariant === 'changhui-power'" />
  <NorthTechHome v-else-if="APP_COMPANY.homeVariant === 'north-tech'" />
  <ZhongyiAssemblyHome v-else-if="APP_COMPANY.homeVariant === 'zhongyi-assembly'" />
  <KangyiIntelligentHome v-else-if="APP_COMPANY.homeVariant === 'kangyi-intelligent'" />
  <LanyuPaperHome v-else-if="APP_COMPANY.homeVariant === 'lanyu-paper'" />
  <ClassicHome v-else />
</template>
src/pages/home/kangyi-intelligent.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { APP_COMPANY } from "../../config/company";
import { useAppTheme } from "../../stores/theme";
import { useCompanyDashboard } from "./use-company-dashboard";
const { themeStyle } = useAppTheme();
const {
  equipmentRate, formatDate, goLogin, goMessages, greeting, isLoggedIn, loadDashboard,
  loadError, loading, notifyState, openRoute, outputChange, qualityRate, statusPercent,
  summary, today, totalWorkOrders, trend, trendMax, userName, visibleActions, workOrderStatus,
} = useCompanyDashboard();
</script>
<template>
  <view class="kangyi-page" :style="themeStyle">
    <template v-if="isLoggedIn">
      <view class="topbar">
        <view class="wordmark">
          <view class="wordmark-symbol"><i /><i /><i /></view>
          <view><text>{{ APP_COMPANY.name }}</text><text>智能制造协同平台</text></view>
        </view>
        <view class="top-message" @click="goMessages">
          <text>消息</text><i v-if="notifyState.unreadCount" />
        </view>
      </view>
      <view class="main-content">
        <view class="hello-block">
          <view><text>{{ greeting }},{{ userName }}</text><text>{{ today }}</text></view>
          <view class="online-label"><i />系统在线</view>
        </view>
        <view v-if="loadError" class="error-block" @click="loadDashboard">
          <text>{{ loadError }}</text><text>点击重新加载</text>
        </view>
        <template v-else>
          <view class="focus-board">
            <view class="focus-main" @click="openRoute('/pages/mes/pro/feedback/index')">
              <view class="focus-label"><i />今日生产</view>
              <strong>{{ summary?.todayOutput ?? '--' }}</strong>
              <text>今日累计产量</text>
              <view :class="['change-label', outputChange >= 0 ? 'change-up' : 'change-down']">
                è¾ƒæ˜¨æ—¥ {{ outputChange >= 0 ? '+' : '' }}{{ outputChange }}
              </view>
            </view>
            <view class="focus-side">
              <view @click="openRoute('/pages/mes/pro/workorder/index')">
                <text>进行中工单</text><strong>{{ summary?.workOrderActiveCount ?? '--' }}</strong><small>待排产 {{ summary?.workOrderPrepareCount ?? 0 }}</small>
              </view>
              <view @click="openRoute('/pages/mes/dv/repair/index')">
                <text>设备待维修</text><strong>{{ summary?.repairActiveCount ?? 0 }}</strong><small>保障任务</small>
              </view>
            </view>
          </view>
          <view class="data-strip">
            <view><text>质量合格率</text><strong>{{ qualityRate }}%</strong></view>
            <i />
            <view><text>设备运行率</text><strong>{{ equipmentRate }}%</strong></view>
            <i />
            <view><text>已完成工单</text><strong>{{ summary?.workOrderFinishedCount ?? 0 }}</strong></view>
          </view>
          <view class="section-title">
            <view><text>我的工作</text><text>常用业务入口</text></view>
            <text @click="openRoute('/pages/workbench/index')">工作台 â€º</text>
          </view>
          <view class="work-list">
            <view v-for="(item,index) in visibleActions" :key="item.component" class="work-item" @click="openRoute(item.route)">
              <view class="work-number">{{ String(index + 1).padStart(2, '0') }}</view>
              <view :class="['work-mark', `mark-${index % 3}`]">{{ item.mark }}</view>
              <view class="work-copy"><text>{{ item.name }}</text><text>{{ item.detail }}</text></view>
              <text class="work-arrow">›</text>
            </view>
          </view>
          <view class="section-title">
            <view><text>生产节奏</text><text>近七日实际产出</text></view>
            <text>{{ loading ? '更新中' : '已同步' }}</text>
          </view>
          <view class="rhythm-card">
            <view v-if="trend.length" class="rhythm-list">
              <view v-for="item in trend" :key="item.date" class="rhythm-row">
                <text>{{ formatDate(item.date) }}</text>
                <view class="rhythm-track"><view :style="{ width: `${Math.max(item.quantity / trendMax * 100, 3)}%` }" /></view>
                <strong>{{ item.quantity }}</strong>
              </view>
            </view>
            <view v-else class="empty-state">暂无生产趋势数据</view>
          </view>
          <view class="section-title">
            <view><text>工单进度</text><text>各阶段任务分布</text></view>
            <text>共 {{ totalWorkOrders }} å•</text>
          </view>
          <view class="order-card">
            <view v-for="(item,index) in workOrderStatus" :key="item.status" class="order-row">
              <view :class="['order-tag', `tag-${index % 4}`]">{{ item.statusName.slice(0, 1) }}</view>
              <view class="order-info">
                <view><text>{{ item.statusName }}</text><strong>{{ item.count }} å•</strong></view>
                <view class="order-track"><view :class="`order-bar-${index % 4}`" :style="{ width: `${statusPercent(item.count)}%` }" /></view>
              </view>
            </view>
            <view v-if="!workOrderStatus.length" class="empty-state">暂无工单状态数据</view>
          </view>
        </template>
      </view>
    </template>
    <view v-else class="login-view">
      <view class="login-symbol"><i /><i /><i /></view>
      <text>{{ APP_COMPANY.name }}</text>
      <text>智能制造协同平台</text>
      <button @click="goLogin">进入工作台</button>
    </view>
  </view>
</template>
<style scoped lang="scss">
.kangyi-page{min-height:100vh;padding-bottom:150rpx;background:#f4f5f7;color:#202b33}.topbar,.wordmark,.hello-block,.section-title,.work-item,.rhythm-row,.order-row,.order-info>view:first-child{display:flex;align-items:center}.topbar{justify-content:space-between;padding:calc(var(--status-bar-height) + 22rpx) 28rpx 20rpx;background:#fff;border-bottom:1rpx solid #eceef1}.wordmark{gap:14rpx}.wordmark-symbol,.login-symbol{position:relative;background:#183f49}.wordmark-symbol{width:60rpx;height:60rpx;border-radius:9rpx}.wordmark-symbol i,.login-symbol i{position:absolute;border-radius:50%;background:#6ad7bf}.wordmark-symbol i:nth-child(1),.login-symbol i:nth-child(1){width:13rpx;height:13rpx;left:14rpx;top:24rpx}.wordmark-symbol i:nth-child(2),.login-symbol i:nth-child(2){width:9rpx;height:9rpx;left:31rpx;top:13rpx}.wordmark-symbol i:nth-child(3),.login-symbol i:nth-child(3){width:11rpx;height:11rpx;right:11rpx;bottom:14rpx}.wordmark-symbol:before,.wordmark-symbol:after,.login-symbol:before,.login-symbol:after{position:absolute;height:2rpx;content:"";transform-origin:left center;background:#6ad7bf}.wordmark-symbol:before,.login-symbol:before{width:22rpx;left:22rpx;top:27rpx;transform:rotate(-32deg)}.wordmark-symbol:after,.login-symbol:after{width:19rpx;left:33rpx;top:24rpx;transform:rotate(54deg)}.wordmark text{display:block}.wordmark text:first-child{font-size:28rpx;font-weight:750;letter-spacing:1rpx}.wordmark text:last-child{margin-top:3rpx;color:#8b959c;font-size:17rpx}.top-message{position:relative;padding:12rpx 16rpx;border-radius:7rpx;color:#4f626b;background:#f1f4f5;font-size:19rpx}.top-message i{position:absolute;right:-3rpx;top:-4rpx;width:11rpx;height:11rpx;border:3rpx solid #fff;border-radius:50%;background:#e76161}
.main-content{padding:24rpx 27rpx}.hello-block{justify-content:space-between;padding:10rpx 3rpx 24rpx}.hello-block>view:first-child text{display:block}.hello-block>view:first-child text:first-child{font-size:34rpx;font-weight:700}.hello-block>view:first-child text:last-child{margin-top:7rpx;color:#89939a;font-size:20rpx}.online-label{padding:9rpx 13rpx;border-radius:6rpx;color:#357966;background:#e5f4ef;font-size:17rpx}.online-label i{display:inline-block;width:8rpx;height:8rpx;margin-right:7rpx;border-radius:50%;background:#36a581}.focus-board{display:grid;grid-template-columns:1.35fr .9fr;overflow:hidden;border-radius:16rpx;background:#183f49;box-shadow:0 12rpx 26rpx rgba(28,58,66,.13);color:#fff}.focus-main{position:relative;min-height:287rpx;padding:28rpx;border-right:1rpx solid rgba(255,255,255,.12)}.focus-label{color:#9fd5ca;font-size:19rpx}.focus-label i{display:inline-block;width:5rpx;height:19rpx;margin-right:9rpx;border-radius:3rpx;background:#65d6bc}.focus-main>strong{display:block;margin-top:27rpx;font-size:70rpx;line-height:1}.focus-main>text{display:block;margin-top:8rpx;color:#bdd0d4;font-size:19rpx}.change-label{position:absolute;left:27rpx;bottom:24rpx;padding:7rpx 11rpx;border-radius:5rpx;font-size:17rpx}.change-up{color:#99e6d3;background:rgba(78,197,167,.14)}.change-down{color:#ffc5ba;background:rgba(226,105,82,.14)}.focus-side{display:flex;flex-direction:column}.focus-side>view{min-height:143rpx;box-sizing:border-box;padding:23rpx}.focus-side>view:first-child{border-bottom:1rpx solid rgba(255,255,255,.12)}.focus-side text,.focus-side strong,.focus-side small{display:block}.focus-side text{color:#a8c1c6;font-size:17rpx}.focus-side strong{margin:5rpx 0;color:#fff;font-size:38rpx}.focus-side small{color:#73969e;font-size:16rpx}.data-strip{display:flex;align-items:center;margin-top:15rpx;padding:20rpx 10rpx;border:1rpx solid #e6e9eb;border-radius:13rpx;background:#fff}.data-strip>view{min-width:0;flex:1;text-align:center}.data-strip text,.data-strip strong{display:block}.data-strip text{color:#90999f;font-size:16rpx}.data-strip strong{margin-top:4rpx;color:#293b43;font-size:25rpx}.data-strip>i{width:1rpx;height:44rpx;background:#e9ecee}
.section-title{justify-content:space-between;margin:32rpx 3rpx 14rpx}.section-title>view text{display:block}.section-title>view text:first-child{font-size:27rpx;font-weight:700}.section-title>view text:last-child{margin-top:3rpx;color:#9aa2a8;font-size:16rpx}.section-title>text{color:#76878d;font-size:18rpx}.work-list{display:grid;grid-template-columns:1fr 1fr;gap:11rpx}.work-item{position:relative;min-width:0;padding:20rpx 15rpx;border:1rpx solid #e4e8ea;border-radius:12rpx;background:#fff}.work-number{position:absolute;right:11rpx;top:8rpx;color:#e4e8e9;font-size:17rpx;font-weight:700}.work-mark{width:49rpx;height:49rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:9rpx;font-size:19rpx;font-weight:700}.mark-0{color:#267866;background:#e4f3ef}.mark-1{color:#476d90;background:#e9f0f6}.mark-2{color:#8c6a34;background:#f7efdf}.work-copy{min-width:0;margin-left:12rpx}.work-copy text{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.work-copy text:first-child{font-size:20rpx;font-weight:650}.work-copy text:last-child{margin-top:3rpx;color:#9ba3a8;font-size:15rpx}.work-arrow{margin-left:auto;color:#b6bdc1;font-size:27rpx}
.rhythm-card,.order-card{padding:20rpx;border:1rpx solid #e4e8ea;border-radius:14rpx;background:#fff}.rhythm-list{display:flex;flex-direction:column;gap:17rpx}.rhythm-row>text{width:73rpx;color:#7e898f;font-size:16rpx}.rhythm-track{height:12rpx;flex:1;overflow:hidden;border-radius:6rpx;background:#eef1f2}.rhythm-track view{height:100%;border-radius:inherit;background:#4caa96}.rhythm-row strong{width:60rpx;color:#34464e;font-size:18rpx;text-align:right}.order-card{display:grid;grid-template-columns:1fr 1fr;gap:20rpx 18rpx}.order-row{min-width:0}.order-tag{width:46rpx;height:46rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:9rpx;font-size:18rpx;font-weight:700}.tag-0{color:#277d6a;background:#e2f3ee}.tag-1{color:#456f96;background:#e5eef6}.tag-2{color:#a27630;background:#f8efdc}.tag-3{color:#865f8e;background:#f1e9f3}.order-info{min-width:0;flex:1;margin-left:11rpx}.order-info>view:first-child{justify-content:space-between}.order-info text{overflow:hidden;color:#5e6b71;font-size:16rpx;text-overflow:ellipsis;white-space:nowrap}.order-info strong{margin-left:5rpx;color:#34464e;font-size:17rpx;white-space:nowrap}.order-track{height:5rpx;margin-top:8rpx;overflow:hidden;border-radius:4rpx;background:#edf0f1}.order-track view{height:100%;border-radius:inherit}.order-bar-0{background:#46a892}.order-bar-1{background:#5c8ab4}.order-bar-2{background:#d4a557}.order-bar-3{background:#a47bac}.empty-state{grid-column:1/-1;padding:38rpx 0;color:#a0a8ad;font-size:19rpx;text-align:center}.error-block{padding:23rpx;border-radius:12rpx;color:#b8554d;background:#fcedeb}.error-block text{display:block;font-size:20rpx}.error-block text:last-child{margin-top:5rpx;color:#a8807c;font-size:17rpx}
.login-view{min-height:calc(100vh - 150rpx);display:flex;align-items:center;flex-direction:column;justify-content:center;background:#f4f5f7}.login-symbol{width:120rpx;height:120rpx;border-radius:18rpx}.login-symbol i:nth-child(1){width:20rpx;height:20rpx;left:28rpx;top:51rpx}.login-symbol i:nth-child(2){width:15rpx;height:15rpx;left:62rpx;top:27rpx}.login-symbol i:nth-child(3){width:18rpx;height:18rpx;right:21rpx;bottom:28rpx}.login-symbol:before{width:44rpx;left:45rpx;top:56rpx}.login-symbol:after{width:38rpx;left:67rpx;top:50rpx}.login-view>text:nth-child(2){margin-top:27rpx;font-size:35rpx;font-weight:750}.login-view>text:nth-child(3){margin-top:7rpx;color:#8c969c;font-size:21rpx}.login-view button{width:360rpx;margin-top:43rpx;border:0;border-radius:8rpx;color:#fff;background:#183f49;font-size:24rpx}.login-view button::after{border:0}
/* ä¸Žåº”用内其他页面共用同一套主题、卡片和留白规则;布局仍保留康意版本的任务型结构。 */
.kangyi-page{
  box-sizing:border-box;
  background:radial-gradient(circle at 88% 1%,var(--color-primary-border),transparent 31%),linear-gradient(180deg,var(--color-primary-soft) 0%,#f7f8fc 38%,var(--color-bg-page) 100%);
  color:#263246;
}
.kangyi-page,.kangyi-page view,.kangyi-page text,.kangyi-page button{box-sizing:border-box}
.topbar{padding:calc(var(--status-bar-height) + 24rpx) 30rpx 18rpx;background:transparent;border-bottom:0}
.wordmark-symbol,.login-symbol{background:linear-gradient(145deg,var(--color-primary),var(--color-primary-light))}
.wordmark-symbol{border-radius:18rpx;box-shadow:0 8rpx 18rpx rgba(59,55,104,.14)}
.wordmark-symbol i,.login-symbol i{background:#fff}
.wordmark-symbol:before,.wordmark-symbol:after,.login-symbol:before,.login-symbol:after{background:rgba(255,255,255,.82)}
.wordmark text:last-child{color:#79869a}
.top-message{display:flex;width:66rpx;height:66rpx;align-items:center;justify-content:center;padding:0;border:1rpx solid rgba(220,225,235,.92);border-radius:50%;color:#627086;background:rgba(255,255,255,.88);box-shadow:0 8rpx 20rpx rgba(49,60,82,.08)}
.top-message i{right:6rpx;top:5rpx}
.main-content{padding:8rpx 28rpx 0}
.hello-block{padding:10rpx 5rpx 26rpx}
.hello-block>view:first-child text:first-child{font-size:37rpx}
.hello-block>view:first-child text:last-child{color:#738197;font-size:21rpx}
.online-label{border-radius:18rpx;color:var(--color-primary-dark);background:rgba(255,255,255,.72)}
.online-label i{background:var(--color-primary)}
.focus-board{border-radius:22rpx;background:linear-gradient(145deg,var(--color-primary-dark),var(--color-primary-light));box-shadow:0 16rpx 32rpx rgba(64,57,118,.16)}
.focus-label,.focus-main>text,.focus-side text{color:rgba(255,255,255,.72)}
.focus-label i{background:#fff}
.focus-side small{color:rgba(255,255,255,.52)}
.change-label{border-radius:12rpx}
.change-up{color:#fff;background:rgba(255,255,255,.16)}
.change-down{color:#fff;background:rgba(176,52,75,.25)}
.data-strip,.work-item,.rhythm-card,.order-card{border:1rpx solid rgba(223,228,238,.92);background:rgba(255,255,255,.88);box-shadow:0 8rpx 20rpx rgba(39,51,74,.04)}
.data-strip,.work-item,.rhythm-card,.order-card{border-radius:18rpx}
.data-strip{padding:22rpx 10rpx}
.data-strip text{color:#8390a2;font-size:17rpx}
.data-strip strong{color:#334155}
.section-title{margin-top:34rpx}
.section-title>view text:first-child{font-size:28rpx}
.section-title>view text:last-child{color:#929cac;font-size:17rpx}
.work-item{padding:20rpx 16rpx}
.work-mark{border-radius:14rpx}
.mark-0{color:var(--color-primary);background:var(--color-primary-soft)}
.rhythm-track{background:#e9edf4}
.rhythm-track view{background:linear-gradient(90deg,var(--color-primary-light),var(--color-primary))}
.order-track{background:#e9edf4}
.order-bar-0,.order-bar-1,.order-bar-2,.order-bar-3{background:linear-gradient(90deg,var(--color-primary-light),var(--color-primary))}
.login-view{background:linear-gradient(180deg,var(--color-primary-soft),var(--color-bg-page))}
.login-symbol{border-radius:28rpx}
.login-view button{height:84rpx;border-radius:42rpx;background:var(--color-primary);line-height:84rpx}
</style>
src/pages/home/lanyu-paper.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1083 @@
<script setup lang="ts">
  import { computed, ref } from "vue";
  import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
  import {
    getHomeSummary,
    getProductionTrend,
    getWorkOrderStatus,
    type HomeSummary,
    type ProductionTrend,
    type WorkOrderStatus,
  } from "../../api/home";
  import type { AuthMenu } from "../../api/auth";
  import { appRouteMap } from "../../config/app-routes";
  import { APP_COMPANY } from "../../config/company";
  import { useAuth } from "../../stores/auth";
  import { useNotify } from "../../stores/notify";
  import { useAppTheme } from "../../stores/theme";
  const { state, isLoggedIn, restoreUser } = useAuth();
  const { state: notifyState, refreshUnreadCount } = useNotify();
  const { themeStyle } = useAppTheme();
  const summary = ref<HomeSummary | null>(null);
  const trend = ref<ProductionTrend[]>([]);
  const workOrderStatus = ref<WorkOrderStatus[]>([]);
  const loading = ref(false);
  const loadError = ref("");
  const userName = computed(
    () => state.user?.nickname || state.user?.username || "同事"
  );
  const greeting = computed(() => {
    const hour = new Date().getHours();
    if (hour < 6) return "夜深了";
    if (hour < 9) return "早上好";
    if (hour < 12) return "上午好";
    if (hour < 14) return "中午好";
    if (hour < 18) return "下午好";
    return "晚上好";
  });
  const today = computed(() => {
    const date = new Date();
    const weeks = ["日", "一", "二", "三", "四", "五", "六"];
    return `${date.getMonth() + 1}月${date.getDate()}日 æ˜ŸæœŸ${
      weeks[date.getDay()]
    }`;
  });
  const qualityRate = computed(() => {
    const data = summary.value;
    if (!data) return "0";
    const total = data.todayQualifiedQuantity + data.todayUnqualifiedQuantity;
    return total ? ((data.todayQualifiedQuantity / total) * 100).toFixed(1) : "0";
  });
  const equipmentRate = computed(() => {
    const data = summary.value;
    if (!data?.machineryTotal) return "0";
    return ((data.machineryProducing / data.machineryTotal) * 100).toFixed(0);
  });
  const outputChange = computed(() => {
    const data = summary.value;
    if (!data) return 0;
    return data.todayOutput - data.yesterdayOutput;
  });
  const trendMax = computed(() =>
    Math.max(...trend.value.map(item => item.quantity), 1)
  );
  const totalWorkOrders = computed(() =>
    workOrderStatus.value.reduce((total, item) => total + item.count, 0)
  );
  interface QuickAction {
    name: string;
    detail: string;
    icon: string;
    tone: string;
    route: string;
    component: string;
  }
  const quickActions: QuickAction[] = [
    {
      name: "生产报工",
      detail: "登记产出",
      icon: "report",
      tone: "blue",
      route: "/pages/mes/pro/feedback/index",
      component: "mes/pro/feedback/index",
    },
    {
      name: "生产工单",
      detail: "查看进度",
      icon: "order",
      tone: "violet",
      route: "/pages/mes/pro/workorder/index",
      component: "mes/pro/workorder/index",
    },
    {
      name: "质量管理",
      detail: "质检记录",
      icon: "quality",
      tone: "orange",
      route: "/pages/mes/qc/ledger/index",
      component: "mes/qc/ledger/index",
    },
    {
      name: "设备点检",
      detail: "设备状态",
      icon: "device",
      tone: "green",
      route: "/pages/mes/dv/checkrecord/index",
      component: "mes/dv/checkrecord/index",
    },
    {
      name: "物料库存",
      detail: "库存查询",
      icon: "stock",
      tone: "cyan",
      route: "/pages/wls/material-stock/index",
      component: "wls/materialstock/index",
    },
    {
      name: "仓库管理",
      detail: "仓库资料",
      icon: "stock",
      tone: "cyan",
      route: "/pages/wls/warehouse/index",
      component: "wls/warehouse/index",
    },
    {
      name: "采购申请",
      detail: "申请管理",
      icon: "report",
      tone: "violet",
      route: "/pages/purchase/request/index",
      component: "erp/purchase/request/index",
    },
    {
      name: "采购订单",
      detail: "订单管理",
      icon: "order",
      tone: "violet",
      route: "/pages/purchase/order/index",
      component: "erp/purchase/order/index",
    },
    {
      name: "销售订单",
      detail: "订单管理",
      icon: "order",
      tone: "blue",
      route: "/pages/sale/order/index",
      component: "erp/sale/order/index",
    },
    { name: "员工管理", detail: "员工档案", icon: "device", tone: "green", route: "/pages/hrm/employee/index", component: "hrm/employee/index" },
    { name: "请假申请", detail: "发起申请", icon: "report", tone: "orange", route: "/pages/hrm/leave/index", component: "hrm/leave/index" },
    { name: "调岗申请", detail: "发起申请", icon: "order", tone: "cyan", route: "/pages/hrm/transfer/index", component: "hrm/transfer/index" },
    { name: "离职申请", detail: "发起申请", icon: "report", tone: "red", route: "/pages/hrm/resignation/index", component: "hrm/resignation/index" },
    { name: "加班申请", detail: "发起申请", icon: "quality", tone: "blue", route: "/pages/hrm/overtime/index", component: "hrm/overtime/index" },
    // è“ç¾½çº¸ä¸šæš‚不展示考勤打卡;恢复时取消下面入口的注释。
    // {
    //   name: "考勤打卡",
    //   detail: "上下班打卡",
    //   icon: "device",
    //   tone: "green",
    //   route: "/pages/hrm/attendance-clock/index",
    //   component: "hrm/attendance/clock/index",
    // },
  ];
  const ATTENDANCE_COMPONENT = "hrm/attendance/clock/index";
  function collectMenuComponents(menus: AuthMenu[]) {
    const components = new Set<string>();
    const visit = (items: AuthMenu[]) =>
      items.forEach(menu => {
        if (!menu.visible) return;
        if (menu.component) components.add(menu.component);
        if (menu.children?.length) visit(menu.children);
      });
    visit(menus);
    return components;
  }
  const menuComponents = computed(() => collectMenuComponents(state.menus));
  const hasMenu = (component: string) => menuComponents.value.has(component);
  const routeMenus = computed(() => {
    const result: Array<{ name: string; component: string }> = [];
    const visit = (items: AuthMenu[]) =>
      items.forEach(menu => {
        if (!menu.visible) return;
        if (menu.component && appRouteMap[menu.component])
          result.push({ name: menu.name, component: menu.component });
        if (menu.children?.length) visit(menu.children);
      });
    visit(state.menus);
    return result;
  });
  function fallbackStyle(name: string, component: string) {
    const source = `${name} ${component}`;
    if (/质量|检验|缺陷|不合格/.test(source))
      return { icon: "quality", tone: "orange" };
    if (/设备|点检|保养|ç»´ä¿®|员工|考勤/.test(source))
      return { icon: "device", tone: "green" };
    if (/仓|库存|物料|入库|出库|发货|调拨/.test(source))
      return { icon: "stock", tone: "cyan" };
    if (/采购|供应商|付款/.test(source)) return { icon: "order", tone: "violet" };
    if (/离职|异常|退/.test(source)) return { icon: "report", tone: "red" };
    if (/申请|流程|审批|报工/.test(source))
      return { icon: "report", tone: "blue" };
    return { icon: "order", tone: "blue" };
  }
  const visibleQuickActions = computed(() => {
    const preferred = quickActions.filter(
      item => item.component !== ATTENDANCE_COMPONENT && hasMenu(item.component)
    );
    const used = new Set(preferred.map(item => item.component));
    const fallback = routeMenus.value
      .filter(item => item.component !== ATTENDANCE_COMPONENT && !used.has(item.component))
      .map(item => ({
        name: item.name,
        detail: "快速进入",
        route: appRouteMap[item.component],
        component: item.component,
        ...fallbackStyle(item.name, item.component),
      }));
    return preferred.concat(fallback).slice(0, 6);
  });
  const canViewWorkOrder = computed(() => hasMenu("mes/pro/workorder/index"));
  const canViewFeedback = computed(() => hasMenu("mes/pro/feedback/index"));
  const canViewQuality = computed(() => hasMenu("mes/qc/ledger/index"));
  const canViewMachinery = computed(() => hasMenu("mes/dv/machinery/index"));
  const canViewRepair = computed(() => hasMenu("mes/dv/repair/index"));
  const tabRoutes = [
    "/pages/home/index",
    "/pages/workbench/index",
    "/pages/message/index",
    "/pages/profile/index",
  ];
  function getAccessToken() {
    return state.accessToken;
  }
  async function loadDashboard() {
    const accessToken = getAccessToken();
    if (!accessToken || loading.value) return;
    loading.value = true;
    loadError.value = "";
    try {
      const [summaryData, trendData, statusData] = await Promise.all([
        getHomeSummary(accessToken),
        getProductionTrend(accessToken),
        getWorkOrderStatus(accessToken),
      ]);
      summary.value = summaryData;
      trend.value = trendData;
      workOrderStatus.value = statusData;
    } catch (error) {
      loadError.value =
        error instanceof Error ? error.message : "首页数据加载失败";
    } finally {
      loading.value = false;
    }
  }
  async function refreshPage() {
    await restoreUser();
    try {
      await refreshUnreadCount();
    } catch {
      // æœªè¯»æ¶ˆæ¯åŠ è½½å¤±è´¥ä¸å½±å“é¦–é¡µå±•ç¤º
    }
    await loadDashboard();
  }
  function goMessages() {
    uni.switchTab({ url: "/pages/message/index" });
  }
  function goLogin() {
    uni.navigateTo({ url: "/pages/login/index" });
  }
  function openRoute(route?: string) {
    if (!route) {
      return;
    }
    if (tabRoutes.includes(route)) {
      uni.switchTab({ url: route });
      return;
    }
    uni.navigateTo({ url: route });
  }
  function formatDate(value: string) {
    const matched = value.match(/(\d{1,2})[-/](\d{1,2})$/);
    return matched ? `${matched[1]}/${matched[2]}` : value.slice(5);
  }
  onShow(() => {
    refreshPage();
  });
  onPullDownRefresh(async () => {
    await refreshPage();
    uni.stopPullDownRefresh();
  });
</script>
<template>
  <view class="page" :style="themeStyle">
    <view class="home-hero">
      <view class="topbar">
        <view class="welcome-copy">
          <text class="brand-name">{{ APP_COMPANY.name }}</text>
          <text class="greeting">{{ greeting }},{{ userName }}</text>
          <text class="date">{{ today }} Â· æ™ºé€ è¿è¥æ¦‚览</text>
        </view>
        <view class="notice-button"
              @click="goMessages">
          <view class="bell">
            <view />
          </view>
          <view v-if="notifyState.unreadCount > 0"
                class="notice-dot" />
        </view>
      </view>
      <template v-if="isLoggedIn">
        <view v-if="canViewWorkOrder || canViewFeedback"
              class="overview-card">
          <view class="overview-heading">
            <view>
              <text>生产总览</text>
              <text>实时掌握现场运行状态</text>
            </view>
            <view class="live-status">
              <view />运行中
            </view>
          </view>
          <view class="overview-main">
            <view v-if="canViewWorkOrder"
                  class="main-kpi"
                  @click="openRoute('/pages/mes/pro/workorder/index')">
              <text>进行中工单</text>
              <text>{{ summary?.workOrderActiveCount ?? '--' }}</text>
              <text>待排产 {{ summary?.workOrderPrepareCount ?? 0 }} å•</text>
            </view>
            <view v-if="canViewWorkOrder && canViewFeedback"
                  class="overview-divider" />
            <view v-if="canViewFeedback"
                  class="main-kpi"
                  @click="openRoute('/pages/mes/pro/feedback/index')">
              <text>今日产量</text>
              <text>{{ summary?.todayOutput ?? '--' }}</text>
              <text :class="{ increase: outputChange >= 0, decrease: outputChange < 0 }">
                è¾ƒæ˜¨æ—¥ {{ outputChange >= 0 ? '+' : '' }}{{ outputChange }} ä»¶
              </text>
            </view>
          </view>
        </view>
      </template>
    </view>
    <template v-if="isLoggedIn">
      <view v-if="loadError"
            class="error-state"
            @click="loadDashboard">
        <text>{{ loadError }}</text>
        <text>点击重新加载</text>
      </view>
      <template v-else>
        <view v-if="canViewQuality || canViewMachinery"
              class="section-heading">
          <text>关键指标</text>
          <text v-if="loading">更新中</text>
        </view>
        <view v-if="canViewQuality || canViewMachinery"
              class="metrics-grid">
          <view v-if="canViewQuality"
                class="metric-item"
                @click="openRoute('/pages/mes/qc/ledger/index')">
            <view class="metric-icon quality-icon">
              <view />
            </view>
            <view>
              <text>质量合格率</text>
              <text>{{ qualityRate }}<small>%</small></text>
              <text>不良 {{ summary?.todayUnqualifiedQuantity ?? 0 }} ä»¶</text>
            </view>
          </view>
          <view v-if="canViewMachinery"
                class="metric-item"
                @click="openRoute('/pages/mes/dv/machinery/index')">
            <view class="metric-icon device-icon">
              <view />
            </view>
            <view>
              <text>设备运行率</text>
              <text>{{ equipmentRate }}<small>%</small></text>
              <text>停机 {{ summary?.machineryStop ?? 0 }} å°</text>
            </view>
          </view>
        </view>
        <view v-if="canViewRepair"
              class="section-heading"><text>现场预警</text><text>优先处理</text></view>
        <view v-if="canViewRepair"
              class="alert-strip">
          <view class="alert-item repair-alert"
                @click="openRoute('/pages/mes/dv/repair/index')">
            <view class="alert-symbol">+</view>
            <view><text>维修工单</text><text>{{ summary?.repairActiveCount ?? 0 }} <small>项待处理</small></text></view>
            <text class="arrow">›</text>
          </view>
        </view>
        <view class="section-heading"><text>近 7 æ—¥ç”Ÿäº§è¶‹åŠ¿</text><text>产量</text></view>
        <view class="trend-panel">
          <view v-if="trend.length"
                class="bar-chart">
            <view v-for="item in trend"
                  :key="item.date"
                  class="bar-item">
              <text>{{ item.quantity }}</text>
              <view class="bar-track">
                <view class="bar-fill"
                      :style="{ height: `${Math.max((item.quantity / trendMax) * 100, 5)}%` }" />
              </view>
              <text>{{ formatDate(item.date) }}</text>
            </view>
          </view>
          <view v-else
                class="compact-empty">暂无近 7 æ—¥ç”Ÿäº§æ•°æ®</view>
        </view>
        <view class="section-heading"><text>工单状态</text><text>{{ totalWorkOrders }} å•</text></view>
        <view class="status-panel">
          <view v-if="workOrderStatus.length"
                class="status-list">
            <view v-for="item in workOrderStatus"
                  :key="item.status"
                  class="status-row">
              <text>{{ item.statusName }}</text>
              <view>
                <view :style="{ width: `${totalWorkOrders ? Math.max((item.count / totalWorkOrders) * 100, 4) : 0}%` }" />
              </view>
              <text>{{ item.count }}</text>
            </view>
          </view>
          <view v-else
                class="compact-empty">暂无工单状态数据</view>
        </view>
        <view v-if="visibleQuickActions.length"
              class="section-heading"><text>快捷操作</text><text @click="openRoute('/pages/workbench/index')">全部应用</text></view>
        <view v-if="visibleQuickActions.length"
              class="quick-grid">
          <view v-for="item in visibleQuickActions"
                :key="item.name"
                class="quick-item"
                @click="openRoute(item.route)">
            <view class="quick-icon"
                  :class="`${item.tone} ${item.icon}`">
              <view />
            </view>
            <text>{{ item.name }}</text>
            <text>{{ item.detail }}</text>
          </view>
        </view>
      </template>
    </template>
    <view v-else
          class="login-state">
      <view class="login-art">
        <view />
        <view />
        <view />
      </view>
      <text>登录后查看生产运营数据</text>
      <text>工单、质量、设备和现场预警将实时同步</text>
      <button @click="goLogin">立即登录</button>
    </view>
  </view>
</template>
<style lang="scss" scoped>
  .page {
    box-sizing: border-box;
    overflow-x: hidden;
    min-height: 100vh;
    padding: calc(var(--status-bar-height) + 22rpx) 28rpx 156rpx;
    background: radial-gradient(
        circle at 86% 2%,
        var(--color-primary-border, #bfdbff),
        transparent 34%
      ),
      linear-gradient(180deg, var(--color-primary-soft, #eef5ff) 0%, #f7f8fc 42%, #f3f7fb 68%, #eef3f9 100%);
  }
  .page,
  .page view,
  .page text,
  .page button {
    box-sizing: border-box;
  }
  .topbar,
  .overview-heading,
  .overview-main,
  .section-heading,
  .metrics-grid,
  .alert-strip,
  .alert-item,
  .bar-chart,
  .status-row {
    display: flex;
  }
  .topbar {
    align-items: center;
    justify-content: space-between;
    padding: 8rpx 8rpx 30rpx;
  }
  .welcome-copy text,
  .overview-heading text,
  .main-kpi text,
  .metric-item text,
  .alert-item text,
  .quick-item text,
  .login-state text {
    display: block;
  }
  .greeting {
    color: #10233d;
    font-size: 42rpx;
    font-weight: 700;
    line-height: 1.18;
  }
  .brand-name {
    display: inline-block;
    margin-bottom: 10rpx;
    color: var(--color-primary, #1769d8);
    font-size: 23rpx;
    font-weight: 700;
  }
  .date {
    margin-top: 13rpx;
    color: #6f7f95;
    font-size: 23rpx;
  }
  .home-hero {
    position: relative;
    overflow: visible;
    margin-bottom: 34rpx;
    padding: 12rpx 4rpx 0;
  }
  .home-hero > view {
    position: relative;
    z-index: 1;
  }
  .notice-button {
    position: relative;
    display: flex;
    width: 76rpx;
    height: 76rpx;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.86);
    border: 1rpx solid rgba(218, 229, 243, 0.92);
    border-radius: 50%;
    box-shadow: 0 10rpx 24rpx rgba(44, 77, 112, 0.1);
  }
  .bell {
    position: relative;
    width: 26rpx;
    height: 28rpx;
    border: 3rpx solid #23344f;
    border-top-left-radius: 16rpx;
    border-top-right-radius: 16rpx;
    border-bottom: 0;
  }
  .bell::before {
    position: absolute;
    right: -7rpx;
    bottom: -4rpx;
    left: -7rpx;
    height: 3rpx;
    content: "";
    background: #23344f;
    border-radius: 3rpx;
  }
  .bell view {
    position: absolute;
    bottom: -9rpx;
    left: 8rpx;
    width: 7rpx;
    height: 7rpx;
    background: #23344f;
    border-radius: 50%;
  }
  .notice-dot {
    position: absolute;
    top: 15rpx;
    right: 16rpx;
    width: 12rpx;
    height: 12rpx;
    background: #ef5c5c;
    border: 3rpx solid rgba(255, 255, 255, 0.9);
    border-radius: 50%;
  }
  .overview-card {
    overflow: hidden;
    padding: 30rpx 28rpx;
    color: #14243c;
    background: rgba(255, 255, 255, 0.82);
    border: 1rpx solid rgba(218, 229, 243, 0.8);
    border-radius: 24rpx;
    box-shadow: 0 12rpx 26rpx rgba(44, 77, 112, 0.08);
  }
  .overview-heading {
    align-items: flex-start;
    justify-content: space-between;
  }
  .overview-heading text:first-child {
    color: #11243d;
    font-size: 30rpx;
    font-weight: 650;
  }
  .overview-heading text:last-child {
    margin-top: 7rpx;
    color: #718198;
    font-size: 21rpx;
  }
  .live-status {
    display: flex;
    align-items: center;
    gap: 8rpx;
    padding: 8rpx 13rpx;
    color: #12835a;
    font-size: 19rpx;
    background: #e5f7ee;
    border-radius: 20rpx;
  }
  .live-status view {
    width: 10rpx;
    height: 10rpx;
    background: #71e2a8;
    border-radius: 50%;
  }
  .overview-main {
    margin-top: 30rpx;
  }
  .main-kpi {
    flex: 1;
  }
  .main-kpi text:first-child {
    color: #697991;
    font-size: 22rpx;
  }
  .main-kpi text:nth-child(2) {
    margin-top: 3rpx;
    color: var(--color-primary, #1769d8);
    font-size: 53rpx;
    font-weight: 700;
    line-height: 1.12;
  }
  .main-kpi text:last-child {
    margin-top: 9rpx;
    color: #7a8798;
    font-size: 20rpx;
  }
  .overview-divider {
    width: 1rpx;
    margin: 6rpx 30rpx;
    background: #dfe8f3;
  }
  .increase {
    color: #12945f !important;
  }
  .decrease {
    color: #d64e4e !important;
  }
  .section-heading {
    align-items: center;
    justify-content: space-between;
    margin: 42rpx 3rpx 18rpx;
    color: #263247;
    font-size: 30rpx;
    font-weight: 700;
  }
  .section-heading > text:last-child:not(:first-child) {
    color: #7d899a;
    font-size: 22rpx;
    font-weight: 400;
  }
  .metrics-grid {
    gap: 14rpx;
  }
  .metric-item {
    display: flex;
    flex: 1;
    min-width: 0;
    min-height: 126rpx;
    align-items: center;
    gap: 16rpx;
    padding: 20rpx 18rpx;
    background: rgba(255, 255, 255, 0.58);
    border: 1rpx solid rgba(221, 230, 242, 0.92);
    border-radius: 16rpx;
  }
  .metric-icon {
    position: relative;
    display: flex;
    width: 48rpx;
    height: 48rpx;
    flex: none;
    align-items: center;
    justify-content: center;
    border-radius: 14rpx;
  }
  .metric-icon view {
    width: 20rpx;
    height: 20rpx;
    border: 3rpx solid currentColor;
    border-radius: 50%;
  }
  .quality-icon {
    color: #dc8a16;
    background: #fff1da;
  }
  .device-icon {
    color: #248f63;
    background: #e1f5eb;
  }
  .device-icon view {
    border-radius: 5rpx;
  }
  .metric-item text:first-child {
    color: #7c8999;
    font-size: 19rpx;
  }
  .metric-item text:nth-child(2) {
    margin-top: 2rpx;
    color: #263348;
    font-size: 32rpx;
    font-weight: 700;
    line-height: 1.1;
  }
  .metric-item small,
  .alert-item small {
    font-size: 19rpx;
    font-weight: 400;
  }
  .metric-item text:last-child {
    margin-top: 6rpx;
    color: #96a0ad;
    font-size: 18rpx;
  }
  .alert-strip {
    position: relative;
    overflow: hidden;
    align-items: stretch;
    background: linear-gradient(90deg, #fff7f7 0, #fff 48%, #fffbf4 100%);
    border: 1rpx solid rgba(245, 218, 210, 0.9);
    border-radius: 20rpx;
    box-shadow: 0 14rpx 30rpx rgba(154, 74, 49, 0.08);
  }
  .alert-strip::before {
    width: 8rpx;
    content: "";
    background: linear-gradient(180deg, #ff7373, #f2aa45);
  }
  .alert-item {
    flex: 1;
    min-width: 0;
    align-items: center;
    padding: 25rpx 17rpx;
  }
  .alert-symbol {
    display: flex;
    width: 43rpx;
    height: 43rpx;
    flex: none;
    align-items: center;
    justify-content: center;
    margin-right: 13rpx;
    font-size: 28rpx;
    font-weight: 700;
    border-radius: 50%;
  }
  .repair-alert .alert-symbol {
    color: #d98513;
    background: #fff0da;
  }
  .alert-item text:first-of-type {
    color: #758294;
    font-size: 20rpx;
  }
  .alert-item text:last-of-type {
    margin-top: 3rpx;
    color: #313d50;
    font-size: 30rpx;
    font-weight: 700;
  }
  .alert-divider {
    width: 1rpx;
    margin: 22rpx 0;
    background: #edf0f5;
  }
  .arrow {
    margin-left: auto;
    color: #a1abb8;
    font-size: 34rpx;
  }
  .trend-panel,
  .status-panel {
    padding: 24rpx 22rpx;
    background: rgba(255, 255, 255, 0.72);
    border: 1rpx solid rgba(223, 232, 245, 0.9);
    border-radius: 18rpx;
  }
  .bar-chart {
    height: 190rpx;
    align-items: flex-end;
    justify-content: space-between;
    gap: 12rpx;
  }
  .bar-item {
    display: flex;
    height: 100%;
    flex: 1;
    min-width: 0;
    align-items: center;
    flex-direction: column;
    justify-content: flex-end;
  }
  .bar-item text:first-child {
    height: 28rpx;
    overflow: hidden;
    color: #657387;
    font-size: 18rpx;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .bar-track {
    display: flex;
    width: 100%;
    height: 118rpx;
    align-items: flex-end;
    justify-content: center;
    margin: 4rpx 0 8rpx;
    background: #e9f0f8;
    border-radius: 8rpx;
  }
  .bar-fill {
    width: 58%;
    min-height: 8rpx;
    background: linear-gradient(180deg, var(--color-primary-light, #4b8ee8), var(--color-primary, #1769d8));
    border-radius: 8rpx 8rpx 3rpx 3rpx;
  }
  .bar-item text:last-child {
    color: #8a96a6;
    font-size: 18rpx;
  }
  .compact-empty {
    padding: 40rpx 0;
    color: #99a3b1;
    font-size: 22rpx;
    text-align: center;
  }
  .status-list {
    display: flex;
    flex-direction: column;
    gap: 18rpx;
  }
  .status-row {
    align-items: center;
    gap: 15rpx;
  }
  .status-row > text:first-child {
    width: 112rpx;
    overflow: hidden;
    color: #5e6b7c;
    font-size: 22rpx;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .status-row > view {
    height: 12rpx;
    flex: 1;
    overflow: hidden;
    background: #e8eff7;
    border-radius: 12rpx;
  }
  .status-row > view > view {
    height: 100%;
    background: linear-gradient(90deg, var(--color-primary-light, #4b8ee8), var(--color-primary, #1769d8));
    border-radius: 12rpx;
  }
  .status-row > text:last-child {
    width: 34rpx;
    color: #334155;
    font-size: 22rpx;
    font-weight: 600;
    text-align: right;
  }
  .quick-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 17rpx;
  }
  .quick-item {
    min-height: 158rpx;
    padding: 20rpx 12rpx;
    background: #fff;
    border-radius: 18rpx;
    box-shadow: 0 8rpx 18rpx rgba(30, 53, 82, 0.04);
  }
  .quick-icon {
    position: relative;
    display: flex;
    width: 52rpx;
    height: 52rpx;
    align-items: center;
    justify-content: center;
    border-radius: 15rpx;
  }
  .quick-icon::before,
  .quick-icon::after,
  .quick-icon view {
    position: absolute;
    box-sizing: border-box;
    content: "";
  }
  .quick-icon::before {
    width: 23rpx;
    height: 23rpx;
    border: 3rpx solid currentColor;
    border-radius: 5rpx;
  }
  .quick-icon::after {
    width: 11rpx;
    height: 3rpx;
    background: currentColor;
  }
  .blue {
    color: var(--color-primary, #1769d8);
    background: var(--color-primary-soft, #eef5ff);
  }
  .violet {
    color: #695bd8;
    background: #eeecff;
  }
  .orange {
    color: #da8619;
    background: #fff0db;
  }
  .red {
    color: #dc5757;
    background: #ffeded;
  }
  .green {
    color: #269464;
    background: #e2f5eb;
  }
  .cyan {
    color: #238eaa;
    background: #e0f5fa;
  }
  .report::after {
    transform: rotate(-45deg);
  }
  .order::before {
    border-radius: 50%;
  }
  .quality::after {
    width: 12rpx;
    height: 7rpx;
    background: transparent;
    border-bottom: 3rpx solid currentColor;
    border-left: 3rpx solid currentColor;
    transform: rotate(-45deg);
  }
  .andon::before {
    border-radius: 50%;
  }
  .andon::after {
    width: 3rpx;
    height: 13rpx;
  }
  .device::before {
    width: 25rpx;
    height: 19rpx;
    border-radius: 4rpx;
  }
  .stock::before {
    transform: rotate(45deg);
    border-radius: 3rpx;
  }
  .quick-item text:nth-child(2) {
    margin-top: 14rpx;
    overflow: hidden;
    color: #3a4659;
    font-size: 23rpx;
    font-weight: 600;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .quick-item text:last-child {
    margin-top: 4rpx;
    overflow: hidden;
    color: #929cab;
    font-size: 18rpx;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .error-state {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 24rpx;
    padding: 24rpx;
    color: #b55050;
    font-size: 23rpx;
    background: #fff2f2;
    border-radius: 16rpx;
  }
  .error-state text:last-child {
    color: #d56767;
    font-size: 21rpx;
  }
  .login-state {
    display: flex;
    align-items: center;
    flex-direction: column;
    padding-top: 180rpx;
    text-align: center;
  }
  .login-art {
    position: relative;
    width: 126rpx;
    height: 108rpx;
    margin-bottom: 35rpx;
    background: #e3efff;
    border-radius: 30rpx;
  }
  .login-art view {
    position: absolute;
    background: #2d75d6;
    border-radius: 10rpx;
  }
  .login-art view:nth-child(1) {
    bottom: 24rpx;
    left: 28rpx;
    width: 15rpx;
    height: 29rpx;
  }
  .login-art view:nth-child(2) {
    bottom: 24rpx;
    left: 55rpx;
    width: 15rpx;
    height: 47rpx;
  }
  .login-art view:nth-child(3) {
    bottom: 24rpx;
    left: 82rpx;
    width: 15rpx;
    height: 63rpx;
  }
  .login-state text:nth-of-type(1) {
    color: #364358;
    font-size: 30rpx;
    font-weight: 600;
  }
  .login-state text:nth-of-type(2) {
    margin-top: 12rpx;
    color: #929cab;
    font-size: 23rpx;
  }
  .login-state button {
    width: 360rpx;
    height: 84rpx;
    margin-top: 42rpx;
    color: #fff;
    font-size: 27rpx;
    line-height: 84rpx;
    background: var(--color-primary, #1769d8);
    border-radius: 42rpx;
  }
</style>
src/pages/home/use-company-dashboard.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,207 @@
import { computed, ref } from "vue";
import { onPullDownRefresh, onShow } from "@dcloudio/uni-app";
import type { AuthMenu } from "../../api/auth";
import {
  getHomeSummary,
  getProductionTrend,
  getWorkOrderStatus,
  type HomeSummary,
  type ProductionTrend,
  type WorkOrderStatus,
} from "../../api/home";
import { getAttendanceSummaryPage } from "../../api/hrm";
import { getPurchaseOrderPage } from "../../api/purchase";
import { getMaterialStockPage } from "../../api/wls";
import { appRouteMap } from "../../config/app-routes";
import { useAuth } from "../../stores/auth";
import { useNotify } from "../../stores/notify";
export interface CompanyHomeAction {
  name: string;
  detail: string;
  mark: string;
  tone: string;
  route: string;
  component: string;
}
export interface AttendanceOverview {
  headCount: number;
  attendanceRate: string;
  averageActualDays: string;
  lateCount: number;
  absentCount: number;
  leaveHours: number;
}
export interface SupplyOverview {
  stockQuantity: number;
  availableQuantity: number;
  purchaseTotal: number;
  pendingPurchaseTotal: number;
}
const ATTENDANCE_COMPONENT = "hrm/attendance/clock/index";
const actions: CompanyHomeAction[] = [
  { name: "考勤打卡", detail: "上下班打卡", mark: "勤", tone: "teal", route: "/pages/hrm/attendance-clock/index", component: ATTENDANCE_COMPONENT },
  { name: "生产报工", detail: "登记生产产出", mark: "报", tone: "blue", route: "/pages/mes/pro/feedback/index", component: "mes/pro/feedback/index" },
  { name: "生产工单", detail: "跟踪作业进度", mark: "单", tone: "violet", route: "/pages/mes/pro/workorder/index", component: "mes/pro/workorder/index" },
  { name: "质量管理", detail: "查看质检记录", mark: "è´¨", tone: "amber", route: "/pages/mes/qc/ledger/index", component: "mes/qc/ledger/index" },
  { name: "设备点检", detail: "掌握设备状态", mark: "检", tone: "green", route: "/pages/mes/dv/checkrecord/index", component: "mes/dv/checkrecord/index" },
  { name: "物料库存", detail: "查询库存台账", mark: "库", tone: "cyan", route: "/pages/wls/material-stock/index", component: "wls/materialstock/index" },
  { name: "采购订单", detail: "采购业务管理", mark: "采", tone: "violet", route: "/pages/purchase/order/index", component: "erp/purchase/order/index" },
  { name: "销售订单", detail: "销售业务管理", mark: "销", tone: "blue", route: "/pages/sale/order/index", component: "erp/sale/order/index" },
];
function collectMenuComponents(menus: AuthMenu[]) {
  const result = new Set<string>();
  const visit = (items: AuthMenu[]) => items.forEach(item => {
    if (!item.visible) return;
    if (item.component) result.add(item.component);
    if (item.children?.length) visit(item.children);
  });
  visit(menus);
  return result;
}
export function useCompanyDashboard() {
  const { state, isLoggedIn, restoreUser } = useAuth();
  const { state: notifyState, refreshUnreadCount } = useNotify();
  const summary = ref<HomeSummary | null>(null);
  const trend = ref<ProductionTrend[]>([]);
  const workOrderStatus = ref<WorkOrderStatus[]>([]);
  const attendanceOverview = ref<AttendanceOverview | null>(null);
  const supplyOverview = ref<SupplyOverview | null>(null);
  const loading = ref(false);
  const loadError = ref("");
  const userName = computed(() => state.user?.nickname || state.user?.username || "同事");
  const greeting = computed(() => {
    const hour = new Date().getHours();
    if (hour < 6) return "夜深了";
    if (hour < 9) return "早上好";
    if (hour < 12) return "上午好";
    if (hour < 14) return "中午好";
    if (hour < 18) return "下午好";
    return "晚上好";
  });
  const today = computed(() => {
    const value = new Date();
    const weeks = ["日", "一", "二", "三", "四", "五", "六"];
    return `${value.getMonth() + 1}月${value.getDate()}日 æ˜ŸæœŸ${weeks[value.getDay()]}`;
  });
  const qualityRate = computed(() => {
    if (!summary.value) return "0.0";
    const total = summary.value.todayQualifiedQuantity + summary.value.todayUnqualifiedQuantity;
    return total ? (summary.value.todayQualifiedQuantity / total * 100).toFixed(1) : "0.0";
  });
  const equipmentRate = computed(() => {
    if (!summary.value?.machineryTotal) return "0";
    return (summary.value.machineryProducing / summary.value.machineryTotal * 100).toFixed(0);
  });
  const outputChange = computed(() => (summary.value?.todayOutput || 0) - (summary.value?.yesterdayOutput || 0));
  const trendMax = computed(() => Math.max(...trend.value.map(item => item.quantity), 1));
  const totalWorkOrders = computed(() => workOrderStatus.value.reduce((total, item) => total + item.count, 0));
  const menuComponents = computed(() => collectMenuComponents(state.menus));
  const visibleActions = computed(() => {
    const preferred = actions.filter(item => item.component === ATTENDANCE_COMPONENT || menuComponents.value.has(item.component));
    const used = new Set(preferred.map(item => item.component));
    const fallback: CompanyHomeAction[] = [];
    const visit = (items: AuthMenu[]) => items.forEach(item => {
      if (!item.visible) return;
      if (item.component && appRouteMap[item.component] && !used.has(item.component)) {
        used.add(item.component);
        fallback.push({
          name: item.name,
          detail: "快速进入",
          mark: item.name.slice(0, 1),
          tone: "slate",
          route: appRouteMap[item.component],
          component: item.component,
        });
      }
      if (item.children?.length) visit(item.children);
    });
    visit(state.menus);
    return preferred.concat(fallback).slice(0, 6);
  });
  const tabRoutes = ["/pages/home/index", "/pages/workbench/index", "/pages/message/index", "/pages/profile/index"];
  function openRoute(route?: string) {
    if (!route) return;
    if (tabRoutes.includes(route)) uni.switchTab({ url: route });
    else uni.navigateTo({ url: route });
  }
  function goMessages() { uni.switchTab({ url: "/pages/message/index" }); }
  function goLogin() { uni.navigateTo({ url: "/pages/login/index" }); }
  function formatDate(value: string) {
    const matched = value.match(/(\d{1,2})[-/](\d{1,2})$/);
    return matched ? `${matched[1]}/${matched[2]}` : value.slice(5);
  }
  function statusPercent(count: number) {
    return totalWorkOrders.value ? Math.max(count / totalWorkOrders.value * 100, 4) : 0;
  }
  async function loadDashboard() {
    if (!state.accessToken || loading.value) return;
    loading.value = true;
    loadError.value = "";
    try {
      const now = new Date();
      const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
      const [summaryData, trendData, statusData, attendanceData, stockData, purchaseData, pendingPurchaseData] = await Promise.all([
        getHomeSummary(state.accessToken),
        getProductionTrend(state.accessToken),
        getWorkOrderStatus(state.accessToken),
        getAttendanceSummaryPage(state.accessToken, { pageNo: 1, pageSize: 200, yearMonth }).catch(() => null),
        getMaterialStockPage(state.accessToken, { pageNo: 1, pageSize: 200 }).catch(() => null),
        getPurchaseOrderPage(state.accessToken, { pageNo: 1, pageSize: 1 }).catch(() => null),
        getPurchaseOrderPage(state.accessToken, { pageNo: 1, pageSize: 1, status: 10 }).catch(() => null),
      ]);
      summary.value = summaryData;
      trend.value = trendData;
      workOrderStatus.value = statusData;
      if (attendanceData) {
        const rows = attendanceData.list;
        const workDays = rows.reduce((total, item) => total + Number(item.workDays || 0), 0);
        const actualDays = rows.reduce((total, item) => total + Number(item.actualDays || 0), 0);
        attendanceOverview.value = {
          headCount: attendanceData.total,
          attendanceRate: workDays ? (actualDays / workDays * 100).toFixed(1) : "0.0",
          averageActualDays: rows.length ? (actualDays / rows.length).toFixed(1) : "0.0",
          lateCount: rows.reduce((total, item) => total + Number(item.lateCount || 0), 0),
          absentCount: rows.reduce((total, item) => total + Number(item.absentCount || 0), 0),
          leaveHours: rows.reduce((total, item) => total + Number(item.leaveHours || 0), 0),
        };
      }
      if (stockData || purchaseData || pendingPurchaseData) {
        const stockRows = stockData?.list || [];
        supplyOverview.value = {
          stockQuantity: stockRows.reduce((total, item) => total + Number(item.quantity || 0), 0),
          availableQuantity: stockRows.reduce((total, item) => total + Number(item.availableQuantity || 0), 0),
          purchaseTotal: purchaseData?.total || 0,
          pendingPurchaseTotal: pendingPurchaseData?.total || 0,
        };
      }
    } catch (cause) {
      loadError.value = cause instanceof Error ? cause.message : "首页数据加载失败";
    } finally {
      loading.value = false;
    }
  }
  async function refreshPage() {
    await restoreUser();
    try { await refreshUnreadCount(); } catch { /* æ¶ˆæ¯å¼‚常不影响首页 */ }
    await loadDashboard();
  }
  onShow(refreshPage);
  onPullDownRefresh(async () => { await refreshPage(); uni.stopPullDownRefresh(); });
  return {
    attendanceOverview, equipmentRate, formatDate, goLogin, goMessages, greeting, isLoggedIn, loadDashboard,
    loadError, loading, notifyState, openRoute, outputChange, qualityRate, statusPercent,
    summary, supplyOverview, today, totalWorkOrders, trend, trendMax, userName, visibleActions, workOrderStatus,
  };
}
src/pages/home/zhongyi-assembly.vue
@@ -33,6 +33,7 @@
  mark: string;
  tone: string;
  component: string;
  permanent?: boolean;
}
const peopleCards: DashboardCard[] = [
@@ -50,7 +51,7 @@
];
const quickEntries: QuickEntry[] = [
  { name: "考勤打卡", detail: "上下班打卡", mark: "勤", tone: "orange", component: "hrm/attendance/clock/index" },
  { name: "考勤打卡", detail: "上下班打卡", mark: "勤", tone: "orange", component: "hrm/attendance/clock/index", permanent: true },
  // äººäº‹ç”³è¯·å…¥å£æš‚不在首页展示。
  // { name: "请假申请", detail: "提交请假", mark: "假", tone: "blue", component: "hrm/leave/index" },
  // { name: "加班申请", detail: "提交加班", mark: "班", tone: "violet", component: "hrm/overtime/index" },
@@ -92,6 +93,10 @@
  const route = routeFor(component);
  if (route) uni.navigateTo({ url: route });
}
function openQuickEntry(item: QuickEntry) {
  const route = item.permanent ? appRouteMap[item.component] : routeFor(item.component);
  if (route) uni.navigateTo({ url: route });
}
const userName = computed(() => state.user?.nickname || state.user?.username || "同事");
const greeting = computed(() => {
@@ -108,7 +113,7 @@
  const weeks = ["日", "一", "二", "三", "四", "五", "六"];
  return `${value.getMonth() + 1}月${value.getDate()}日 æ˜ŸæœŸ${weeks[value.getDay()]}`;
});
const availableQuickEntries = computed(() => quickEntries.filter(item => canOpen(item.component)));
const availableQuickEntries = computed(() => quickEntries.filter(item => item.permanent || canOpen(item.component)));
function displayCount(key: string) {
  const value = counts.value[key];
@@ -193,7 +198,7 @@
        <view class="section quick-section">
          <view class="section-head"><view><text>快捷操作</text><text>员工自助服务</text></view><text @click="goWorkbench">全部应用</text></view>
          <view v-if="availableQuickEntries.length" class="quick-grid">
            <view v-for="item in availableQuickEntries" :key="item.component" class="quick-card" @click="openComponent(item.component)">
            <view v-for="item in availableQuickEntries" :key="item.component" class="quick-card" @click="openQuickEntry(item)">
              <view :class="['quick-mark', item.tone]">{{ item.mark }}</view><text>{{ item.name }}</text><text>{{ item.detail }}</text>
            </view>
          </view>
src/pages/hrm/attendance-clock/index.vue
@@ -77,6 +77,10 @@
  const isInsecureH5 = computed(() => {
    // #ifdef H5
    return typeof window !== "undefined" && !window.isSecureContext;
    // #endif
    // #ifndef H5
    return false;
    // #endif
  });
  const locationStatusText = computed(() => {
    if (!locationRequired.value) return "当前规则未启用位置限制";
@@ -228,6 +232,12 @@
      "当前用户"
  );
  const department = computed(() => status.value?.deptName || "未分配部门");
  const onLeave = computed(() => status.value?.onLeave === true || record.value?.leaveFlag === true);
  const leaveText = computed(() => {
    const names = status.value?.leaveTypeNames || "请假";
    const hours = Number(status.value?.leaveHours ?? record.value?.leaveHours ?? 0);
    return hours > 0 ? `${names} ${hours.toFixed(2)} å°æ—¶` : names;
  });
  const dateLabel = computed(() => {
    const rest =
      status.value && status.value.workDay === false
@@ -483,6 +493,10 @@
      <text class="smart-hint">{{ punch.hint }}</text>
      <text class="rule-time">班次 {{ rule?.workStartTime || "--:--" }} - {{ rule?.workEndTime || "--:--" }}</text>
    </view>
    <view v-if="onLeave" class="leave-alert">
      <strong>今日已请假,如需上班可正常打卡</strong>
      <text>{{ leaveText }} Â· è¯·å‡å½“天打卡不计迟到、早退或缺卡</text>
    </view>
    <!-- #ifdef H5 -->
    <view v-if="locationRequired && mapVisible"
          class="map-panel">
@@ -610,6 +624,19 @@
    align-items: center;
    padding: 38rpx 0 30rpx;
  }
  .leave-alert {
    display: flex;
    flex-direction: column;
    gap: 8rpx;
    padding: 20rpx 24rpx;
    margin-bottom: 20rpx;
    color: #245a91;
    background: #eaf4ff;
    border: 1rpx solid #b8d8f7;
    border-radius: 14rpx;
  }
  .leave-alert strong { font-size: 27rpx; }
  .leave-alert text { color: #5d7894; font-size: 23rpx; }
  .time {
    display: block;
    font-size: 66rpx;
src/pages/hrm/attendance-record/index.vue
@@ -9,6 +9,12 @@
const pageSize = 15;
const canQuery = computed(() => hasPermission("hrm:attendance-record:query"));
const hasMore = computed(() => rows.value.length < total.value);
const leaveNames: Record<string, string> = { "1":"事假", "2":"病假", "3":"年假", "4":"婚假", "5":"产假", "6":"丧假" };
function leaveText(row: AttendanceRecord) {
  if (!row.leaveFlag) return "";
  const names = String(row.leaveTypes || "").split(",").map(type => leaveNames[type.trim()] || type.trim()).filter(Boolean).join("、") || "请假";
  return Number(row.leaveHours) > 0 ? `${names} ${Number(row.leaveHours).toFixed(2)}h` : names;
}
async function load(reset = true) {
  if (!state.accessToken || !canQuery.value || loading.value) return;
  if (reset) { pageNo.value = 1; total.value = 0; }
@@ -24,4 +30,4 @@
onShow(() => load(true));
onPullDownRefresh(() => load(true));
onReachBottom(() => { if (hasMore.value) load(false); });
</script><template><view class="page"><template v-if="canQuery"><view class="search"><input v-model="keyword" placeholder="搜索员工姓名" @confirm="load"/><text @click="load">搜索</text></view><view v-for="row in rows" :key="row.id" class="card"><view class="head"><view class="head-main"><text class="name">{{row.userName||'--'}}</text><text class="sub">{{row.deptName||'--'}} Â· {{row.date||'--'}}</text></view><text class="tag tag-blue">{{row.dataSourceName||'考勤'}}</text></view><view class="grid"><text>上班:{{row.clockInTime||'--'}}</text><text>下班:{{row.clockOutTime||'--'}}</text><text>上班状态:{{row.clockInTypeName||'--'}}</text><text>下班状态:{{row.clockOutTypeName||'--'}}</text><text>工时:{{row.workHours??0}} å°æ—¶</text><text>加班:{{row.overtimeHours??0}} å°æ—¶</text><text>地点:{{row.location||'--'}}</text></view></view><view v-if="!rows.length" class="state">{{loading?'加载中...':'暂无考勤记录'}}</view><view v-else class="state more">{{loading ? "加载中..." : hasMore ? "上拉加载更多" : "没有更多了"}}</view></template><view v-else class="state">暂无考勤记录查看权限</view></view></template><style scoped lang="scss">@import "../list.scss";</style>
</script><template><view class="page"><template v-if="canQuery"><view class="search"><input v-model="keyword" placeholder="搜索员工姓名" @confirm="load"/><text @click="load">搜索</text></view><view v-for="row in rows" :key="row.id" class="card"><view class="head"><view class="head-main"><text class="name">{{row.userName||'--'}}</text><text class="sub">{{row.deptName||'--'}} Â· {{row.date||'--'}}</text></view><view class="tags"><text v-if="row.leaveFlag" class="tag tag-blue">{{leaveText(row)}}</text><text class="tag tag-blue">{{row.dataSourceName||'考勤'}}</text></view></view><view class="grid"><text>上班:{{row.clockInTime||'--'}}</text><text>下班:{{row.clockOutTime||'--'}}</text><text>上班状态:{{row.clockInTypeName||'--'}}</text><text>下班状态:{{row.clockOutTypeName||'--'}}</text><text>工时:{{row.workHours??0}} å°æ—¶</text><text>加班:{{row.overtimeHours??0}} å°æ—¶</text><text>地点:{{row.location||'--'}}</text></view></view><view v-if="!rows.length" class="state">{{loading?'加载中...':'暂无考勤记录'}}</view><view v-else class="state more">{{loading ? "加载中..." : hasMore ? "上拉加载更多" : "没有更多了"}}</view></template><view v-else class="state">暂无考勤记录查看权限</view></view></template><style scoped lang="scss">@import "../list.scss";</style>
src/pages/hrm/form.scss
@@ -1 +1 @@
.page{min-height:100vh;padding:24rpx 30rpx 70rpx;box-sizing:border-box;background:#f6f7f9}.section-title{padding:18rpx 6rpx 12rpx;color:#728197;font-size:23rpx}.form-card{overflow:hidden;background:#fff;border-radius:14rpx}.field{display:flex;align-items:center;min-height:96rpx;padding:0 24rpx;gap:18rpx;border-bottom:1rpx solid #eef1f5}.field>text:first-child{width:205rpx;flex:none;color:#33455d;font-size:27rpx}.field input,.field picker,.picker,.value{min-width:0;flex:1;color:#1e3049;font-size:27rpx}.field input,.value{text-align:right}.picker{display:flex;width:100%;align-items:center;justify-content:flex-end;gap:10rpx}.picker text:first-child{min-width:0;overflow:hidden;text-align:right;text-overflow:ellipsis;white-space:nowrap}.picker text:last-child{color:#8092a7;font-size:36rpx}.datetime{display:flex;min-width:0;flex:1;justify-content:flex-end;gap:16rpx}.datetime picker{flex:none}.datetime text{color:#1e3049;font-size:25rpx}.column{align-items:flex-start;flex-direction:column;padding:20rpx 24rpx}.column>text:first-child{width:auto}.column textarea{width:100%;height:150rpx;font-size:27rpx}.required{color:#e23b3b}.save{margin-top:32rpx;color:#fff;background:var(--color-primary, #1769d8);border-radius:10rpx}.save::after{border:0}.state{padding:38rpx;color:#8795a8;text-align:center}
.page{min-height:100vh;padding:24rpx 30rpx 70rpx;box-sizing:border-box;background:#f6f7f9}.section-title{padding:18rpx 6rpx 12rpx;color:#728197;font-size:23rpx}.form-card{overflow:hidden;background:#fff;border-radius:14rpx}.field{display:flex;align-items:center;min-height:96rpx;padding:0 24rpx;gap:18rpx;border-bottom:1rpx solid #eef1f5}.field>text:first-child{width:205rpx;flex:none;color:#33455d;font-size:27rpx}.field input,.field picker,.picker,.value{min-width:0;flex:1;color:#1e3049;font-size:27rpx}.field input,.value{text-align:right}.picker{display:flex;width:100%;align-items:center;justify-content:flex-end;gap:10rpx}.picker text:first-child{min-width:0;overflow:hidden;text-align:right;text-overflow:ellipsis;white-space:nowrap}.picker text:last-child{color:#8092a7;font-size:36rpx}.datetime{display:flex;min-width:0;flex:1;justify-content:flex-end;gap:16rpx}.datetime picker{flex:none}.datetime text{color:#1e3049;font-size:25rpx}.column{align-items:flex-start;flex-direction:column;padding:20rpx 24rpx}.column>text:first-child{width:auto}.column textarea{width:100%;height:150rpx;font-size:27rpx}.required{color:#e23b3b}.form-actions{display:flex;gap:20rpx;margin-top:32rpx}.form-actions .save{min-width:0;flex:1;margin-top:0}.save{margin-top:32rpx;color:#fff;background:var(--color-primary, #1769d8);border-radius:10rpx}.save.secondary{color:var(--color-primary, #1769d8);background:#fff;border:1rpx solid var(--color-primary, #1769d8)}.save::after{border:0}.state{padding:38rpx;color:#8795a8;text-align:center}
src/pages/hrm/leave/form.vue
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createLeave, getHrmDicts, getLeave, updateLeave, type HrmAttachment, type Leave } from "../../../api/hrm";
import { createLeave, getAutoApprovalPolicy, getHrmDicts, getLeave, getLeaveProcesses, submitLeave, updateLeave, type ApproveProcess, type HrmAttachment, type Leave } from "../../../api/hrm";
import type { DictData } from "../../../api/crm";
import { useAuth } from "../../../stores/auth";
import { HRM_DICT, label, options, setDateTime } from "../shared";
@@ -11,33 +11,94 @@
const id = ref<number>();
const mode = ref("edit");
const saving = ref(false);
const submitting = ref(false);
const dicts = ref<DictData[]>([]);
const attachments = ref<HrmAttachment[]>([]);
const form = reactive<Leave>({ userId: undefined, leaveType: undefined, startTime: "", endTime: "", duration: 0.5, reason: "", blobIds: [] });
const readonly = computed(() => mode.value === "detail");
const canSave = computed(() => !readonly.value && (id.value ? hasPermission("hrm:leave:update") : hasPermission("hrm:leave:create")));
const canSubmit = computed(() => canSave.value && hasPermission("hrm:leave:submit"));
const typeOptions = computed(() => options(dicts.value, HRM_DICT.leaveType));
const userText = computed(() => form.userName || state.user?.nickname || state.user?.username || "当前登录用户");
async function save() {
function validateForm() {
  if (!form.userId || form.leaveType === undefined || !form.startTime || !form.endTime || !form.duration || !form.reason?.trim()) {
    return uni.showToast({ title: "请完整填写必填项", icon: "none" });
    uni.showToast({ title: "请完整填写必填项", icon: "none" });
    return false;
  }
  if (new Date(form.endTime.replace(/-/g, "/")) <= new Date(form.startTime.replace(/-/g, "/"))) {
    return uni.showToast({ title: "结束时间应晚于开始时间", icon: "none" });
    uni.showToast({ title: "结束时间应晚于开始时间", icon: "none" });
    return false;
  }
  if (!state.accessToken || saving.value) return;
  return true;
}
async function persistDraft() {
  if (!state.accessToken) throw new Error("登录状态已失效");
  const data = { ...form, id: id.value, userId: state.user?.id };
  if (id.value) await updateLeave(state.accessToken, data);
  else id.value = await createLeave(state.accessToken, data);
  if (!id.value) throw new Error("草稿保存失败");
  const latest = await getLeave(state.accessToken, id.value);
  Object.assign(form, latest, { blobIds: latest.attachmentList?.map(item => item.id) || latest.blobIds || [] });
  attachments.value = latest.attachmentList || [];
  return latest;
}
function chooseProcess(processes: ApproveProcess[]) {
  return new Promise<ApproveProcess | undefined>(resolve => {
    uni.showActionSheet({
      title: "请选择请假审批流程",
      itemList: processes.map(item => item.name || item.key),
      success: result => resolve(processes[result.tapIndex]),
      fail: () => resolve(undefined),
    });
  });
}
async function save() {
  if (!validateForm() || !state.accessToken || saving.value || submitting.value) return;
  saving.value = true;
  try {
    const data = { ...form, id: id.value, userId: state.user?.id };
    if (id.value) await updateLeave(state.accessToken, data);
    else id.value = await createLeave(state.accessToken, data);
    await persistDraft();
    uni.showToast({ title: "保存成功", icon: "success" });
    setTimeout(() => uni.navigateBack(), 500);
  } catch (error) {
    uni.showToast({ title: error instanceof Error ? error.message : "保存失败", icon: "none" });
  } finally {
    saving.value = false;
  }
}
async function saveAndSubmit() {
  if (!validateForm() || !state.accessToken || saving.value || submitting.value) return;
  submitting.value = true;
  let draftSaved = false;
  try {
    const latest = await persistDraft();
    draftSaved = true;
    if (!latest.id) throw new Error("未获取到最新申请信息");
    const automaticApproval = await getAutoApprovalPolicy(state.accessToken).catch(() => false);
    let processKey = "hrm_leave";
    if (!automaticApproval) {
      const processes = await getLeaveProcesses(state.accessToken);
      if (!processes.length) throw new Error("暂无可用审批流程");
      const process = await chooseProcess(processes);
      if (!process) {
        uni.showToast({ title: "已保存草稿", icon: "success" });
        return;
      }
      processKey = process.key;
    }
    await submitLeave(state.accessToken, latest.id, processKey);
    uni.showToast({ title: "提交成功", icon: "success" });
    setTimeout(() => uni.navigateBack(), 500);
  } catch (error) {
    const message = error instanceof Error ? error.message : "提交失败";
    uni.showToast({ title: draftSaved ? `草稿已保存,${message}` : message, icon: "none" });
  } finally {
    submitting.value = false;
  }
}
@@ -95,12 +156,18 @@
      </view>
      <view class="field"><text>请假时长 <text class="required">*</text></text><input v-model.number="form.duration" :disabled="readonly" type="digit" placeholder="单位:天" /></view>
      <view class="field column"><text>请假原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" maxlength="500" placeholder="请输入请假原因" /></view>
      <HrmAttachmentField v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
      <HrmAttachmentField class="leave-attachment" v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
      <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view>
    </view>
    <button v-if="canSave" class="save" :loading="saving" @click="save">保存草稿</button>
    <view v-if="canSave" class="form-actions">
      <button class="save secondary" :loading="saving" :disabled="submitting" @click="save">保存草稿</button>
      <button v-if="canSubmit" class="save" :loading="submitting" :disabled="saving" @click="saveAndSubmit">直接提交</button>
    </view>
    <button v-else-if="readonly&&form.status===0&&hasPermission('hrm:leave:update')" class="save" @click="mode='edit'">编辑</button>
  </view>
</template>
<style lang="scss" scoped>@import "../form.scss";</style>
<style lang="scss" scoped>
@import "../form.scss";
.leave-attachment{box-sizing:border-box;padding-right:24rpx;padding-left:24rpx}
</style>
src/pages/hrm/leave/index.vue
@@ -13,18 +13,24 @@
const total = ref(0);
const loading = ref(false);
const error = ref("");
const canQuery = computed(() => hasPermission("hrm:leave:query"));
const canCreate = computed(() => hasPermission("hrm:leave:create"));
const hasMore = computed(() => rows.value.length < total.value);
const isOwn = (row: Leave) => Number(row.userId) === Number(state.user?.id);
const canOperate = (row: Leave) => isOwn(row);
async function load(reset = false) {
  if (!state.accessToken || loading.value || !canQuery.value) return;
  if (!state.accessToken || loading.value) return;
  if (reset) { pageNo.value = 1; rows.value = []; }
  loading.value = true;
  error.value = "";
  try {
    if (!dicts.value.length) dicts.value = await getHrmDicts(state.accessToken);
    const data = await getLeavePage(state.accessToken, { pageNo: pageNo.value, pageSize: 15, userId: state.user?.id });
    const data = await getLeavePage(state.accessToken, {
      pageNo: pageNo.value,
      pageSize: 15,
      userId: state.user?.id,
    });
    const page = data.list || [];
    total.value = data.total || 0;
    rows.value = reset ? page : rows.value.concat(page);
@@ -42,8 +48,8 @@
function chooseProcess(processes: ApproveProcess[]) {
  return new Promise<ApproveProcess | undefined>(resolve => {
    if (processes.length === 1) return resolve(processes[0]);
    uni.showActionSheet({
      title: "请选择请假审批流程",
      itemList: processes.map(item => item.name || item.key),
      success: result => resolve(processes[result.tapIndex]),
      fail: () => resolve(undefined)
@@ -52,7 +58,7 @@
}
async function submit(row: Leave) {
  if (!state.accessToken || !row.id) return;
  if (!state.accessToken || !row.id || !canOperate(row)) return;
  try {
    const automaticApproval = await getAutoApprovalPolicy(state.accessToken).catch(() => false);
    let processKey = "hrm_leave";
@@ -72,7 +78,7 @@
}
function remove(row: Leave) {
  if (!state.accessToken || !row.id) return;
  if (!state.accessToken || !row.id || !canOperate(row)) return;
  uni.showModal({
    title: "删除申请",
    content: "确定删除该请假申请吗?",
@@ -96,14 +102,13 @@
<template>
  <view class="page">
    <view v-if="!canQuery" class="state">暂无请假申请查看权限</view>
    <view v-if="error" class="state error" @click="load(true)">{{ error }}</view>
    <view v-for="row in rows" :key="row.id" class="card">
      <view class="head"><view><text class="name">{{ row.userName || "--" }}的请假申请</text><text class="sub">申请编号 {{ row.id }} Â· {{ row.deptName || "未分配部门" }}</text></view><text :class="['tag', auditClass(row.status)]">{{ label(dicts, HRM_DICT.auditStatus, row.status) }}</text></view>
      <view class="grid"><text>类型:{{ label(dicts, HRM_DICT.leaveType, row.leaveType) }}</text><text>时长:{{ row.duration ?? "--" }} å¤©</text><text>开始:{{ displayDate(row.startTime) }}</text><text>结束:{{ displayDate(row.endTime) }}</text></view>
      <view class="actions"><button @click="go(row.id, 'detail')">详情</button><button v-if="row.status===0&&hasPermission('hrm:leave:update')" @click="go(row.id)">编辑</button><button v-if="row.status===0&&hasPermission('hrm:leave:submit')" @click="submit(row)">提交</button><button v-if="row.status===0&&hasPermission('hrm:leave:delete')" class="danger" @click="remove(row)">删除</button></view>
      <view class="actions"><button @click="go(row.id, 'detail')">详情</button><button v-if="canOperate(row)&&row.status===0&&hasPermission('hrm:leave:update')" @click="go(row.id)">编辑</button><button v-if="canOperate(row)&&row.status===0&&hasPermission('hrm:leave:submit')" @click="submit(row)">提交</button><button v-if="canOperate(row)&&(row.status===0||row.status===40)&&hasPermission('hrm:leave:delete')" class="danger" @click="remove(row)">删除</button></view>
    </view>
    <view v-if="canQuery&&!rows.length&&!loading" class="state">暂无本人提交的请假申请</view>
    <view v-if="!rows.length&&!loading&&!error" class="state">暂无本人提交的请假申请</view>
    <view v-else-if="rows.length" class="more">{{ hasMore ? (loading ? "加载中..." : "上拉加载更多") : "没有更多了" }}</view>
    <button v-if="canCreate" class="fab" @click="go()"><view /></button>
  </view>
src/pages/hrm/list.scss
@@ -1 +1 @@
.page{min-height:100vh;padding: 20rpx 30rpx 132rpx;box-sizing:border-box;background:#f3f6fb}.search{display:flex;align-items:center;height:80rpx;padding:0 20rpx;margin-bottom:18rpx;background:#fff;border-radius:12rpx}.search input{min-width:0;flex:1;font-size:27rpx}.search text{padding-left:18rpx;color:var(--color-primary, #1769d8);font-size:24rpx}.card{padding:24rpx 22rpx 20rpx;margin-bottom:18rpx;background:#fff;border-radius:14rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.07)}.head{display:flex;align-items:flex-start;justify-content:space-between;gap:16rpx}.head>view{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}.name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.sub{color:#78879b;font-size:23rpx}.tag{flex:none;padding:6rpx 15rpx;font-size:22rpx;border-radius:999rpx}.tag-on{color:#0e9663;background:#e9fbf2}.tag-off{color:#66778c;background:#f0f3f7}.tag-blue{color:var(--color-primary, #1769d8);background:var(--color-primary-soft, #eef6ff)}.tag-danger{color:#c65050;background:#fff0f0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:11rpx;padding:16rpx 0;margin-top:15rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6;border-bottom:1rpx solid #edf1f6}.grid text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:12rpx;padding-top:16rpx}.actions button{min-height:50rpx;padding:0 16rpx;margin:0;color:var(--color-primary, #1769d8);font-size:22rpx;line-height:48rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee);border-radius:8rpx}.actions .danger{color:#c65050;background:#fff7f7;border-color:#e3a0a0}.actions button::after,.fab::after{border:0}.state,.more{display:block;padding:45rpx 0;color:#8795a8;font-size:26rpx;text-align:center}.error{color:#c65050}.fab{position:fixed;right:32rpx;bottom:56rpx;display:flex;width:100rpx;height:100rpx;align-items:center;justify-content:center;padding:0;margin:0;background:var(--color-primary, #1769d8);border-radius:50%;box-shadow:0 14rpx 28rpx rgba(23,105,216,.32)}.fab view{position:relative;width:38rpx;height:38rpx}.fab view::before,.fab view::after{position:absolute;content:"";background:#fff}.fab view::before{top:17rpx;width:38rpx;height:4rpx}.fab view::after{left:17rpx;width:4rpx;height:38rpx}
.page{min-height:100vh;padding: 20rpx 30rpx 132rpx;box-sizing:border-box;background:#f3f6fb}.search{display:flex;align-items:center;height:80rpx;padding:0 20rpx;margin-bottom:18rpx;background:#fff;border-radius:12rpx}.search input{min-width:0;flex:1;font-size:27rpx}.search text{padding-left:18rpx;color:var(--color-primary, #1769d8);font-size:24rpx}.card{padding:24rpx 22rpx 20rpx;margin-bottom:18rpx;background:#fff;border-radius:14rpx;box-shadow:0 8rpx 20rpx rgba(43,69,107,.07)}.head{display:flex;align-items:flex-start;justify-content:space-between;gap:16rpx}.head>view{display:flex;min-width:0;flex:1;flex-direction:column;gap:8rpx}.name{overflow:hidden;color:#20334c;font-size:30rpx;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.sub{color:#78879b;font-size:23rpx}.tag{flex:none;padding:6rpx 15rpx;font-size:22rpx;border-radius:999rpx}.tag-on{color:#0e9663;background:#e9fbf2}.tag-off{color:#66778c;background:#f0f3f7}.tag-blue{color:var(--color-primary, #1769d8);background:var(--color-primary-soft, #eef6ff)}.tag-danger{color:#c65050;background:#fff0f0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:11rpx;padding:16rpx 0;margin-top:15rpx;color:#66778c;font-size:23rpx;border-top:1rpx solid #edf1f6;border-bottom:1rpx solid #edf1f6}.grid text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:12rpx;padding-top:16rpx}.actions button{min-height:50rpx;padding:0 16rpx;margin:0;color:var(--color-primary, #1769d8);font-size:22rpx;line-height:48rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee);border-radius:8rpx}.actions .danger{color:#c65050;background:#fff7f7;border-color:#e3a0a0}.actions button::after,.fab::after{border:0}.state,.more{display:block;padding:45rpx 0;color:#8795a8;font-size:26rpx;text-align:center}.error{color:#c65050}.fab{position:fixed;right:32rpx;bottom:56rpx;display:flex;width:100rpx;height:100rpx;align-items:center;justify-content:center;padding:0;margin:0;background:var(--color-primary, #1769d8);border-radius:50%;box-shadow:0 14rpx 28rpx rgba(23,105,216,.32)}.fab view{position:relative;width:38rpx;height:38rpx}.fab view::before,.fab view::after{position:absolute;content:"";background:#fff}.fab view::before{top:17rpx;left:0;width:38rpx;height:4rpx}.fab view::after{top:0;left:17rpx;width:4rpx;height:38rpx}
src/pages/hrm/resignation/form.vue
@@ -6,16 +6,18 @@
import { useAuth } from "../../../stores/auth";
import { HRM_DICT, label, options } from "../shared";
import HrmAttachmentField from "../HrmAttachmentField.vue";
import PickerSheet from "../../mes/process-design/components/PickerSheet.vue";
const { hasPermission, state } = useAuth();
const id = ref<number>(); const mode = ref("edit"); const saving = ref(false);
const handoverVisible = ref(false);
const dicts = ref<DictData[]>([]); const employees = ref<EmployeeSimple[]>([]); const attachments = ref<HrmAttachment[]>([]);
const form = reactive<Resignation>({ userId: undefined, resignationType: undefined, expectedDate: "", reason: "", handoverUserId: undefined, handoverRemark: "", blobIds: [], remark: "" });
const readonly = computed(() => mode.value === "detail");
const canSave = computed(() => !readonly.value && (id.value ? hasPermission("hrm:resignation:update") : hasPermission("hrm:resignation:create")));
const typeOptions = computed(() => options(dicts.value, HRM_DICT.resignationType));
const handoverText = computed(() => employees.value.find(item => item.id === form.handoverUserId)?.name || form.handoverUserName || "请选择交接人");
function selectHandover(index: number) { const item = employees.value[index]; form.handoverUserId = item?.id; form.handoverUserName = item?.name; }
function selectHandover(keys: Array<number | string>) { const item = employees.value.find(row => row.id === Number(keys[0])); form.handoverUserId = item?.id; form.handoverUserName = item?.name; }
async function save() {
  if (form.resignationType === undefined || !form.expectedDate || !form.reason?.trim()) return uni.showToast({ title: "请完整填写必填项", icon: "none" });
  if (!state.accessToken || saving.value) return; saving.value = true;
@@ -50,10 +52,10 @@
  <view class="field"><text>申请人</text><text class="value">{{ form.userName || state.user?.nickname || "当前登录用户" }}</text></view>
  <view class="field"><text>离职类型 <text class="required">*</text></text><picker v-if="!readonly" :range="typeOptions" range-key="label" @change="form.resignationType=Number(typeOptions[$event.detail.value].value)"><view class="picker"><text>{{ label(dicts,HRM_DICT.resignationType,form.resignationType,"请选择离职类型") }}</text><text>›</text></view></picker><text v-else class="value">{{ form.resignationTypeName || label(dicts,HRM_DICT.resignationType,form.resignationType) }}</text></view>
  <view class="field"><text>预计离职日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="form.expectedDate" @change="form.expectedDate=$event.detail.value"><view class="picker"><text>{{ form.expectedDate || "请选择日期" }}</text><text>›</text></view></picker><text v-else class="value">{{ form.expectedDate || "--" }}</text></view>
  <view class="field"><text>交接人</text><picker v-if="!readonly" :range="employees" range-key="name" @change="selectHandover(Number($event.detail.value))"><view class="picker"><text>{{ handoverText }}</text><text>›</text></view></picker><text v-else class="value">{{ handoverText }}</text></view>
  <view class="field"><text>交接人</text><view v-if="!readonly" class="picker" @click="handoverVisible=true"><text>{{ handoverText }}</text><text>›</text></view><text v-else class="value">{{ handoverText }}</text></view>
  <view class="field column"><text>离职原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" maxlength="500" placeholder="请输入离职原因" /></view>
  <view class="field column"><text>交接说明</text><textarea v-model="form.handoverRemark" :disabled="readonly" maxlength="500" placeholder="请输入交接说明" /></view>
  <HrmAttachmentField v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
  <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view>
</view><button v-if="canSave" class="save" :loading="saving" @click="save">保存草稿</button><button v-else-if="readonly&&form.status===0&&hasPermission('hrm:resignation:update')" class="save" @click="mode='edit'">编辑</button></view></template>
</view><button v-if="canSave" class="save" :loading="saving" @click="save">保存草稿</button><button v-else-if="readonly&&form.status===0&&hasPermission('hrm:resignation:update')" class="save" @click="mode='edit'">编辑</button><PickerSheet v-model:show="handoverVisible" title="选择交接人" :items="employees.map(item=>({key:item.id||0,label:item.name||'--',sub:item.employeeNo}))" :selected-keys="form.handoverUserId?[form.handoverUserId]:[]" search-placeholder="搜索姓名或工号" @confirm="selectHandover" /></view></template>
<style lang="scss" scoped>@import "../form.scss";</style>
src/pages/mes/process-design/components/PickerSheet.vue
@@ -40,6 +40,11 @@
  const picked = ref<Array<number | string>>([]);
  const pickedSet = computed(() => new Set(picked.value.map(String)));
  const displayedItems = computed(() => {
    const value = keyword.value.trim().toLocaleLowerCase();
    if (!value) return props.items;
    return props.items.filter(item => [item.label, item.sub, item.extra].some(text => String(text || "").toLocaleLowerCase().includes(value)));
  });
  function isPicked(key: number | string) {
    return pickedSet.value.has(String(key));
@@ -117,8 +122,8 @@
      </view>
      <scroll-view class="sheet-body" scroll-y lower-threshold="60" @scrolltolower="onScrollToLower">
        <view v-if="loading && !items.length" class="sheet-state">加载中...</view>
        <template v-else-if="items.length">
          <view v-for="item in items" :key="item.key" class="sheet-row" @click="toggle(item)">
        <template v-else-if="displayedItems.length">
          <view v-for="item in displayedItems" :key="item.key" class="sheet-row" @click="toggle(item)">
            <view class="sheet-row-main">
              <text class="sheet-row-label">{{ item.label }}</text>
              <text v-if="item.sub" class="sheet-row-sub">{{ item.sub }}</text>
src/pages/mes/qc/ipqc/form.vue
@@ -14,6 +14,7 @@
} from "../../../../api/quality";
import { useAuth } from "../../../../stores/auth";
import { dateText, dictLabel, dictOptions } from "../../dv/shared";
import PickerSheet from "../../process-design/components/PickerSheet.vue";
import { usePagedList } from "../paged";
const { hasPermission, state } = useAuth();
@@ -27,6 +28,7 @@
const activeTab = ref<"line" | "result" | "defect">("line");
const dicts = ref<DictData[]>([]);
const users = ref<UserSimple[]>([]);
const userSheet = ref(false);
const workOrders = ref<WorkOrder[]>([]);
const workstations = ref<MesWorkstation[]>([]);
const tasks = ref<ProTask[]>([]);
@@ -338,7 +340,7 @@
      <view class="field hint-field"><text>不合格品数量 <text class="required">*</text></text><input :value="String(form.unqualifiedQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入不合格品数量" @input="form.unqualifiedQuantity=Number($event.detail.value||0)" /></view>
      <view class="field-hint">不合格品数量大于0时,保存后将自动生成不合格处理单</view>
      <template v-if="Number(form.unqualifiedQuantity || 0) > 0"><view class="field"><text>工废数量 <text class="required">*</text></text><input :value="String(form.laborScrapQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入工废数量" @input="form.laborScrapQuantity=Number($event.detail.value||0);syncUnqualified()" /></view><view class="field"><text>料废数量 <text class="required">*</text></text><input :value="String(form.materialScrapQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入料废数量" @input="form.materialScrapQuantity=Number($event.detail.value||0);syncUnqualified()" /></view><view class="field"><text>其他废品数量 <text class="required">*</text></text><input :value="String(form.otherScrapQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入其他废品数量" @input="form.otherScrapQuantity=Number($event.detail.value||0);syncUnqualified()" /></view></template>
      <view class="field"><text>检测人员 <text class="required">*</text></text><picker v-if="!readonly" :range="users" range-key="nickname" @change="form.inspectorUserId=users[$event.detail.value].id;form.inspectorNickname=users[$event.detail.value].nickname"><view class="picker"><text>{{ userName() }}</text><text>›</text></view></picker><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测人员 <text class="required">*</text></text><view v-if="!readonly" class="picker" @click="userSheet=true"><text>{{ userName() }}</text><text>›</text></view><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="String(form.inspectDate || '')" @change="form.inspectDate=$event.detail.value"><view class="picker"><text>{{ dateText(form.inspectDate).replace('--','请选择检测日期') }}</text><text>›</text></view></picker><text v-else class="value">{{ dateText(form.inspectDate) }}</text></view>
      <view class="field"><text>检测结果</text><picker v-if="!readonly" :range="checkResults" range-key="label" @change="form.checkResult=Number(checkResults[$event.detail.value].value)"><view class="picker"><text>{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult,'请选择检测结果') }}</text><text>›</text></view></picker><text v-else class="value">{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult) }}</text></view>
      <view v-if="id" class="field"><text>缺陷统计</text><text class="value">致命 {{ form.criticalQuantity ?? 0 }} / ä¸¥é‡ {{ form.majorQuantity ?? 0 }} / è½»å¾® {{ form.minorQuantity ?? 0 }}</text></view>
@@ -395,6 +397,7 @@
        <button class="save in-modal" @click="saveDefect">保存</button>
      </template>
    </view></view>
    <PickerSheet v-model:show="userSheet" title="选择检测人员" :items="users.map(item=>({key:item.id,label:item.nickname||item.username||String(item.id)}))" :selected-keys="form.inspectorUserId?[form.inspectorUserId]:[]" search-placeholder="搜索人员" @confirm="keys=>{const item=users.find(row=>row.id===Number(keys[0]));form.inspectorUserId=item?.id;form.inspectorNickname=item?.nickname}" />
  </view>
</template>
src/pages/mes/qc/iqc/form.vue
@@ -33,6 +33,7 @@
const templates = ref<Array<{ templateId?: number; templateName?: string; templateCode?: string }>>([]);
const form = reactive<Iqc>({ qualifiedQuantity: 0, unqualifiedQuantity: 0 });
const materialSheet = ref(false);
const userSheet = ref(false);
/** äº§å“ç‰©æ–™å€™é€‰ï¼šå…³é”®å­—远程检索 + ä¸Šæ‹‰åŠ è½½æ›´å¤šï¼ˆæ›¿ä»£åŽŸå…ˆä¸€æ¬¡æ‹‰ 200 æ¡çš„ picker) */
const materialPicker = usePagedList<MesMdItem>(
@@ -301,7 +302,7 @@
      <view class="field hint-field"><text>不合格品数量 <text class="required">*</text></text><input :value="String(form.unqualifiedQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入不合格品数量" @input="form.unqualifiedQuantity=Number($event.detail.value||0)" /></view>
      <view class="field-hint">不合格品数量大于0时,保存后将自动生成不合格处理单</view>
      <view class="field"><text>来料日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="String(form.receiveDate || '')" @change="form.receiveDate=$event.detail.value"><view class="picker"><text>{{ dateText(form.receiveDate).replace('--','请选择来料日期') }}</text><text>›</text></view></picker><text v-else class="value">{{ dateText(form.receiveDate) }}</text></view>
      <view class="field"><text>检测人员 <text class="required">*</text></text><picker v-if="!readonly" :range="users" range-key="nickname" @change="form.inspectorUserId=users[$event.detail.value].id;form.inspectorNickname=users[$event.detail.value].nickname"><view class="picker"><text>{{ userName() }}</text><text>›</text></view></picker><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测人员 <text class="required">*</text></text><view v-if="!readonly" class="picker" @click="userSheet=true"><text>{{ userName() }}</text><text>›</text></view><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="String(form.inspectDate || '')" @change="form.inspectDate=$event.detail.value"><view class="picker"><text>{{ dateText(form.inspectDate).replace('--','请选择检测日期') }}</text><text>›</text></view></picker><text v-else class="value">{{ dateText(form.inspectDate) }}</text></view>
      <view class="field"><text>检测结果</text><picker v-if="!readonly" :range="checkResults" range-key="label" @change="form.checkResult=Number(checkResults[$event.detail.value].value)"><view class="picker"><text>{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult,'请选择检测结果') }}</text><text>›</text></view></picker><text v-else class="value">{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult) }}</text></view>
      <view v-if="id" class="field"><text>缺陷统计</text><text class="value">致命 {{ form.criticalQuantity ?? 0 }} / ä¸¥é‡ {{ form.majorQuantity ?? 0 }} / è½»å¾® {{ form.minorQuantity ?? 0 }}</text></view>
@@ -371,6 +372,7 @@
      @load-more="materialPicker.loadMore"
      @confirm="pickMaterial"
    />
    <PickerSheet v-model:show="userSheet" title="选择检测人员" :items="users.map(item=>({key:item.id,label:item.nickname||item.username||String(item.id)}))" :selected-keys="form.inspectorUserId?[form.inspectorUserId]:[]" search-placeholder="搜索人员" @confirm="keys=>{const item=users.find(row=>row.id===Number(keys[0]));form.inspectorUserId=item?.id;form.inspectorNickname=item?.nickname}" />
  </view>
</template>
src/pages/mes/qc/oqc/form.vue
@@ -32,6 +32,7 @@
const templates = ref<Array<{ templateId?: number; templateName?: string; templateCode?: string }>>([]);
const form = reactive<Oqc>({ qualifiedQuantity: 0, unqualifiedQuantity: 0 });
const materialSheet = ref(false);
const userSheet = ref(false);
/** äº§å“ç‰©æ–™å€™é€‰ï¼šå…³é”®å­—远程检索 + ä¸Šæ‹‰åŠ è½½æ›´å¤šï¼ˆæ›¿ä»£åŽŸå…ˆä¸€æ¬¡æ‹‰ 200 æ¡çš„ picker) */
const materialPicker = usePagedList<MesMdItem>(
@@ -318,7 +319,7 @@
      <view class="field hint-field"><text>不合格品数量 <text class="required">*</text></text><input :value="String(form.unqualifiedQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入不合格品数量" @input="form.unqualifiedQuantity=Number($event.detail.value||0)" /></view>
      <view class="field-hint">不合格品数量大于0时,保存后将自动生成不合格处理单</view>
      <view class="field"><text>出货日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="String(form.outDate || '')" @change="form.outDate=$event.detail.value"><view class="picker"><text>{{ dateText(form.outDate).replace('--','请选择出货日期') }}</text><text>›</text></view></picker><text v-else class="value">{{ dateText(form.outDate) }}</text></view>
      <view class="field"><text>检测人员 <text class="required">*</text></text><picker v-if="!readonly" :range="users" range-key="nickname" @change="form.inspectorUserId=users[$event.detail.value].id;form.inspectorNickname=users[$event.detail.value].nickname"><view class="picker"><text>{{ userName() }}</text><text>›</text></view></picker><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测人员 <text class="required">*</text></text><view v-if="!readonly" class="picker" @click="userSheet=true"><text>{{ userName() }}</text><text>›</text></view><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="String(form.inspectDate || '')" @change="form.inspectDate=$event.detail.value"><view class="picker"><text>{{ dateText(form.inspectDate).replace('--','请选择检测日期') }}</text><text>›</text></view></picker><text v-else class="value">{{ dateText(form.inspectDate) }}</text></view>
      <view class="field"><text>检测结果</text><picker v-if="!readonly" :range="checkResults" range-key="label" @change="form.checkResult=Number(checkResults[$event.detail.value].value)"><view class="picker"><text>{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult,'请选择检测结果') }}</text><text>›</text></view></picker><text v-else class="value">{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult) }}</text></view>
      <view v-if="id" class="field"><text>缺陷统计</text><text class="value">致命 {{ form.criticalQuantity ?? 0 }} / ä¸¥é‡ {{ form.majorQuantity ?? 0 }} / è½»å¾® {{ form.minorQuantity ?? 0 }}</text></view>
@@ -388,6 +389,7 @@
      @load-more="materialPicker.loadMore"
      @confirm="pickMaterial"
    />
    <PickerSheet v-model:show="userSheet" title="选择检测人员" :items="users.map(item=>({key:item.id,label:item.nickname||item.username||String(item.id)}))" :selected-keys="form.inspectorUserId?[form.inspectorUserId]:[]" search-placeholder="搜索人员" @confirm="keys=>{const item=users.find(row=>row.id===Number(keys[0]));form.inspectorUserId=item?.id;form.inspectorNickname=item?.nickname}" />
  </view>
</template>
src/pages/mes/qc/rqc/form.vue
@@ -31,6 +31,7 @@
const templates = ref<Array<{ templateId?: number; templateName?: string; templateCode?: string }>>([]);
const form = reactive<Rqc>({ qualifiedQuantity: 0, unqualifiedQuantity: 0 });
const materialSheet = ref(false);
const userSheet = ref(false);
/** äº§å“ç‰©æ–™å€™é€‰ï¼šå…³é”®å­—远程检索 + ä¸Šæ‹‰åŠ è½½æ›´å¤šï¼ˆæ›¿ä»£åŽŸå…ˆä¸€æ¬¡æ‹‰ 200 æ¡çš„ picker) */
const materialPicker = usePagedList<MesMdItem>(
@@ -298,7 +299,7 @@
      <view class="field hint-field"><text>不合格品数量 <text class="required">*</text></text><input :value="String(form.unqualifiedQuantity ?? '')" :disabled="readonly" type="digit" placeholder="请输入不合格品数量" @input="form.unqualifiedQuantity=Number($event.detail.value||0)" /></view>
      <view class="field-hint">不合格品数量大于0时,保存后将自动生成不合格处理单</view>
      
      <view class="field"><text>检测人员 <text class="required">*</text></text><picker v-if="!readonly" :range="users" range-key="nickname" @change="form.inspectorUserId=users[$event.detail.value].id;form.inspectorNickname=users[$event.detail.value].nickname"><view class="picker"><text>{{ userName() }}</text><text>›</text></view></picker><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测人员 <text class="required">*</text></text><view v-if="!readonly" class="picker" @click="userSheet=true"><text>{{ userName() }}</text><text>›</text></view><text v-else class="value">{{ userName() }}</text></view>
      <view class="field"><text>检测日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="String(form.inspectDate || '')" @change="form.inspectDate=$event.detail.value"><view class="picker"><text>{{ dateText(form.inspectDate).replace('--','请选择检测日期') }}</text><text>›</text></view></picker><text v-else class="value">{{ dateText(form.inspectDate) }}</text></view>
      <view class="field"><text>检测结果</text><picker v-if="!readonly" :range="checkResults" range-key="label" @change="form.checkResult=Number(checkResults[$event.detail.value].value)"><view class="picker"><text>{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult,'请选择检测结果') }}</text><text>›</text></view></picker><text v-else class="value">{{ dictLabel(dicts,'mes_qc_check_result',form.checkResult) }}</text></view>
      <view v-if="id" class="field"><text>缺陷统计</text><text class="value">致命 {{ form.criticalQuantity ?? 0 }} / ä¸¥é‡ {{ form.majorQuantity ?? 0 }} / è½»å¾® {{ form.minorQuantity ?? 0 }}</text></view>
@@ -368,6 +369,7 @@
      @load-more="materialPicker.loadMore"
      @confirm="pickMaterial"
    />
    <PickerSheet v-model:show="userSheet" title="选择检测人员" :items="users.map(item=>({key:item.id,label:item.nickname||item.username||String(item.id)}))" :selected-keys="form.inspectorUserId?[form.inspectorUserId]:[]" search-placeholder="搜索人员" @confirm="keys=>{const item=users.find(row=>row.id===Number(keys[0]));form.inspectorUserId=item?.id;form.inspectorNickname=item?.nickname}" />
  </view>
</template>
src/pages/workbench/index.vue
@@ -28,7 +28,6 @@
  const loadError = ref("");
  const activeGroup = ref("全部");
  const hiddenAppNames = [
    "请假申请",
    "调岗申请",
    "离职申请",
    "加班申请",
@@ -42,7 +41,6 @@
    "MES é¦–页",
  ];
  const hiddenComponentPatterns = [
    /^hrm\/leave\/index$/i,
    /^hrm\/transfer\/index$/i,
    /^hrm\/resignation\/index$/i,
    /^hrm\/overtime\/index$/i,
@@ -215,8 +213,10 @@
  const apps = computed(() => {
    const menuApps = flattenMenus(state.menus);
    // â€œæˆ‘的申请”模块暂时隐藏;恢复时取消下一行注释,并在返回值中重新合并 selfServiceApps。
    // const selfServiceApps = getSelfServiceApps(menuApps);
    // è¯·å‡æ˜¯æ‰€æœ‰å‘˜å·¥éƒ½å¯ä½¿ç”¨çš„自助申请;有菜单权限时使用正式菜单,没有时补充“我的申请”入口。
    const selfServiceApps = getSelfServiceApps(menuApps).filter(
      item => item.component === "hrm/leave/index"
    );
    const attendance =
      !isModuleEnabled("考勤规则") ||
      menuApps.some(app => app.component === "hrm/attendance/clock/index")
@@ -235,7 +235,7 @@
              mark: "mark-9",
            },
          ];
    return menuApps.concat(attendance);
    return menuApps.concat(selfServiceApps, attendance);
  });
  const groups = computed(() => [
    "全部",
src/stores/theme.ts
@@ -1,6 +1,7 @@
import { computed, readonly, ref } from "vue";
import { APP_COMPANY } from "../config/company";
export type ThemeKey = "blue" | "green" | "purple";
export type ThemeKey = "blue" | "electric" | "green" | "aurora" | "purple";
export interface AppTheme {
  key: ThemeKey;
@@ -15,7 +16,7 @@
  cardBackground: string;
}
export const THEME_STORAGE_KEY = "mom_app_theme";
export const THEME_STORAGE_KEY = `mom_app_theme_${APP_COMPANY.key}`;
export const appThemes: AppTheme[] = [
  {
@@ -43,6 +44,30 @@
    cardBackground: "#ffffff",
  },
  {
    key: "electric",
    name: "电力深蓝",
    description: "冷峻清晰,突出电力运行状态",
    primary: "#0879e8",
    primaryDark: "#0756ac",
    primaryLight: "#47a9ff",
    primarySoft: "#e9f5ff",
    primaryBorder: "#b7ddff",
    pageBackground: "#f2f7fb",
    cardBackground: "#ffffff",
  },
  {
    key: "aurora",
    name: "智能极光",
    description: "紫青渐变,体现智能科技感",
    primary: "#7257df",
    primaryDark: "#5640b7",
    primaryLight: "#9a84f3",
    primarySoft: "#f2efff",
    primaryBorder: "#d9d0ff",
    pageBackground: "#f7f5fc",
    cardBackground: "#ffffff",
  },
  {
    key: "purple",
    name: "科技紫",
    description: "层次鲜明,适合数据场景",
@@ -56,7 +81,12 @@
  },
];
const defaultTheme = appThemes[0];
const companyDefaultThemeKey: ThemeKey = APP_COMPANY.key === "changhui"
  ? "electric"
  : APP_COMPANY.key === "kangyi"
    ? "aurora"
    : "blue";
const defaultTheme = appThemes.find(item => item.key === companyDefaultThemeKey) || appThemes[0];
const currentTheme = ref<AppTheme>(defaultTheme);
function findTheme(key?: string) {