소스 검색

更新代码

yanghao 1 개월 전
부모
커밋
3ca2430066

+ 40 - 4
App.vue

@@ -1,9 +1,35 @@
 <script>
 import { initAppDatabase } from '@/utils/appDb';
-import { runWgtHotUpdate } from '@/utils/hot-update';
+import { getAccessToken } from '@/utils/auth';
+import {
+	HOT_UPDATE_CHECK_INTERVAL,
+	runWgtHotUpdate
+} from '@/utils/hot-update';
+import { useDataDictStore } from '@/store/modules/dataDict';
+
+let hotUpdateTimer = null;
+
+const stopHotUpdatePolling = () => {
+	if (hotUpdateTimer) {
+		clearInterval(hotUpdateTimer);
+		hotUpdateTimer = null;
+	}
+};
+
+const startHotUpdatePolling = () => {
+	stopHotUpdatePolling();
+	hotUpdateTimer = setInterval(() => {
+		runWgtHotUpdate();
+	}, HOT_UPDATE_CHECK_INTERVAL);
+};
 
 export default {
 	onLaunch: (options) => {
+		// 已登录用户在应用启动时预加载字典,避免依赖某个具体页面初始化。
+		if (getAccessToken()) {
+			useDataDictStore().loadDataDictList();
+		}
+
 		// #ifdef APP
 		initAppDatabase();
 		// #endif
@@ -64,19 +90,29 @@ export default {
 		 * 2. 热更新检查失败时我们希望“静默降级,不阻断原有启动”。
 		 * 3. 因此这里直接触发即可,让原有启动逻辑保持原样继续往下走。
 		 */
-		runWgtHotUpdate();
+		runWgtHotUpdate({ forceCheck: true });
 		// #endif
 	},
 	onExit: () => {
+		// #ifdef APP-PLUS
+		stopHotUpdatePolling();
+		// #endif
 		// #ifdef APP
 		// sqlite.closeDB('app')
 		// #endif
 	},
 	onShow: function () {
-		// console.log('App Show')
+		// #ifdef APP-PLUS
+		// 进入前台立即检查一次,并在停留前台期间每 30 秒轮询。
+		runWgtHotUpdate();
+		startHotUpdatePolling();
+		// #endif
 	},
 	onHide: function () {
-		// console.log('App Hide')
+		// #ifdef APP-PLUS
+		// 后台不轮询,避免无效耗电和网络请求;再次进入前台会立即恢复。
+		stopHotUpdatePolling();
+		// #endif
 	}
 };
 </script>

+ 2 - 2
android/simpleDemo/build.gradle

@@ -10,8 +10,8 @@ android {
         minSdkVersion 21
         // targetSdkVersion 33
         targetSdk 33
-        versionCode 10305
-        versionName "1.3.5"
+        versionCode 10306
+        versionName "1.3.6"
         multiDexEnabled true
         compileOptions {
             sourceCompatibility JavaVersion.VERSION_1_8

+ 31 - 0
api/operationMeeting.js

@@ -0,0 +1,31 @@
+import { request } from "@/utils/request";
+
+const baseUrl = "/pms/iot-operation-meeting";
+
+export const getOperationMeetingPage = (params) =>
+  request({ url: `${baseUrl}/page`, method: "get", data: params });
+
+export const getIntegratedMeetingList = () =>
+  request({ url: `${baseUrl}/integratedMeeting`, method: "get" });
+
+export const getSummarizedProjectDetails = (params) =>
+  request({ url: `${baseUrl}/summarizedProjectDetails`, method: "get", data: params });
+
+export const getOperationMeeting = (id) =>
+  request({ url: `${baseUrl}/get`, method: "get", data: { id } });
+
+export const saveOperationMeetingBatch = (data) =>
+  request({ url: `${baseUrl}/saveBatch`, method: "post", data });
+
+export const getOperationMeetingProjects = () =>
+  request({ url: `${baseUrl}/cachedProjects`, method: "get" });
+
+export const getCurrentCompanyExtProperties = () =>
+  request({
+    url: `${baseUrl}/currentCompanyExtProperties`,
+    method: "get",
+    custom: { showError: false },
+  });
+
+export const getOperationMeetingCompanies = () =>
+  request({ url: "/system/dept/includeDisabledCompanies", method: "get" });

+ 3 - 0
components/upgrade.vue

@@ -51,6 +51,7 @@
 
 <script>
 import {
+  consumePendingAppUpgradeCheck,
   isHotUpdateRunning,
   HOT_UPDATE_FALLBACK_APP_CHECK_EVENT,
 } from "@/utils/hot-update.js";
@@ -135,6 +136,7 @@ export default {
     // #endif
   },
   created() {
+    this.pendingHotUpdateFallbackAppCheck = consumePendingAppUpgradeCheck();
     uni.$on("upgrade-app", this.bindEmit);
     uni.$on(
       HOT_UPDATE_FALLBACK_APP_CHECK_EVENT,
@@ -155,6 +157,7 @@ export default {
       this.appCheckVersion();
     },
     handleHotUpdateFallbackAppCheck() {
+      consumePendingAppUpgradeCheck();
       if (!this.t) {
         this.pendingHotUpdateFallbackAppCheck = true;
         return;

+ 1 - 1
config/env.dev.js

@@ -1,7 +1,7 @@
 // 开发环境配置
 export default {
-  // apiUrl: "https://iot.deepoil.cc:5443",
   apiUrl: "https://iot.deepoil.cc:5443",
+  // apiUrl: "http://192.168.188.86:48080",
   // apiUrl: "https://aims.deepoil.cc", //正式
   apiUrlSuffix: "/admin-api",
   // 其他开发环境配置...

+ 19 - 5
main.js

@@ -4,9 +4,13 @@ import languages from './locale/index'
 import {
 	setupPinia
 } from './store';
-import {
-	createSSRApp
-} from 'vue'
+import {
+	createSSRApp
+} from 'vue'
+
+// #ifdef APP-PLUS
+import { runWgtHotUpdate } from '@/utils/hot-update'
+// #endif
 
 import {
 	createI18n
@@ -23,9 +27,19 @@ export function createApp() {
 	setupPinia(app)
 	app.use(i18n)
 	// 把t函数添加到全局属性
-	app.config.globalProperties.$t = i18n.global.t
+	app.config.globalProperties.$t = i18n.global.t
+
+	// #ifdef APP-PLUS
+	// uni-app 的页面 onShow 会应用全局 mixin,因此任意页面显示时都能触发检查。
+	// 热更新模块内部负责并发合并和三十秒去重,不会因页面切换连续请求或弹窗。
+	app.mixin({
+		onShow() {
+			runWgtHotUpdate()
+		}
+	})
+	// #endif
 
 	return {
 		app,
 	}
-}
+}

+ 129 - 129
manifest.json

@@ -1,130 +1,130 @@
 {
-	"name": "DeepOil",
-	"appid": "__UNI__6E4BC49",
-	"description": "",
-	"versionName": "1.3.2",
-	"versionCode": 10302,
-	"transformPx": false,
-	/* 5+App特有相关 */
-	"app-plus": {
-		"usingComponents": true,
-		"nvueStyleCompiler": "uni-app",
-		"compilerVersion": 3,
-		"splashscreen": {
-			"alwaysShowBeforeRender": true,
-			"waiting": true,
-			"autoclose": true,
-			"delay": 0
-		},
-		"webpack": {
-			"externals": {
-				"dingtalk-jsapi": "dingtalk-jsapi" // 告诉 webpack 不打包该依赖
-			}
-		},
-		/* 模块配置 */
-		"modules": {
-			"VideoPlayer": {
-				"enabled": true,
-				"mode": "default",
-				"orientation": "auto",
-				"background": "#000000",
-				"controls": true,
-				"autoPlay": false,
-				"loop": false,
-				"showFullscreenBtn": true,
-				"showPlayBtn": true,
-				"showCenterPlayBtn": true,
-				"showProgress": true,
-				"objectFit": "contain"
-			},
-			"SQLite": {}
-		},
-		/* 应用发布信息 */
-		"distribute": {
-			/* sdk */
-			"sdkConfigs": {
-				"oauth": {
-					"dingtalk": {
-						"appid": "dingcrhejkptu0mcsw3r",
-						"universalLinks": "http://1.94.244.160:70/"
-					}
-				}
-			},
-			/* android打包配置 */
-			"android": {
-				"permissions": [
-					"<uses-feature android:name=\"android.hardware.camera\"/>",
-					"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
-					"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
-					"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
-					"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
-					"<uses-permission android:name=\"android.permission.ACCESS_MOCK_LOCATION\"/>",
-					"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
-					"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
-					// "<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
-					"<uses-permission android:name=\"android.permission.CAMERA\"/>",
-					"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
-					"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
-					"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
-					"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
-					"<uses-permission android:name=\"android.permission.GET_TASKS\"/>",
-					"<uses-permission android:name=\"android.permission.INTERNET\"/>",
-					"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
-					"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
-					"<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>",
-					"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
-					"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
-					"<uses-permission android:name=\"android.permission.READ_SMS\"/>",
-					"<uses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\"/>",
-					"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
-					"<uses-permission android:name=\"android.permission.SEND_SMS\"/>",
-					"<uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\"/>",
-					"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
-					"<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
-					"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
-					"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
-					"<uses-permission android:name=\"android.permission.WRITE_SMS\"/>",
-					"<uses-permission android:name=\"android.permission.RECEIVE_USER_PRESENT\"/>"
-				],
-				"schemes": "deepoil"
-			},
-			/* ios打包配置 */
-			"ios": {
-				"dSYMs": false
-			},
-			/* SDK配置 */
-			"sdkConfigs__UNI__6E4BC49\t": {},
-			"splashscreen": {
-				"useOriginalMsgbox": false
-			}
-		}
-	},
-	/* 快应用特有相关 */
-	"quickapp": {},
-	/* 小程序特有相关 */
-	"mp-weixin": {
-		"appid": "",
-		"setting": {
-			"urlCheck": false
-		},
-		"usingComponents": true
-	},
-	"mp-alipay": {
-		"usingComponents": true
-	},
-	"mp-baidu": {
-		"usingComponents": true
-	},
-	"mp-toutiao": {
-		"usingComponents": true
-	},
-	"uniStatistics": {
-		"enable": false
-	},
-	"vueVersion": "3",
-	"h5": {
-		"router": {
-			"base": "./"
-		}
-	}
-}
+  "name": "DeepOil",
+  "appid": "__UNI__6E4BC49",
+  "description": "",
+  "versionName": "1.3.6",
+  "versionCode": 10306,
+  "transformPx": false,
+  /* 5+App特有相关 */
+  "app-plus": {
+    "usingComponents": true,
+    "nvueStyleCompiler": "uni-app",
+    "compilerVersion": 3,
+    "splashscreen": {
+      "alwaysShowBeforeRender": true,
+      "waiting": true,
+      "autoclose": true,
+      "delay": 0
+    },
+    "webpack": {
+      "externals": {
+        "dingtalk-jsapi": "dingtalk-jsapi" // 告诉 webpack 不打包该依赖
+      }
+    },
+    /* 模块配置 */
+    "modules": {
+      "VideoPlayer": {
+        "enabled": true,
+        "mode": "default",
+        "orientation": "auto",
+        "background": "#000000",
+        "controls": true,
+        "autoPlay": false,
+        "loop": false,
+        "showFullscreenBtn": true,
+        "showPlayBtn": true,
+        "showCenterPlayBtn": true,
+        "showProgress": true,
+        "objectFit": "contain"
+      },
+      "SQLite": {}
+    },
+    /* 应用发布信息 */
+    "distribute": {
+      /* sdk */
+      "sdkConfigs": {
+        "oauth": {
+          "dingtalk": {
+            "appid": "dingcrhejkptu0mcsw3r",
+            "universalLinks": "http://1.94.244.160:70/"
+          }
+        }
+      },
+      /* android打包配置 */
+      "android": {
+        "permissions": [
+          "<uses-feature android:name=\"android.hardware.camera\"/>",
+          "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
+          "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_MOCK_LOCATION\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+          // "<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
+          "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+          "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+          "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+          "<uses-permission android:name=\"android.permission.GET_TASKS\"/>",
+          "<uses-permission android:name=\"android.permission.INTERNET\"/>",
+          "<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
+          "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+          "<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>",
+          "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+          "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+          "<uses-permission android:name=\"android.permission.READ_SMS\"/>",
+          "<uses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\"/>",
+          "<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
+          "<uses-permission android:name=\"android.permission.SEND_SMS\"/>",
+          "<uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\"/>",
+          "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+          "<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
+          "<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
+          "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
+          "<uses-permission android:name=\"android.permission.WRITE_SMS\"/>",
+          "<uses-permission android:name=\"android.permission.RECEIVE_USER_PRESENT\"/>"
+        ],
+        "schemes": "deepoil"
+      },
+      /* ios打包配置 */
+      "ios": {
+        "dSYMs": false
+      },
+      /* SDK配置 */
+      "sdkConfigs__UNI__6E4BC49\t": {},
+      "splashscreen": {
+        "useOriginalMsgbox": false
+      }
+    }
+  },
+  /* 快应用特有相关 */
+  "quickapp": {},
+  /* 小程序特有相关 */
+  "mp-weixin": {
+    "appid": "",
+    "setting": {
+      "urlCheck": false
+    },
+    "usingComponents": true
+  },
+  "mp-alipay": {
+    "usingComponents": true
+  },
+  "mp-baidu": {
+    "usingComponents": true
+  },
+  "mp-toutiao": {
+    "usingComponents": true
+  },
+  "uniStatistics": {
+    "enable": false
+  },
+  "vueVersion": "3",
+  "h5": {
+    "router": {
+      "base": "./"
+    }
+  }
+}

+ 27 - 3
pages.json

@@ -15,15 +15,39 @@
 				"navigationStyle": "custom"
 			}
 		},
-		{
-			// 首页
+		{
+			// 首页
 			"path": "pages/home/index",
 			"style": {
 				"navigationBarTitleText": "PMS",
 				"navigationBarTextStyle": "white",
 				"navigationBarBackgroundColor": "#3d80de"
 			}
-		},
+		},
+		{
+			"path": "pages/swh/index",
+			"style": {
+				"navigationBarTitleText": "生产运营双周会-数据填报"
+			}
+		},
+		{
+			"path": "pages/swh/form",
+			"style": {
+				"navigationBarTitleText": "生产运营双周会"
+			}
+		},
+		{
+			"path": "pages/swh/summary",
+			"style": {
+				"navigationBarTitleText": "生产运营双周会-数据汇总"
+			}
+		},
+		{
+			"path": "pages/swh/summary-detail",
+			"style": {
+				"navigationBarTitleText": "双周会汇总详情"
+			}
+		},
 		{
 			// 我的
 			"path": "pages/user/index",

+ 75 - 3
pages/entry/index.vue

@@ -1,5 +1,6 @@
 <template>
-  <scroll-view class="entry-page" scroll-y>
+  <view class="entry-root">
+    <scroll-view class="entry-page" scroll-y>
     <view class="hero">
       <image class="hero-bg" src="/static/entry/bg.png" mode="widthFix" />
       <view class="hero-mask"></view>
@@ -56,7 +57,7 @@
               :key="item.title"
               class="core-item"
               :class="{ active: item.active }"
-              @click="navigatorTo(item.path)"
+              @click="handleCoreFeatureClick(item)"
             >
               <view class="core-icon">
                 <image
@@ -163,7 +164,33 @@
         </view>
       </view>
     </view>
-  </scroll-view>
+    </scroll-view>
+    <uni-popup
+      ref="operationMeetingPopup"
+      type="bottom"
+      background-color="#fff"
+      border-radius="10px 10px 0 0"
+    >
+      <view class="report-popup">
+        <view class="report-popup-header">请选择生产运营双周会功能</view>
+        <view
+          class="report-popup-item"
+          @click="navigateToOperationMeeting('/pages/swh/index')"
+        >
+          数据填报
+        </view>
+        <view
+          class="report-popup-item"
+          @click="navigateToOperationMeeting('/pages/swh/summary')"
+        >
+          数据汇总
+        </view>
+        <view class="report-popup-cancel" @click="closeOperationMeetingPopup">
+          取消
+        </view>
+      </view>
+    </uni-popup>
+  </view>
 </template>
 
 <script setup>
@@ -414,6 +441,25 @@ const navigatorTo = (url) => {
   uni.navigateTo({ url });
 };
 
+const operationMeetingPopup = ref(null);
+
+const handleCoreFeatureClick = (item) => {
+  if (item.title === "生产运营双周会") {
+    operationMeetingPopup.value?.open();
+    return;
+  }
+  navigatorTo(item.path);
+};
+
+const closeOperationMeetingPopup = () => {
+  operationMeetingPopup.value?.close();
+};
+
+const navigateToOperationMeeting = (url) => {
+  closeOperationMeetingPopup();
+  uni.navigateTo({ url });
+};
+
 onShow(() => {
   loadPageData();
 });
@@ -426,6 +472,32 @@ onShow(() => {
   padding-bottom: 30rpx;
 }
 
+.report-popup {
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.report-popup-header {
+  padding: 32rpx 30rpx 22rpx;
+  color: #8a94a6;
+  font-size: 26rpx;
+  text-align: center;
+}
+
+.report-popup-item,
+.report-popup-cancel {
+  padding: 30rpx;
+  border-top: 1rpx solid #eef1f5;
+  color: #246bff;
+  font-size: 32rpx;
+  text-align: center;
+}
+
+.report-popup-cancel {
+  margin-top: 14rpx;
+  border-top: 14rpx solid #f5f6f8;
+  color: #334155;
+}
+
 .hero {
   position: relative;
   height: 640rpx;

+ 42 - 69
pages/home/index.vue

@@ -5,7 +5,7 @@
     <view class="page-content flex-col">
       <!-- <uni-row class="row-full">
         <uni-col class="daiban-tixing flex-row align-center justify-between">
-        
+
           <swiper
             class="dt-swiper"
             circular="true"
@@ -25,14 +25,14 @@
                 style="margin-top: 10px"
               >
                 <view class="dt-status green">
-                  
+
                   {{ item1.status }}
                 </view>
                 <view class="dt-content">
-                 
+
                   {{ item1.type + "-" + item1.title }}
                 </view>
-            
+
               </view>
               <view
                 v-if="item2"
@@ -40,14 +40,14 @@
                 style="margin-bottom: 10px"
               >
                 <view class="dt-status green">
-                  
+
                   {{ item2?.status }}
                 </view>
                 <view class="dt-content">
-                
+
                   {{ item2?.type + "-" + item2?.title }}
                 </view>
-              
+
               </view>
             </swiper-item>
           </swiper>
@@ -60,8 +60,7 @@
         <uni-col
           :span="12"
           class="yunxingjilu flex-col justify-between"
-          @click="navigatorTo('/pages/recordFilling/list')"
-        >
+          @click="navigatorTo('/pages/recordFilling/list')">
           <view class="half-title">
             {{ $t("home.operationRecordFilling") }}
           </view>
@@ -73,8 +72,7 @@
         <uni-col
           :span="12"
           class="baoyang flex-col justify-between"
-          @click="navigatorTo('/pages/maintenance/index')"
-        >
+          @click="navigatorTo('/pages/maintenance/index')">
           <view class="half-title">
             {{ $t("home.maintenanceWorkOrder") }}
           </view>
@@ -88,8 +86,7 @@
         <uni-col
           :span="12"
           class="shebeiweixiu flex-col justify-between"
-          @click="navigatorTo('/pages/repair/index')"
-        >
+          @click="navigatorTo('/pages/repair/index')">
           <view class="half-title">
             {{ $t("home.equipmentMaintenance") }}
           </view>
@@ -101,8 +98,7 @@
         <uni-col
           :span="12"
           class="xunjian flex-col justify-between"
-          @click="navigatorTo('/pages/inspection/index')"
-        >
+          @click="navigatorTo('/pages/inspection/index')">
           <view class="half-title">
             {{ $t("home.inspectionWorkOrder") }}
           </view>
@@ -115,8 +111,7 @@
         <!-- 故障上报 -->
         <uni-col
           class="guzhang flex-row align-center"
-          @click="navigatorTo('/pages/fault/index')"
-        >
+          @click="navigatorTo('/pages/fault/index')">
           <view class="half-title" style="margin-right: 10px">
             {{ $t("home.faultReporting") }}
           </view>
@@ -130,8 +125,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruiDu/index')"
-          v-if="isShowRuiduDaily"
-        >
+          v-if="isShowRuiduDaily">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -149,8 +143,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="openRuiDuReportPopup"
-          v-if="rdReportStatisticsFlag"
-        >
+          v-if="rdReportStatisticsFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -169,7 +162,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="openRuiHengReportPopup"
-        >
+          v-if="rhReportApprovalFlag">
           <image src="/static/home/rh.png" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -187,7 +180,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="openRuiYingReportPopup"
-        >
+          v-if="ryReportStatisticsFlag">
           <image src="/static/home/ruiying.png" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -204,8 +197,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruihen-task/index')"
-          v-if="rhTaskFlag"
-        >
+          v-if="rhTaskFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -222,8 +214,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruihen/index?type=edit')"
-          v-if="rhReportFlag"
-        >
+          v-if="rhReportFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -240,8 +231,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruihen/index?type=approval')"
-          v-if="rhReportApprovalFlag"
-        >
+          v-if="rhReportApprovalFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -258,8 +248,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruiying/index?type=edit')"
-          v-if="ryReportFlag"
-        >
+          v-if="ryReportFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -276,8 +265,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruiying/index?type=approval')"
-          v-if="ryReportApprovalFlag"
-        >
+          v-if="ryReportApprovalFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -294,8 +282,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruiyingx/index?type=edit')"
-          v-if="ryXjReportFlag"
-        >
+          v-if="ryXjReportFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -312,8 +299,7 @@
         <view
           class="card-cell flex-row align-center justify-between"
           @click="navigatorTo('/pages/ruiyingx/index?type=approval')"
-          v-if="ryXjReportApprovalFlag"
-        >
+          v-if="ryXjReportApprovalFlag">
           <image src="/static/home/ribao.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -330,8 +316,7 @@
         <!-- 库存查询 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/inventory/index')"
-        >
+          @click="navigatorTo('/pages/inventory/index')">
           <image src="/static/home/kucun.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -348,8 +333,7 @@
         <!-- 保养查询 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/maintenance/search')"
-        >
+          @click="navigatorTo('/pages/maintenance/search')">
           <image src="/static/home/taizhang.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -366,8 +350,7 @@
         <!-- 设备台账 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/ledger/index')"
-        >
+          @click="navigatorTo('/pages/ledger/index')">
           <image src="/static/home/taizhang.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -384,12 +367,10 @@
         <!-- 设备状态变更 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/statusChange/index')"
-        >
+          @click="navigatorTo('/pages/statusChange/index')">
           <image
             src="/static/home/zhuangtaibiangeng.svg"
-            mode="aspectFill"
-          ></image>
+            mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
               <view class="title">
@@ -405,8 +386,7 @@
         <!-- 设备责任人 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/deviceUser/index')"
-        >
+          @click="navigatorTo('/pages/deviceUser/index')">
           <image src="/static/home/deviceUser.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -423,8 +403,7 @@
         <!-- 设备实时数据监控 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/realTimeData/index')"
-        >
+          @click="navigatorTo('/pages/realTimeData/index')">
           <image src="/static/home/shujujiankong.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -441,8 +420,7 @@
         <!-- 统计分析 -->
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigatorTo('/pages/statistic/index')"
-        >
+          @click="navigatorTo('/pages/statistic/index')">
           <image src="/static/home/tongjifenxi.svg" mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
@@ -464,24 +442,21 @@
       ref="ruiDuReportPopup"
       type="bottom"
       background-color="#fff"
-      border-radius="10px 10px 0 0"
-    >
+      border-radius="10px 10px 0 0">
       <view class="report-popup">
         <view class="report-popup-header">
           {{ $t("ruiDuReport.selectTitle") }}
         </view>
         <view
           class="report-popup-item"
-          @click="navigateToRuiDuReport('/pages/ruiDuReport/daily-detail')"
-        >
+          @click="navigateToRuiDuReport('/pages/ruiDuReport/daily-detail')">
           {{ $t("ruiDuReport.dailyDetail") }}
         </view>
         <view
           class="report-popup-item"
           @click="
             navigateToRuiDuReport('/pages/ruiDuReport/daily-team-statistic')
-          "
-        >
+          ">
           {{ $t("ruiDuReport.dailyTeamStatistic") }}
         </view>
         <view class="report-popup-cancel" @click="closeRuiDuReportPopup">
@@ -495,16 +470,14 @@
       ref="ruiHengReportPopup"
       type="bottom"
       background-color="#fff"
-      border-radius="10px 10px 0 0"
-    >
+      border-radius="10px 10px 0 0">
       <view class="report-popup">
         <view class="report-popup-header">
           {{ $t("ruiHengReport.selectTitle") }}
         </view>
         <view
           class="report-popup-item"
-          @click="navigateToRuiHengReport('/pages/ruiHengReport/index')"
-        >
+          @click="navigateToRuiHengReport('/pages/ruiHengReport/index')">
           {{ $t("ruiDuReport.dailyDetail") }}
         </view>
 
@@ -519,16 +492,14 @@
       ref="ruiYingReportPopup"
       type="bottom"
       background-color="#fff"
-      border-radius="10px 10px 0 0"
-    >
+      border-radius="10px 10px 0 0">
       <view class="report-popup">
         <view class="report-popup-header">
           {{ $t("ruiYingReport.selectTitle") }}
         </view>
         <view
           class="report-popup-item"
-          @click="navigateToRuiYingReport('/pages/ruiYingReport/index')"
-        >
+          @click="navigateToRuiYingReport('/pages/ruiYingReport/index')">
           {{ $t("ruiDuReport.dailyDetail") }}
         </view>
 
@@ -546,7 +517,6 @@ import { nextTick, onMounted, ref } from "vue";
 import { getUnreadMessageCount } from "@/api/message";
 import { getOvertimeTaskList } from "@/api/task";
 import { getLoginUserInfo } from "@/api/login";
-import { useDataDictStore } from "@/store/modules/dataDict";
 import { useDeptStore } from "@/store/modules/dept";
 import { useDeviceStore } from "@/store/modules/device";
 import { messageNavigate } from "@/utils/navigate";
@@ -601,7 +571,6 @@ const navigateToRuiYingReport = (url) => {
 const messageCount = ref(0);
 onMounted(async () => {
   await Promise.all([
-    useDataDictStore().loadDataDictList(),
     useDeptStore().loadDeptList(),
     useDeviceStore().loadDeviceTypeList(),
   ]);
@@ -610,6 +579,8 @@ onMounted(async () => {
 // 是否展示瑞都日报入口
 const isShowRuiduDaily = ref(false);
 const rdReportStatisticsFlag = ref(false);
+const ryReportStatisticsFlag = ref(false);
+const rhReportStatisticsFlag = ref(false);
 const rhReportFlag = ref(false);
 const rhTaskFlag = ref(false);
 const rhReportApprovalFlag = ref(false);
@@ -627,9 +598,11 @@ const getLoginUser = async () => {
     // isShowRuiduDaily.value = true;
     rhReportFlag.value = response.data.rhReportFlag;
     rhTaskFlag.value = response.data.rhReportFlag;
+    rhReportStatisticsFlag.value = response.data.rhReportStatisticsFlag;
     // rhTaskFlag.value = true;
     rhReportApprovalFlag.value = response.data.rhReportApprovalFlag;
     ryReportFlag.value = response.data.ryReportFlag;
+    ryReportStatisticsFlag.value = response.data.ryReportStatisticsFlag;
     // ryReportFlag.value = true;
     ryReportApprovalFlag.value = response.data.ryReportApprovalFlag;
     // ryReportApprovalFlag.value = true;

+ 166 - 46
pages/recordFilling/list.vue

@@ -2,29 +2,6 @@
   <z-paging class="page" ref="paging" v-model="dataList" @query="queryList">
     <!-- z-paging默认铺满全屏,此时页面所有view都应放在z-paging标签内,否则会被盖住 -->
     <!-- 需要固定在页面顶部的view请通过slot="top"插入,包括自定义的导航栏 -->
-    <template #top>
-      <uni-row class="search-row flex-row align-center justify-between">
-        <uni-col :span="19">
-          <uni-easyinput
-            v-model="orderName"
-            :styles="inputStyles"
-            :placeholderStyle="placeholderStyle"
-            :placeholder="$t('operation.searchText')"
-          >
-          </uni-easyinput>
-        </uni-col>
-        <uni-col :span="5" class="flex-row justify-end">
-          <button
-            class="mini-btn"
-            type="primary"
-            size="mini"
-            @click="searchList"
-          >
-            {{ $t("operation.search") }}
-          </button>
-        </uni-col>
-      </uni-row>
-    </template>
     <view class="list">
       <view class="item" v-for="(item, index) in dataList" :key="index">
         <view
@@ -32,8 +9,7 @@
           :class="{
             tobeFilled: item.orderStatus == 0,
             ignore: item.orderStatus == 3,
-          }"
-        >
+          }">
           <!-- 创建时间 -->
           <view class="module-name">
             {{ item.createTime ? formatDate(item.createTime) : "" }}
@@ -44,8 +20,7 @@
             :class="{
               pending: item.orderStatus == 0,
               ignore: item.orderStatus == 3,
-            }"
-          >
+            }">
             {{ fillStatusDict[item.orderStatus] }}
           </view>
         </view>
@@ -125,8 +100,7 @@
             type="default"
             class="btn-ignore"
             v-if="item.orderStatus == 0 || item.orderStatus == 2"
-            @click="ignoreOrder(item)"
-          >
+            @click="ignoreOrder(item)">
             {{ $t("operation.ignore") }}
           </button>
           <!-- 查看 -->
@@ -134,22 +108,78 @@
             type="primary"
             plain="true"
             v-if="item.orderStatus == 1 || item.orderStatus == 3"
-            @click="navigatorDetail(item, 0)"
-          >
+            @click="navigatorDetail(item, 0)">
             {{ $t("operation.view") }}
           </button>
           <!-- 填写 -->
           <button
             type="primary"
             v-if="item.orderStatus == 0 || item.orderStatus == 2"
-            @click="navigatorDetail(item, 1)"
-          >
+            @click="navigatorDetail(item, 1)">
             {{ $t("operation.fill") }}
           </button>
         </view>
       </view>
     </view>
   </z-paging>
+
+  <UniFab
+    :pattern="fabPattern"
+    horizontal="right"
+    vertical="bottom"
+    direction="horizontal"
+    :popMenu="false"
+    @fabClick="openFilterPopup" />
+
+  <uni-popup
+    ref="filterPopup"
+    type="bottom"
+    background-color="#fff"
+    border-radius="10px 10px 0 0">
+    <view class="filter-popup">
+      <view class="filter-header">
+        <text class="filter-action" @click="closeFilterPopup">{{
+          $t("operation.cancel")
+        }}</text>
+        <text class="filter-title">筛选条件</text>
+        <text class="filter-action primary" @click="searchList">{{
+          $t("operation.confirm")
+        }}</text>
+      </view>
+
+      <view class="filter-body">
+        <view class="filter-item">
+          <view class="filter-label">工单名称</view>
+          <uni-easyinput
+            v-model="orderName"
+            :input-border="false"
+            :styles="inputStyles"
+            :placeholderStyle="placeholderStyle"
+            :placeholder="$t('operation.searchText')"
+            @confirm="searchList" />
+        </view>
+        <view class="filter-item">
+          <view class="filter-label">创建时间</view>
+          <uni-datetime-picker
+            v-model="createTime"
+            type="datetimerange"
+            return-type="string"
+            :border="false"
+            :placeholder="$t('operation.PleaseSelect')" />
+        </view>
+      </view>
+
+      <view class="filter-footer">
+        <button class="filter-button reset" @click="resetFilter">
+          {{ $t("inventory.search.reset") }}
+        </button>
+        <button class="filter-button" type="primary" @click="searchList">
+          {{ $t("operation.search") }}
+        </button>
+      </view>
+    </view>
+  </uni-popup>
+
   <ignore-reason ref="ignorePopupRef" @ignore-submit="ignoreSubmit" />
 </template>
 
@@ -161,9 +191,10 @@ import {
   getRecordFillingList,
   recordFillingIgnore,
 } from "@/api/recordFilling.js";
-import { getUserId, getDeptId } from "@/utils/auth.js";
+import { getDeptId } from "@/utils/auth.js";
 import { useDataDictStore } from "@/store/modules/dataDict";
 import ignoreReason from "@/components/ignore/reason.vue";
+import UniFab from "@/uni_modules/uni-fab/components/uni-fab/uni-fab.vue";
 
 // 获取字典项
 const { getStrDictOptions } = useDataDictStore();
@@ -175,6 +206,20 @@ getStrDictOptions("operation_fill_order_status").map((item) => {
 console.log("🚀 ~ getDataDictList ~ fillStatusDict:", fillStatusDict);
 
 const orderName = ref("");
+const getRecentWeekRange = () => [
+  dayjs().subtract(6, "day").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
+  dayjs().endOf("day").format("YYYY-MM-DD HH:mm:ss"),
+];
+const createTime = ref(getRecentWeekRange());
+const filterPopup = ref(null);
+const fabPattern = reactive({
+  color: "#fff",
+  backgroundColor: "#fff",
+  selectedColor: "#fff",
+  buttonColor: "#004098",
+  iconColor: "#fff",
+  icon: "search",
+});
 const placeholderStyle = ref("color:#797979;font-weight:500;font-size:16px");
 const inputStyles = reactive({
   backgroundColor: "#FFFFFF",
@@ -192,6 +237,7 @@ const queryList = (pageNo, pageSize) => {
     pageSize,
     deptId: getDeptId(),
     orderName: orderName.value,
+    createTime: createTime.value,
   })
     .then((res) => {
       // 将请求结果通过complete传给z-paging处理,同时也代表请求结束,这一行必须调用
@@ -205,8 +251,19 @@ const queryList = (pageNo, pageSize) => {
     });
 };
 const searchList = () => {
+  closeFilterPopup();
   paging.value.reload();
 };
+const openFilterPopup = () => {
+  filterPopup.value?.open();
+};
+const closeFilterPopup = () => {
+  filterPopup.value?.close();
+};
+const resetFilter = () => {
+  orderName.value = "";
+  createTime.value = getRecentWeekRange();
+};
 const navigatorDetail = (item, type) => {
   console.log("item", item);
   const param = {
@@ -276,18 +333,6 @@ onShow(() => {
 <style lang="scss" scoped>
 @import "@/style/work-order.scss";
 
-.search-row {
-  height: 35px;
-  background: #f3f5f9;
-
-  .mini-btn {
-    height: 35px;
-    line-height: 35px;
-    width: 100%;
-    margin-left: 8px;
-  }
-}
-
 .item {
   width: 100%;
   // height: 245px;
@@ -297,4 +342,79 @@ onShow(() => {
   border-radius: 6px;
   margin-top: 10px;
 }
+
+.filter-popup {
+  display: flex;
+  flex-direction: column;
+  padding-bottom: env(safe-area-inset-bottom);
+  background: #fff;
+}
+
+.filter-header {
+  height: 48px;
+  padding: 0 16px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid #f0f0f0;
+  box-sizing: border-box;
+}
+
+.filter-title {
+  color: #333;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.filter-action {
+  min-width: 48px;
+  color: #666;
+  font-size: 14px;
+}
+
+.filter-action.primary {
+  color: #004098;
+  text-align: right;
+}
+
+.filter-body {
+  padding: 16px;
+  box-sizing: border-box;
+}
+
+.filter-item + .filter-item {
+  margin-top: 16px;
+}
+
+.filter-label {
+  margin-bottom: 8px;
+  color: #333;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.filter-footer {
+  display: flex;
+  padding: 10px 16px 14px;
+  border-top: 1px solid #f0f0f0;
+  box-sizing: border-box;
+}
+
+.filter-button {
+  flex: 1;
+  height: 38px;
+  margin: 0;
+  font-size: 14px;
+  line-height: 38px;
+}
+
+.filter-button + .filter-button {
+  margin-left: 10px;
+}
+
+.filter-button.reset {
+  border: 1px solid #004098;
+  background: #fff;
+  color: #004098;
+}
 </style>

+ 17 - 7
pages/ruiDuReport/daily-detail.vue

@@ -858,8 +858,8 @@ onUnmounted(() => timers.forEach((timer) => clearTimeout(timer)));
   font-size: 24rpx;
   color: #8a96a8;
 }
-.search-row {
-  gap: 16rpx;
+.search-row > * + * {
+  margin-left: 16rpx;
 }
 .search-btn {
   flex: 0 0 auto;
@@ -1044,7 +1044,9 @@ onUnmounted(() => timers.forEach((timer) => clearTimeout(timer)));
 .report-list {
   display: flex;
   flex-direction: column;
-  gap: 20rpx;
+}
+.report-list > .report-card + .report-card {
+  margin-top: 20rpx;
 }
 .report-card {
   padding: 24rpx;
@@ -1056,10 +1058,12 @@ onUnmounted(() => timers.forEach((timer) => clearTimeout(timer)));
 .card-head {
   align-items: flex-start;
   justify-content: space-between;
-  gap: 16rpx;
   padding-bottom: 20rpx;
   border-bottom: 1rpx solid #edf0f4;
 }
+.card-head > * + * {
+  margin-left: 16rpx;
+}
 .card-heading {
   min-width: 0;
   flex: 1;
@@ -1128,9 +1132,11 @@ onUnmounted(() => timers.forEach((timer) => clearTimeout(timer)));
 }
 .card-actions {
   justify-content: flex-end;
-  gap: 16rpx;
   margin-top: 12rpx;
 }
+.card-actions > .action-btn + .action-btn {
+  margin-left: 16rpx;
+}
 .action-btn {
   min-width: 150rpx;
   margin: 0;
@@ -1162,7 +1168,9 @@ onUnmounted(() => timers.forEach((timer) => clearTimeout(timer)));
   display: flex;
   flex-direction: column;
   align-items: center;
-  gap: 16rpx;
+}
+.empty-page > * + * {
+  margin-top: 16rpx;
 }
 .filter-popup {
   height: 86vh;
@@ -1228,11 +1236,13 @@ onUnmounted(() => timers.forEach((timer) => clearTimeout(timer)));
 }
 .filter-footer {
   display: flex;
-  gap: 10px;
   padding: 10px 16px 14px;
   border-top: 1px solid #f0f0f0;
   box-sizing: border-box;
 }
+.filter-footer > .filter-button + .filter-button {
+  margin-left: 10px;
+}
 .filter-button {
   flex: 1;
   height: 38px;

+ 42 - 13
pages/ruiHengReport/index.vue

@@ -351,13 +351,15 @@ onMounted(() => {
 .filter-bar {
   display: flex;
   align-items: center;
-  gap: 10px;
   padding: 14px;
   border-radius: 18px;
   background: rgba(255, 255, 255, 0.94);
   box-shadow: 0 14px 32px rgba(25, 56, 104, 0.08);
   backdrop-filter: blur(10px);
 }
+.filter-bar > * + * {
+  margin-left: 10px;
+}
 
 .filter-main {
   flex: 1;
@@ -365,7 +367,9 @@ onMounted(() => {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  gap: 10rpx;
+}
+.filter-main > * + * {
+  margin-left: 10rpx;
 }
 
 .filter-label {
@@ -407,9 +411,11 @@ onMounted(() => {
 .report-list {
   display: flex;
   flex-direction: column;
-  gap: 14px;
   padding-bottom: 18px;
 }
+.report-list > .report-card + .report-card {
+  margin-top: 14px;
+}
 
 .report-card {
   padding: 16px;
@@ -423,9 +429,11 @@ onMounted(() => {
   display: flex;
   align-items: flex-start;
   justify-content: space-between;
-  gap: 12px;
   margin-bottom: 14px;
 }
+.card-header > * + * {
+  margin-left: 12px;
+}
 
 .dept-name {
   color: #122033;
@@ -454,11 +462,16 @@ onMounted(() => {
 
 .metrics-grid {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 10px;
+  grid-template-columns: calc(50% - 5px) calc(50% - 5px);
   margin-bottom: 14px;
   margin-top: 14px;
 }
+.metrics-grid > *:nth-child(even) {
+  margin-left: 10px;
+}
+.metrics-grid > *:nth-child(n + 3) {
+  margin-top: 10px;
+}
 
 .metric-item {
   padding: 12px;
@@ -482,18 +495,25 @@ onMounted(() => {
 
 .summary-panel {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 8px 14px;
+  grid-template-columns: calc(50% - 7px) calc(50% - 7px);
   padding: 14px 0;
   // border-top: 1px solid #edf2f7;
   border-bottom: 1px solid #edf2f7;
 }
+.summary-panel > *:nth-child(even) {
+  margin-left: 14px;
+}
+.summary-panel > *:nth-child(n + 3) {
+  margin-top: 8px;
+}
 
 .summary-row {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  gap: 10px;
+}
+.summary-row > * + * {
+  margin-left: 10px;
 }
 
 .summary-label {
@@ -558,7 +578,9 @@ onMounted(() => {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  gap: 12px;
+}
+.team-drawer__header > * + * {
+  margin-left: 12px;
 }
 
 .team-drawer__title {
@@ -591,9 +613,11 @@ onMounted(() => {
 .team-detail-list {
   display: flex;
   flex-direction: column;
-  gap: 12px;
   padding-bottom: 10px;
 }
+.team-detail-list > .team-detail-card + .team-detail-card {
+  margin-top: 12px;
+}
 
 .team-detail-card {
   padding: 14px;
@@ -611,8 +635,13 @@ onMounted(() => {
 
 .team-detail-grid {
   display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 10px;
+  grid-template-columns: calc(50% - 5px) calc(50% - 5px);
+}
+.team-detail-grid > *:nth-child(even) {
+  margin-left: 10px;
+}
+.team-detail-grid > *:nth-child(n + 3) {
+  margin-top: 10px;
 }
 
 .team-detail-item {

+ 19 - 9
pages/ruihen-task/index.vue

@@ -182,7 +182,6 @@ onShow(() => {
 .top {
   display: flex;
   flex-direction: column;
-  gap: 12px;
   background: #f3f5f9;
   padding: 10px 0;
 }
@@ -190,7 +189,6 @@ onShow(() => {
 .search-row {
   display: flex;
   align-items: center;
-  gap: 12px;
 }
 
 .search-row :deep(.uni-easyinput) {
@@ -200,11 +198,11 @@ onShow(() => {
 :deep(.mini-btn) {
   height: 38px !important;
   font-size: 16px !important;
-  margin: 0;
+  margin: 0 0 0 12px;
 }
 
 .create-btn {
-  margin: 0;
+  margin: 12px 0 0;
   align-self: flex-start;
 }
 
@@ -212,7 +210,10 @@ onShow(() => {
   margin-top: 16px;
   display: flex;
   flex-direction: column;
-  gap: 12px;
+}
+
+.list > .item + .item {
+  margin-top: 12px;
 }
 
 .item {
@@ -221,18 +222,21 @@ onShow(() => {
   border-radius: 8px;
   display: flex;
   flex-direction: column;
-  gap: 12px;
+}
+
+.item > * + * {
+  margin-top: 12px;
 }
 
 .header {
   display: flex;
   align-items: flex-start;
   justify-content: space-between;
-  gap: 12px;
 }
 
 .contract-name {
   flex: 1;
+  margin-right: 12px;
   font-size: 16px;
   font-weight: 600;
   color: #1f2329;
@@ -254,7 +258,10 @@ onShow(() => {
 .content {
   display: flex;
   flex-direction: column;
-  gap: 8px;
+}
+
+.content > .content-item + .content-item {
+  margin-top: 8px;
 }
 
 .content-item {
@@ -275,10 +282,13 @@ onShow(() => {
   display: flex;
   justify-content: flex-end;
   align-items: center;
-  gap: 12px;
 }
 
 .button {
   margin: 0;
 }
+
+.footer > .button + .button {
+  margin-left: 12px;
+}
 </style>

+ 9 - 0
pages/ruihen/components/form.vue

@@ -37,6 +37,7 @@ const FORM_KEYS = [
   "relatedTasks",
   "dailyGasInjection",
   "dailyWaterInjection",
+  "dailyPowerUsage",
   "dailyInjectGasTime",
   "dailyInjectWaterTime",
   "productionStatus",
@@ -69,6 +70,7 @@ const initFormData = () => {
   const base = {
     dailyGasInjection: 0,
     dailyWaterInjection: 0,
+    dailyPowerUsage: 0,
     dailyInjectGasTime: 0,
     dailyInjectWaterTime: 0,
     relocationDays: 0,
@@ -366,6 +368,13 @@ defineExpose({ formRef, form, loadDetail });
           :disabled="disabled('edit')"
           v-model="form.dailyWaterInjection" />
       </uni-forms-item>
+      <uni-forms-item label="用电量(kWh)" name="dailyPowerUsage">
+        <uni-easyinput
+          type="number"
+          v-bind="defaultProps"
+          :disabled="disabled('edit')"
+          v-model="form.dailyPowerUsage" />
+      </uni-forms-item>
       <uni-forms-item label="生产动态" name="productionStatus" required>
         <uni-easyinput
           type="textarea"

+ 21 - 5
pages/ruihen/index.vue

@@ -176,7 +176,10 @@
     display: flex;
     align-items: center;
     justify-content: space-between;
-    gap: 20px;
+  }
+
+  .top > * + * {
+    margin-left: 20px;
   }
 
   :deep(.mini-btn) {
@@ -188,7 +191,10 @@
     margin-top: 16px;
     display: flex;
     flex-direction: column;
-    gap: 12px;
+
+    > .item + .item {
+      margin-top: 12px;
+    }
 
     .item {
       background-color: #fff;
@@ -196,7 +202,11 @@
       border-radius: 8px;
       display: flex;
       flex-direction: column;
-      gap: 10px;
+
+      > * + * {
+        margin-top: 10px;
+      }
+
       .header {
         display: flex;
         align-items: center;
@@ -230,7 +240,10 @@
       .content {
         display: flex;
         flex-direction: column;
-        gap: 6px;
+
+        > .content-item + .content-item {
+          margin-top: 6px;
+        }
 
         .content-item {
           font-size: 14px;
@@ -285,12 +298,15 @@
         display: flex;
         justify-content: flex-end;
         align-items: center;
-        gap: 0 12px;
         height: 32px;
 
         .button {
           margin: 0;
         }
+
+        > .button + .button {
+          margin-left: 12px;
+        }
       }
     }
   }

+ 9 - 2
pages/ruiying/components/form.vue

@@ -52,6 +52,7 @@ const FORM_KEYS = [
   "drillingWorkingTime",
   "otherProductionTime",
   "lastCurrentDepth",
+  "footageReadOnly",
 
   "reportDetails",
   "constructionBrief",
@@ -221,6 +222,10 @@ const disabled = computed(() => (field) => {
   else return formType.value === "readonly";
 });
 
+const footageDisabled = computed(
+  () => disabled.value("edit") || form.value.footageReadOnly === true
+);
+
 const formRef = ref(null);
 
 // 辅助函数:计算总时间
@@ -357,6 +362,8 @@ const reportDetailsTimeRange = (data) => {
 };
 
 const inputCurrentDepth = useDebounceFn(function inputCurrentDepth() {
+  if (form.value.footageReadOnly === true) return;
+
   const details = form.value.reportDetails;
 
   if (Array.isArray(details) && details.length > 0) {
@@ -437,7 +444,7 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth() {
         <uni-easyinput
           type="number"
           v-bind="defaultProps"
-          :disabled="disabled('edit')"
+          :disabled="footageDisabled"
           v-model="form.currentDepth" />
       </uni-forms-item>
       <uni-forms-item label="当日用电量(kWh)" name="dailyPowerUsage">
@@ -602,7 +609,7 @@ const inputCurrentDepth = useDebounceFn(function inputCurrentDepth() {
           <uni-easyinput
             type="number"
             v-bind="defaultProps"
-            :disabled="disabled('edit')"
+            :disabled="footageDisabled"
             v-model.number="item.currentDepth"
             @input="(val) => inputCurrentDepth(val, index)" />
         </uni-forms-item>

+ 21 - 5
pages/ruiying/index.vue

@@ -176,7 +176,10 @@
     display: flex;
     align-items: center;
     justify-content: space-between;
-    gap: 20px;
+  }
+
+  .top > * + * {
+    margin-left: 20px;
   }
 
   :deep(.mini-btn) {
@@ -188,7 +191,10 @@
     margin-top: 16px;
     display: flex;
     flex-direction: column;
-    gap: 12px;
+
+    > .item + .item {
+      margin-top: 12px;
+    }
 
     .item {
       background-color: #fff;
@@ -196,7 +202,11 @@
       border-radius: 8px;
       display: flex;
       flex-direction: column;
-      gap: 10px;
+
+      > * + * {
+        margin-top: 10px;
+      }
+
       .header {
         display: flex;
         align-items: center;
@@ -230,7 +240,10 @@
       .content {
         display: flex;
         flex-direction: column;
-        gap: 6px;
+
+        > .content-item + .content-item {
+          margin-top: 6px;
+        }
 
         .content-item {
           display: flex;
@@ -292,12 +305,15 @@
         display: flex;
         justify-content: flex-end;
         align-items: center;
-        gap: 0 12px;
         height: 32px;
 
         .button {
           margin: 0;
         }
+
+        > .button + .button {
+          margin-left: 12px;
+        }
       }
     }
   }

+ 21 - 5
pages/ruiyingx/index.vue

@@ -177,7 +177,10 @@
     display: flex;
     align-items: center;
     justify-content: space-between;
-    gap: 20px;
+  }
+
+  .top > * + * {
+    margin-left: 20px;
   }
 
   :deep(.mini-btn) {
@@ -189,7 +192,10 @@
     margin-top: 16px;
     display: flex;
     flex-direction: column;
-    gap: 12px;
+
+    > .item + .item {
+      margin-top: 12px;
+    }
 
     .item {
       background-color: #fff;
@@ -197,7 +203,11 @@
       border-radius: 8px;
       display: flex;
       flex-direction: column;
-      gap: 10px;
+
+      > * + * {
+        margin-top: 10px;
+      }
+
       .header {
         display: flex;
         align-items: center;
@@ -231,7 +241,10 @@
       .content {
         display: flex;
         flex-direction: column;
-        gap: 6px;
+
+        > .content-item + .content-item {
+          margin-top: 6px;
+        }
 
         .content-item {
           display: flex;
@@ -293,12 +306,15 @@
         display: flex;
         justify-content: flex-end;
         align-items: center;
-        gap: 0 12px;
         height: 32px;
 
         .button {
           margin: 0;
         }
+
+        > .button + .button {
+          margin-left: 12px;
+        }
       }
     }
   }

+ 542 - 0
pages/swh/form.vue

@@ -0,0 +1,542 @@
+<template>
+  <view class="swh-form-page">
+    <scroll-view class="swh-form-scroll" scroll-y :show-scrollbar="false">
+      <view class="swh-form-content">
+    <view class="section">
+      <view class="section-title">会议基本信息</view>
+      <view class="field">
+        <text class="label">会议日期 <text class="required">*</text></text>
+        <uni-datetime-picker v-model="form.meetingDate" type="date" return-type="timestamp" :disabled="readonly" />
+      </view>
+      <view class="field">
+        <text class="label">会议期次 <text class="required">*</text></text>
+        <uni-easyinput v-model="form.meetingSeries" type="number" :disabled="readonly" placeholder="请输入会议期次(数字)" />
+      </view>
+      <view class="field">
+        <text class="label">其他重点事项及需要集团协调事项 <text class="required">*</text></text>
+        <uni-easyinput v-model="form.support" type="textarea" :disabled="readonly" placeholder="请输入相关事项" />
+      </view>
+    </view>
+
+    <view v-if="readonly" class="section summary-section">
+      <view class="summary-title-row">
+        <view>
+          <view class="section-title">公司整体</view>
+          <view class="section-tip">经营数据汇总</view>
+        </view>
+      </view>
+      <view class="summary-grid">
+        <view
+          v-for="item in businessSummaryCards"
+          :key="item.key"
+          class="summary-card"
+          :class="item.tone"
+        >
+          <text>{{ item.label }}</text>
+          <view class="summary-value">{{ item.value }} <small>万元</small></view>
+          <view v-if="item.compare" class="summary-compare" :class="item.compare.tone">
+            环比 {{ item.compare.text }}
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view v-if="readonly && workloadProperties.length" class="section workload-section">
+      <view class="section-title">公司整体</view>
+      <view class="section-tip">工作量完成情况</view>
+      <view class="workload-grid">
+        <view
+          v-for="(item, index) in workloadProperties"
+          :key="item.identifier"
+          class="workload-card"
+          :class="workloadTone(item, index)"
+        >
+          <text>{{ item.name }}</text>
+          <strong>{{ workloadValue(item) }}</strong>
+        </view>
+      </view>
+    </view>
+
+    <view class="section">
+      <view class="detail-head">
+        <view>
+          <view class="section-title">会议明细</view>
+          <view class="section-tip">共 {{ details.length }} 个项目</view>
+        </view>
+        <button v-if="!readonly" class="add-btn" @click="openDetail()">+ 新增明细</button>
+      </view>
+      <view v-if="!details.length" class="empty">请新增至少一条会议明细</view>
+      <view v-for="(item, index) in details" :key="index" class="detail-card">
+        <view class="detail-card-head">
+          <text class="project-name">{{ item.projectName || "未命名项目" }}</text>
+          <text class="detail-link" @click="openDetail(index)">{{ readonly ? "查看" : "编辑" }}</text>
+        </view>
+        <view class="money-grid">
+          <view><text>本期收入</text><strong>{{ numberText(item.currentRevenue) }}</strong></view>
+          <view><text>本期挂帐</text><strong>{{ numberText(item.currentOnAccount) }}</strong></view>
+          <view><text>本期回款</text><strong>{{ numberText(item.currentPayment) }}</strong></view>
+        </view>
+        <view v-if="!readonly" class="delete" @click="removeDetail(index)">删除</view>
+      </view>
+    </view>
+
+        <view class="footer-space"></view>
+      </view>
+    </scroll-view>
+
+    <view class="footer">
+      <button class="cancel-btn" @click="goBack">{{ readonly ? "关闭" : "取消" }}</button>
+      <button v-if="!readonly" class="save-btn" :disabled="saving" @click="submit">保存</button>
+    </view>
+
+    <uni-popup ref="detailPopup" type="bottom" background-color="#f4f7fb" border-radius="20px 20px 0 0">
+      <view class="detail-popup">
+        <view class="popup-head">
+          <text>{{ readonly ? "查看会议明细" : editingIndex < 0 ? "新增会议明细" : "编辑会议明细" }}</text>
+          <uni-icons type="closeempty" size="24" color="#64748b" @click="closeDetail" />
+        </view>
+        <scroll-view class="popup-scroll" scroll-y>
+          <view class="popup-section">
+            <view class="field">
+              <text class="label">项目名称 <text class="required">*</text></text>
+              <uni-easyinput v-model="detailForm.projectName" :disabled="readonly" placeholder="请选择或输入项目名称" />
+              <scroll-view v-if="!readonly && projectOptions.length" class="suggestions" scroll-x>
+                <text v-for="name in projectOptions" :key="name" @click="detailForm.projectName = name">{{ name }}</text>
+              </scroll-view>
+            </view>
+          </view>
+
+          <view class="popup-section">
+            <view class="popup-section-title">经营情况(万元)</view>
+            <view class="two-cols">
+              <view v-for="field in moneyFields" :key="field.key" class="field">
+                <text class="label">{{ field.label }} <text class="required">*</text></text>
+                <uni-easyinput v-model="detailForm[field.key]" type="digit" :disabled="readonly" placeholder="请输入" />
+              </view>
+            </view>
+          </view>
+
+          <view v-if="currentExtProperties.length" class="popup-section">
+            <view class="popup-section-title">本期生产运行情况</view>
+            <view v-for="item in currentExtProperties" :key="item.identifier" class="field">
+              <text class="label">{{ extLabel(item) }} <text v-if="isRequired(item)" class="required">*</text></text>
+              <uni-easyinput v-model="item.actualValue" :type="item.dataType === 'double' ? 'digit' : 'textarea'" :disabled="readonly" :placeholder="`请输入${item.name}`" />
+            </view>
+          </view>
+
+          <view v-for="group in textGroups" :key="group.title" class="popup-section">
+            <view class="popup-section-title">{{ group.title }}</view>
+            <view v-for="field in group.fields" :key="field.key" class="field">
+              <text class="label">{{ field.label }} <text class="required">*</text></text>
+              <uni-easyinput v-model="detailForm[field.key]" type="textarea" :disabled="readonly" :placeholder="`请输入${field.label}`" />
+            </view>
+          </view>
+
+          <view v-if="nextExtProperties.length" class="popup-section">
+            <view class="popup-section-title">下期扩展工作计划</view>
+            <view v-for="item in nextExtProperties" :key="item.identifier" class="field">
+              <text class="label">{{ extLabel(item) }} <text v-if="isRequired(item)" class="required">*</text></text>
+              <uni-easyinput v-model="item.actualValue" :type="item.dataType === 'double' ? 'digit' : 'textarea'" :disabled="readonly" :placeholder="`请输入${item.name}`" />
+            </view>
+          </view>
+          <view class="popup-bottom"></view>
+        </scroll-view>
+        <view class="popup-footer">
+          <button class="cancel-btn" @click="closeDetail">{{ readonly ? "关闭" : "取消" }}</button>
+          <button v-if="!readonly" class="save-btn" @click="saveDetail">确定</button>
+        </view>
+      </view>
+    </uni-popup>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { onLoad } from "@dcloudio/uni-app";
+import {
+  getCurrentCompanyExtProperties,
+  getOperationMeeting,
+  getOperationMeetingProjects,
+  saveOperationMeetingBatch,
+} from "@/api/operationMeeting";
+
+const type = ref("create");
+const id = ref();
+const saving = ref(false);
+const detailPopup = ref(null);
+const editingIndex = ref(-1);
+const projectOptions = ref([]);
+const extProperties = ref([]);
+const form = ref({ meetingDate: "", meetingSeries: "", support: "" });
+const details = ref([]);
+const meetingExtProperties = ref([]);
+const previousTotals = ref({
+  beforeRevenue: undefined,
+  beforeOnAccount: undefined,
+  beforePayment: undefined,
+});
+const readonly = computed(() => type.value === "view");
+
+const moneyFields = [
+  { key: "currentRevenue", label: "收入-本期" },
+  { key: "cumulativeRevenue", label: "收入-累计" },
+  { key: "currentOnAccount", label: "挂帐-本期" },
+  { key: "cumulativeOnAccount", label: "挂帐-累计" },
+  { key: "currentPayment", label: "回款-本期" },
+  { key: "cumulativePayment", label: "回款-累计" },
+];
+const textGroups = [
+  { title: "生产管理情况及重点工作", fields: [
+    { key: "keyWorkCompletion", label: "重点工作及完成情况" },
+    { key: "problemsAnalysis", label: "存在问题及分析" },
+  ] },
+  { title: "设备、安全管理工作", fields: [{ key: "qhse", label: "设备、安全管理工作" }] },
+  { title: "下期工作计划", fields: [
+    { key: "nextPlannedWorkload", label: "计划工作量" },
+    { key: "priorityTasks", label: "重点工作事项" },
+  ] },
+];
+
+const normalizeExtList = (value) => {
+  if (Array.isArray(value)) return value;
+  if (typeof value === "string") {
+    try { return normalizeExtList(JSON.parse(value)); } catch (_) { return []; }
+  }
+  if (value && typeof value === "object") {
+    const values = Object.values(value);
+    if (values.every((item) => item && typeof item === "object")) return values;
+    return Object.entries(value).map(([identifier, actualValue]) => ({
+      name: identifier,
+      identifier,
+      actualValue,
+      dataType: typeof actualValue === "number" ? "double" : "",
+      required: 0,
+    }));
+  }
+  return [];
+};
+const amountNumber = (value) => {
+  const number = Number(value);
+  return Number.isFinite(number) ? number : 0;
+};
+const amountText = (value) => {
+  const number = Math.round((amountNumber(value) + Math.sign(amountNumber(value)) * Number.EPSILON) * 100) / 100;
+  return number.toFixed(2);
+};
+const summaryDefinitions = [
+  { key: "currentRevenue", label: "收入-本期", tone: "revenue", beforeKey: "beforeRevenue" },
+  { key: "cumulativeRevenue", label: "收入-累计", tone: "revenue" },
+  { key: "currentOnAccount", label: "挂帐-本期", tone: "account", beforeKey: "beforeOnAccount" },
+  { key: "cumulativeOnAccount", label: "挂帐-累计", tone: "account" },
+  { key: "currentPayment", label: "回款-本期", tone: "payment", beforeKey: "beforePayment" },
+  { key: "cumulativePayment", label: "回款-累计", tone: "payment" },
+];
+const getDetailTotal = (key) =>
+  details.value.reduce(
+    (sum, item) => sum + Math.round(amountNumber(item[key]) * 100),
+    0,
+  ) / 100;
+const getSummaryCompare = (definition, value) => {
+  if (!definition.beforeKey) return null;
+  const before = amountNumber(previousTotals.value[definition.beforeKey]);
+  if (!before) return null;
+  const compare = ((value - before) / Math.abs(before)) * 100;
+  return {
+    text: `${compare > 0 ? "+" : ""}${amountText(compare)}%`,
+    tone: compare > 0 ? "up" : compare < 0 ? "down" : "flat",
+  };
+};
+const businessSummaryCards = computed(() =>
+  summaryDefinitions.map((definition) => {
+    const value = getDetailTotal(definition.key);
+    return {
+      ...definition,
+      value: amountText(value),
+      compare: getSummaryCompare(definition, value),
+    };
+  }),
+);
+const isRequiredWorkload = (item) => [true, 1, "1"].includes(item.required);
+const workloadProperties = computed(() =>
+  normalizeExtList(meetingExtProperties.value)
+    .filter(
+      (item) =>
+        item.identifier &&
+        item.defaultValue !== "next" &&
+        isRequiredWorkload(item),
+    )
+    .sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0)),
+);
+const workloadMetricText = (item) =>
+  `${item.identifier || ""}${item.name || ""}`.toLowerCase().replace(/[\s%]/g, "");
+const workloadTone = (item, index) => {
+  const text = workloadMetricText(item);
+  if (text.includes("设备利用率") || text.includes("utilization")) return "cyan";
+  if (text.includes("环比") || text.includes("monthonmonth") || text.includes("mom")) return "rose";
+  if (text.includes("计划") || text.includes("plan")) return "blue";
+  if (text.includes("实际") || text.includes("actual")) return "green";
+  if (text.includes("完成") || text.includes("complete")) return "orange";
+  if (text.includes("累计") || text.includes("cumulative")) return "purple";
+  return ["blue", "green", "orange", "purple"][index % 4];
+};
+const workloadValue = (item) => {
+  if (item.actualValue === undefined || item.actualValue === null || item.actualValue === "") return "-";
+  return `${item.dataType === "double" ? amountText(item.actualValue) : item.actualValue}${item.unit || ""}`;
+};
+const cloneExt = (value = extProperties.value) => normalizeExtList(value).map((item) => ({ ...item }));
+const createDetail = () => ({
+  projectName: "", currentRevenue: "", cumulativeRevenue: "", beforeRevenue: undefined,
+  currentOnAccount: "", cumulativeOnAccount: "", beforeOnAccount: undefined,
+  currentPayment: "", cumulativePayment: "", beforePayment: undefined,
+  plannedWorkload: "", actualCompletion: "", equipmentUtilizationRate: undefined,
+  keyWorkCompletion: "", problemsAnalysis: "", qhse: "", nextPlannedWorkload: "",
+  priorityTasks: "", extProperty: cloneExt(),
+});
+const detailForm = ref(createDetail());
+const currentExtProperties = computed(() => detailForm.value.extProperty.filter((item) => item.defaultValue !== "next"));
+const nextExtProperties = computed(() => detailForm.value.extProperty.filter((item) => item.defaultValue === "next"));
+const isRequired = (item) => [true, 1, "1"].includes(item.required);
+const extLabel = (item) => `${item.name || item.identifier}${item.unit ? `(${item.unit})` : ""}`;
+
+const normalizeDetail = (item = {}) => {
+  const normalized = {
+    ...createDetail(),
+    ...item,
+    extProperty: cloneExt(
+      item.extProperty?.length ? item.extProperty : extProperties.value,
+    ),
+  };
+  moneyFields.forEach(({ key }) => {
+    if (normalized[key] !== "" && normalized[key] != null) {
+      normalized[key] = amountText(normalized[key]);
+    }
+  });
+  normalized.extProperty = normalized.extProperty.map((property) => ({
+    ...property,
+    actualValue:
+      property.dataType === "double" &&
+      property.actualValue !== "" &&
+      property.actualValue != null
+        ? amountText(property.actualValue)
+        : property.actualValue,
+  }));
+  return normalized;
+};
+const parseSeries = (value) => String(value || "").match(/\d+/)?.[0] || "";
+const numberText = (value) =>
+  value === "" || value == null || !Number.isFinite(Number(value))
+    ? "-"
+    : `${Number(value).toFixed(2)} 万元`;
+
+const openDetail = (index = -1) => {
+  editingIndex.value = index;
+  detailForm.value = index < 0 ? createDetail() : normalizeDetail(details.value[index]);
+  detailPopup.value?.open();
+};
+const closeDetail = () => detailPopup.value?.close();
+const removeDetail = (index) => details.value.splice(index, 1);
+const toast = (title) => uni.showToast({ title, icon: "none" });
+const hasValue = (value) => value !== undefined && value !== null && String(value).trim() !== "";
+const extScope = (item) => (item.defaultValue === "next" ? "next" : "current");
+const extGroupKey = (item) => {
+  const value = String(item.maxValue ?? "").trim();
+  return value && Number.isNaN(Number(value)) ? value : "";
+};
+const alternativeGroups = (scope) => {
+  const groups = new Map();
+  detailForm.value.extProperty.forEach((item) => {
+    const key = extGroupKey(item);
+    if (!isRequired(item) || extScope(item) !== scope || !key) return;
+    groups.set(key, [...(groups.get(key) || []), item]);
+  });
+  return groups;
+};
+const isAlternative = (item) =>
+  Boolean(extGroupKey(item)) && alternativeGroups(extScope(item)).size > 1;
+const hasCompleteAlternative = (scope) =>
+  [...alternativeGroups(scope).values()].some((items) =>
+    items.every((item) => hasValue(item.actualValue)),
+  );
+const extSearchText = (item) =>
+  `${item.identifier || ""}${item.name || ""}`.toLowerCase().replace(/[\s%()()]/g, "");
+const matchesExt = (item, words) => words.some((word) => extSearchText(item).includes(word));
+const updateEquipmentUtilization = () => {
+  const current = currentExtProperties.value;
+  const utilization = current.find((item) =>
+    matchesExt(item, ["设备利用率", "equipmentutilization", "utilizationrate"]),
+  );
+  const construction = current.find((item) =>
+    matchesExt(item, ["施工设备", "constructiondevicenum"]),
+  );
+  const operating = current.find((item) =>
+    matchesExt(item, ["投运设备", "inservicedevicenum"]),
+  );
+  if (!utilization || !construction || !operating) return;
+  const constructionValue = Number(construction.actualValue);
+  const operatingValue = Number(operating.actualValue);
+  if (!hasValue(construction.actualValue) || !hasValue(operating.actualValue)) return;
+  utilization.actualValue = operatingValue === 0 ? 0 : Number(((constructionValue / operatingValue) * 100).toFixed(2));
+};
+
+const validateDetail = () => {
+  updateEquipmentUtilization();
+  if (!hasValue(detailForm.value.projectName)) return "请输入项目名称";
+  for (const field of moneyFields) {
+    const value = detailForm.value[field.key];
+    if (!hasValue(value)) return `请输入${field.label}`;
+    if (Number(value) < 0) return `${field.label}不能小于0`;
+  }
+  for (const group of textGroups) {
+    for (const field of group.fields) if (!hasValue(detailForm.value[field.key])) return `请输入${field.label}`;
+  }
+  const emptyExt = detailForm.value.extProperty.find(
+    (item) =>
+      isRequired(item) &&
+      !isAlternative(item) &&
+      !hasValue(item.actualValue),
+  );
+  const incompleteAlternative = ["current", "next"].some(
+    (scope) => alternativeGroups(scope).size > 1 && !hasCompleteAlternative(scope),
+  );
+  if (incompleteAlternative) return "请至少完整填写一种工作量";
+  return emptyExt ? `请输入${emptyExt.name}` : "";
+};
+
+const saveDetail = () => {
+  const message = validateDetail();
+  if (message) return toast(message);
+  const item = normalizeDetail(detailForm.value);
+  if (editingIndex.value < 0) details.value.push(item);
+  else details.value.splice(editingIndex.value, 1, item);
+  closeDetail();
+};
+
+const toNumber = (value) => (hasValue(value) ? Number(value) : undefined);
+const serializeDetail = (item) => ({
+  ...item,
+  ...Object.fromEntries(
+    [...moneyFields.map((field) => field.key), "equipmentUtilizationRate"].map(
+      (key) => [key, toNumber(item[key])],
+    ),
+  ),
+  extProperty: cloneExt(item.extProperty).map((property) => ({
+    ...property,
+    actualValue:
+      property.dataType === "double"
+        ? toNumber(property.actualValue)
+        : property.actualValue,
+  })),
+});
+
+const meetingDateTimestamp = (value) => {
+  if (typeof value === "number") return value;
+  const timestamp = new Date(value).getTime();
+  return Number.isNaN(timestamp) ? undefined : timestamp;
+};
+
+const submit = async () => {
+  if (!form.value.meetingDate) return toast("请选择会议日期");
+  if (!form.value.meetingSeries || Number(form.value.meetingSeries) < 1) return toast("请输入正确的会议期次");
+  if (!String(form.value.support || "").trim()) return toast("请输入其他重点事项及需要集团协调事项");
+  if (!details.value.length) return toast("请先新增至少一条会议明细");
+  saving.value = true;
+  try {
+    const operationMeeting = {
+      meetingDate: meetingDateTimestamp(form.value.meetingDate),
+      meetingSeries: `第${Number(form.value.meetingSeries)}期`,
+      support: form.value.support,
+    };
+    if (id.value) operationMeeting.id = Number(id.value);
+    const res = await saveOperationMeetingBatch({
+      operationMeeting,
+      details: details.value.map(serializeDetail),
+    });
+    if (res?.code === 0) {
+      uni.showToast({ title: "保存成功", icon: "success" });
+      setTimeout(() => uni.navigateBack(), 500);
+    }
+  } finally { saving.value = false; }
+};
+
+const goBack = () => uni.navigateBack();
+
+onLoad(async (options) => {
+  type.value = options.type || "create";
+  id.value = options.id;
+  uni.setNavigationBarTitle({
+    title:
+      type.value === "create"
+        ? "新建生产运营双周会"
+        : type.value === "edit"
+          ? "编辑生产运营双周会"
+          : "查看生产运营双周会",
+  });
+  const [extRes, projectRes] = await Promise.all([
+    getCurrentCompanyExtProperties().catch(() => ({ data: [] })),
+    getOperationMeetingProjects().catch(() => ({ data: [] })),
+  ]);
+  extProperties.value = cloneExt(extRes?.data);
+  projectOptions.value = Array.isArray(projectRes?.data) ? projectRes.data.filter(Boolean).map(String) : [];
+  if (!id.value) return;
+  const res = await getOperationMeeting(id.value);
+  const data = res?.data || {};
+  form.value = {
+    meetingDate: data.meetingDate || "",
+    meetingSeries: parseSeries(data.meetingSeries),
+    support: data.support || "",
+  };
+  details.value = (data.details || []).map(normalizeDetail);
+  meetingExtProperties.value = normalizeExtList(data.extProperty);
+  previousTotals.value = {
+    beforeRevenue: data.beforeRevenue,
+    beforeOnAccount: data.beforeOnAccount,
+    beforePayment: data.beforePayment,
+  };
+});
+</script>
+
+<style lang="scss" scoped>
+.swh-form-page { height: 100%; overflow: hidden; background: #f4f7fb; }
+.swh-form-scroll { height: 100%; }
+.swh-form-content { min-height: 100%; padding: 24rpx; box-sizing: border-box; }
+.section, .popup-section { margin-bottom: 22rpx; padding: 28rpx 24rpx; border-radius: 20rpx; background: #fff; }
+.section-title, .popup-section-title { color: #172033; font-size: 32rpx; font-weight: 700; }
+.section-tip { margin-top: 6rpx; color: #94a3b8; font-size: 22rpx; }
+.summary-grid, .workload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16rpx; margin-top: 22rpx; }
+.summary-card, .workload-card { min-width: 0; padding: 20rpx; border-left: 6rpx solid #3b82f6; border-radius: 14rpx; background: #f8fafc; box-sizing: border-box; }
+.summary-card.account, .workload-card.orange { border-color: #f59e0b; }.summary-card.payment, .workload-card.green { border-color: #10b981; }
+.workload-card.purple { border-color: #8b5cf6; }.workload-card.cyan { border-color: #06b6d4; }.workload-card.rose { border-color: #f43f5e; }
+.summary-card > text, .workload-card > text { display: block; overflow: hidden; color: #64748b; font-size: 22rpx; text-overflow: ellipsis; white-space: nowrap; }
+.summary-value, .workload-card strong { display: block; margin-top: 10rpx; color: #172033; font-size: 29rpx; font-weight: 700; overflow-wrap: anywhere; }
+.summary-value small { color: #94a3b8; font-size: 20rpx; font-weight: 400; }
+.summary-compare { margin-top: 8rpx; color: #64748b; font-size: 20rpx; }.summary-compare.up { color: #ef4444; }.summary-compare.down { color: #10b981; }
+.field { margin-top: 24rpx; }
+.label { display: block; margin-bottom: 12rpx; color: #475569; font-size: 26rpx; }
+.required { color: #ef4444; }
+.detail-head { display: flex; align-items: center; justify-content: space-between; }
+.add-btn { width: 180rpx; height: 66rpx; line-height: 66rpx; margin: 0; color: #fff; font-size: 25rpx; border-radius: 12rpx; background: #2563eb; }
+.empty { padding: 70rpx 0 40rpx; color: #94a3b8; text-align: center; }
+.detail-card { margin-top: 22rpx; padding: 24rpx; border: 1rpx solid #e5edf8; border-radius: 16rpx; background: #fbfdff; }
+.detail-card-head { display: flex; justify-content: space-between; }
+.project-name { color: #172033; font-size: 29rpx; font-weight: 700; }
+.detail-link { color: #2563eb; font-size: 25rpx; }
+.money-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10rpx; margin-top: 22rpx; }
+.money-grid view { min-width: 0; padding: 16rpx 8rpx; border-radius: 10rpx; background: #f1f5f9; text-align: center; }
+.money-grid text, .money-grid strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.money-grid text { color: #94a3b8; font-size: 20rpx; }.money-grid strong { margin-top: 8rpx; color: #334155; font-size: 21rpx; }
+.delete { margin-top: 18rpx; color: #ef4444; font-size: 24rpx; text-align: right; }
+.footer-space { height: 130rpx; }
+.footer, .popup-footer { display: flex; padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom)); background: #fff; }
+.footer { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; box-shadow: 0 -6rpx 20rpx rgba(15, 23, 42, .08); }
+.footer button, .popup-footer button { flex: 1; height: 76rpx; margin: 0; line-height: 76rpx; font-size: 28rpx; border-radius: 14rpx; }
+.cancel-btn { color: #475569; background: #eef2f7; }.save-btn { margin-left: 20rpx !important; color: #fff; background: #2563eb; }
+.detail-popup { height: 90vh; display: flex; flex-direction: column; }
+.popup-head { display: flex; align-items: center; justify-content: space-between; padding: 30rpx; color: #172033; font-size: 34rpx; font-weight: 700; background: #fff; }
+.popup-scroll { flex: 1; height: 0; padding: 20rpx 22rpx 0; box-sizing: border-box; }
+.popup-section-title { font-size: 29rpx; }
+.two-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18rpx; }
+.suggestions { margin-top: 14rpx; white-space: nowrap; }
+.suggestions text { display: inline-block; margin-right: 12rpx; padding: 10rpx 18rpx; color: #2563eb; font-size: 22rpx; border-radius: 999rpx; background: #eff6ff; }
+.popup-bottom { height: 20rpx; }
+button::after { border: 0; }
+</style>

+ 167 - 0
pages/swh/index.vue

@@ -0,0 +1,167 @@
+<template>
+  <view class="swh-page">
+    <view class="filter-card">
+      <view class="filter-title">数据填报</view>
+      <view class="filter-subtitle">填报并维护各期生产运营双周会数据</view>
+      <view class="filter-field">
+        <text>专业公司</text>
+        <uni-data-select v-model="deptId" :localdata="companyOptions" placeholder="请选择专业公司" />
+      </view>
+      <view class="filter-field">
+        <text>会议日期</text>
+      <uni-datetime-picker v-model="meetingDate" type="daterange" return-type="string" />
+      </view>
+      <view class="filter-actions">
+        <button class="plain-btn" @click="resetQuery">重置</button>
+        <button class="primary-btn" @click="search">查询</button>
+      </view>
+    </view>
+
+    <view class="list-head">
+      <text>会议记录({{ total }})</text>
+      <button class="create-btn" @click="openForm('create')">+ 新建</button>
+    </view>
+
+    <view v-if="loading" class="state"><uni-load-more status="loading" /></view>
+    <view v-else-if="!list.length" class="state"><text>暂无会议数据</text></view>
+    <view v-else class="card-list">
+      <view v-for="item in list" :key="item.id" class="meeting-card">
+        <view class="card-head">
+          <view class="series">{{ item.meetingSeries || "-" }}</view>
+          <view class="date">{{ formatDate(item.meetingDate) }}</view>
+        </view>
+        <view class="company">
+          <uni-icons type="staff" size="16" color="#64748b" />
+          <text>{{ item.companyName || "-" }}</text>
+        </view>
+        <view class="card-actions">
+          <text @click="openForm('view', item.id)">查看</text>
+          <text class="edit" @click="openForm('edit', item.id)">编辑</text>
+        </view>
+      </view>
+    </view>
+
+    <uni-load-more
+      v-if="list.length"
+      :status="loadStatus"
+      :content-text="loadText"
+      @clickLoadMore="loadMore"
+    />
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { onLoad, onReachBottom, onShow } from "@dcloudio/uni-app";
+import dayjs from "dayjs";
+import {
+  getOperationMeetingCompanies,
+  getOperationMeetingPage,
+} from "@/api/operationMeeting";
+
+const meetingDate = ref([]);
+const deptId = ref("");
+const companyOptions = ref([]);
+const list = ref([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = 10;
+const loading = ref(false);
+const loadingMore = ref(false);
+
+const hasMore = computed(() => list.value.length < total.value);
+const loadStatus = computed(() =>
+  loadingMore.value ? "loading" : hasMore.value ? "more" : "noMore",
+);
+const loadText = {
+  contentdown: "上拉加载更多",
+  contentrefresh: "加载中",
+  contentnomore: "没有更多数据了",
+};
+
+const fetchList = async (append = false) => {
+  append ? (loadingMore.value = true) : (loading.value = true);
+  try {
+    const res = await getOperationMeetingPage({
+      pageNo: pageNo.value,
+      pageSize,
+      deptId: deptId.value || undefined,
+      meetingDate: meetingDate.value?.length
+        ? [
+            `${meetingDate.value[0]} 00:00:00`,
+            `${meetingDate.value[1]} 23:59:59`,
+          ]
+        : undefined,
+    });
+    const data = res?.data || {};
+    const rows = data.list || [];
+    list.value = append ? [...list.value, ...rows] : rows;
+    total.value = data.total || 0;
+  } finally {
+    loading.value = false;
+    loadingMore.value = false;
+  }
+};
+
+const search = () => {
+  pageNo.value = 1;
+  fetchList();
+};
+
+const resetQuery = () => {
+  meetingDate.value = [];
+  deptId.value = "";
+  search();
+};
+
+const loadMore = () => {
+  if (!hasMore.value || loadingMore.value) return;
+  pageNo.value += 1;
+  fetchList(true);
+};
+
+const openForm = (type, id) => {
+  const query = id ? `?type=${type}&id=${id}` : `?type=${type}`;
+  uni.navigateTo({ url: `/pages/swh/form${query}` });
+};
+
+const formatDate = (value) => (value ? dayjs(value).format("YYYY-MM-DD") : "-");
+
+onLoad(async () => {
+  const res = await getOperationMeetingCompanies();
+  companyOptions.value = (Array.isArray(res?.data) ? res.data : [])
+    .filter((item) => item.type === "1")
+    .map((item) => ({ text: item.name, value: item.id }));
+});
+onShow(search);
+onReachBottom(loadMore);
+</script>
+
+<style lang="scss" scoped>
+.swh-page { min-height: 100vh; padding: 24rpx; box-sizing: border-box; background: #f4f7fb; }
+.filter-card, .meeting-card { background: #fff; border-radius: 20rpx; box-shadow: 0 8rpx 28rpx rgba(30, 64, 175, .05); }
+.filter-card { padding: 30rpx 26rpx; }
+.filter-title { color: #0f172a; font-size: 36rpx; font-weight: 700; }
+.filter-subtitle { margin: 8rpx 0 26rpx; color: #94a3b8; font-size: 24rpx; }
+.filter-field + .filter-field { margin-top: 20rpx; }
+.filter-field > text { display: block; margin-bottom: 10rpx; color: #64748b; font-size: 24rpx; }
+.filter-actions { display: flex; margin-top: 24rpx; }
+.filter-actions button { flex: 1; height: 72rpx; margin: 0; line-height: 72rpx; font-size: 28rpx; border-radius: 14rpx; }
+.filter-actions button + button { margin-left: 18rpx; }
+.plain-btn { color: #475569; background: #f1f5f9; }
+.primary-btn, .create-btn { color: #fff; background: #2563eb; }
+.list-head { display: flex; align-items: center; justify-content: space-between; margin: 30rpx 4rpx 20rpx; color: #172033; font-weight: 700; }
+.create-btn { width: 150rpx; height: 64rpx; line-height: 64rpx; margin: 0; font-size: 26rpx; border-radius: 12rpx; }
+.card-list { padding-bottom: 20rpx; }
+.meeting-card + .meeting-card { margin-top: 20rpx; }
+.meeting-card { padding: 26rpx; }
+.card-head { display: flex; justify-content: space-between; align-items: center; }
+.series { color: #1d4ed8; font-size: 32rpx; font-weight: 700; }
+.date { color: #64748b; font-size: 24rpx; }
+.company { display: flex; align-items: center; margin-top: 22rpx; color: #334155; font-size: 28rpx; }
+.company text { margin-left: 10rpx; }
+.card-actions { display: flex; justify-content: flex-end; margin-top: 24rpx; padding-top: 20rpx; border-top: 1rpx solid #eef2f7; color: #16a34a; font-size: 26rpx; }
+.card-actions .edit { margin-left: 42rpx; color: #2563eb; }
+.state { padding: 100rpx 0; color: #94a3b8; text-align: center; }
+button::after { border: 0; }
+</style>

+ 245 - 0
pages/swh/summary-detail.vue

@@ -0,0 +1,245 @@
+<template>
+  <view class="swh-page">
+    <view class="summary-head">
+      <view>
+        <view class="title">{{ meetingSeries || "会议汇总" }}</view>
+        <view class="subtitle">{{ year }} 年生产运营数据</view>
+      </view>
+      <view class="company-count">{{ meetings.length }} 家公司</view>
+    </view>
+
+    <view v-if="loading" class="state"><uni-load-more status="loading" /></view>
+    <view v-else-if="!meetings.length" class="state">暂无汇总会议详情</view>
+    <template v-else>
+      <view class="filter-card">
+        <text class="filter-label">公司筛选</text>
+        <uni-data-select v-model="companyIndex" :localdata="companyOptions" :clear="false" />
+      </view>
+
+      <view class="metric-grid">
+        <view v-for="metric in metrics" :key="metric.key" class="metric-card" :class="metric.tone">
+          <text>{{ metric.label }}</text>
+          <strong>{{ total(metric.key) }}</strong>
+          <small>万元</small>
+        </view>
+      </view>
+
+      <view v-if="companyIndex >= 0 && selectedWorkloadProperties.length" class="workload-section">
+        <view class="workload-head">
+          <view>
+            <view class="workload-company">{{ selectedMeeting?.companyName || "当前公司" }}</view>
+            <view class="workload-subtitle">总体工作量完成情况</view>
+          </view>
+        </view>
+        <view class="workload-grid">
+          <view
+            v-for="(item, index) in selectedWorkloadProperties"
+            :key="item.identifier"
+            class="workload-card"
+            :class="workloadTone(item, index)"
+          >
+            <text>{{ item.name }}</text>
+            <strong>{{ workloadValue(item) }}</strong>
+          </view>
+        </view>
+      </view>
+
+      <view v-for="meeting in visibleMeetings" :key="meeting.key" class="company-section">
+        <view class="company-head">
+          <view>
+            <view class="company-name">{{ meeting.companyName || "未知公司" }}</view>
+            <view class="meeting-date">{{ formatDate(meeting.meetingDate) }} · {{ meeting.meetingSeries }}</view>
+          </view>
+          <view class="project-count">{{ meeting.details.length }} 个项目</view>
+        </view>
+
+        <view v-if="hasSupport(meeting.support)" class="support">
+          <text>协调事项</text>
+          <view class="multiline-text">{{ displayText(meeting.support) }}</view>
+        </view>
+
+        <view v-for="(detail, index) in meeting.details" :key="index" class="project-card">
+          <view class="project-title">{{ detail.projectName || "未命名项目" }}</view>
+          <view class="money-list">
+            <view v-for="metric in metrics" :key="metric.key">
+              <text>{{ metric.label }}</text>
+              <strong>{{ valueText(detail[metric.key]) }} 万元</strong>
+            </view>
+          </view>
+          <view v-for="field in detailTextFields" :key="field.key" class="text-field">
+            <text>{{ field.label }}</text>
+            <view class="multiline-text">{{ displayText(detail[field.key]) }}</view>
+          </view>
+          <view v-for="item in normalizeExt(detail.extProperty)" :key="item.identifier" class="text-field">
+            <text>{{ extLabel(item) }}</text>
+            <view class="multiline-text">{{ displayPropertyText(item) }}</view>
+          </view>
+        </view>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { onLoad } from "@dcloudio/uni-app";
+import dayjs from "dayjs";
+import { getSummarizedProjectDetails } from "@/api/operationMeeting";
+
+const loading = ref(false);
+const meetingSeries = ref("");
+const year = ref("");
+const meetings = ref([]);
+const companyIndex = ref(-1);
+
+const metrics = [
+  { key: "currentRevenue", label: "收入-本期", tone: "blue" },
+  { key: "cumulativeRevenue", label: "收入-累计", tone: "blue" },
+  { key: "currentOnAccount", label: "挂帐-本期", tone: "orange" },
+  { key: "cumulativeOnAccount", label: "挂帐-累计", tone: "orange" },
+  { key: "currentPayment", label: "回款-本期", tone: "green" },
+  { key: "cumulativePayment", label: "回款-累计", tone: "green" },
+];
+const detailTextFields = [
+  { key: "keyWorkCompletion", label: "重点工作及完成情况" },
+  { key: "problemsAnalysis", label: "存在问题及分析" },
+  { key: "qhse", label: "设备、安全管理工作" },
+  { key: "nextPlannedWorkload", label: "下期计划工作量" },
+  { key: "priorityTasks", label: "重点工作事项" },
+];
+
+const normalizedMeetings = (list) => list.map((item, index) => ({
+  ...item,
+  key: `${item.id ?? index}-${index}`,
+  details: Array.isArray(item.details) ? item.details : [],
+}));
+const companyOptions = computed(() => [
+  { text: "全部公司", value: -1 },
+  ...meetings.value.map((item, index) => ({ text: item.companyName || `公司${index + 1}`, value: index })),
+]);
+const visibleMeetings = computed(() => companyIndex.value < 0 ? meetings.value : [meetings.value[companyIndex.value]].filter(Boolean));
+const selectedMeeting = computed(() =>
+  companyIndex.value < 0 ? undefined : meetings.value[companyIndex.value],
+);
+const summaryDetails = computed(() => {
+  const selected = visibleMeetings.value;
+  if (companyIndex.value >= 0) return selected.flatMap((item) => item.details);
+  const cumulative = selected.filter((item) => item.cumulative);
+  return (cumulative.length ? cumulative : selected).flatMap((item) => item.details);
+});
+const toAmountNumber = (value) => {
+  const number = Number(value);
+  return Number.isFinite(number) ? number : 0;
+};
+const roundAmount = (value) => {
+  const number = toAmountNumber(value);
+  return Math.round((number + Math.sign(number) * Number.EPSILON) * 100) / 100;
+};
+const valueText = (value) => roundAmount(value).toFixed(2);
+const total = (key) => {
+  const totalCents = summaryDetails.value.reduce(
+    (sum, item) => sum + Math.round(roundAmount(item[key]) * 100),
+    0,
+  );
+  return valueText(totalCents / 100);
+};
+const formatDate = (value) => value ? dayjs(value).format("YYYY-MM-DD") : "-";
+const hasValue = (value) => value !== undefined && value !== null && String(value).trim() !== "";
+const hasSupport = (value) => {
+  const text = String(value ?? "").trim();
+  return Boolean(text) && !["无", "暂无", "-"].includes(text);
+};
+const displayText = (value) => {
+  if (!hasValue(value)) return "-";
+  return String(value)
+    .replace(/\\r\\n/g, "\n")
+    .replace(/\\n/g, "\n")
+    .replace(/\r\n/g, "\n");
+};
+const normalizeExt = (value) => {
+  if (Array.isArray(value)) return value;
+  if (typeof value === "string") { try { return normalizeExt(JSON.parse(value)); } catch (_) { return []; } }
+  if (value && typeof value === "object") {
+    const values = Object.values(value);
+    if (values.every((item) => item && typeof item === "object")) return values;
+    return Object.entries(value).map(([identifier, actualValue]) => ({
+      name: identifier,
+      identifier,
+      actualValue,
+      dataType: typeof actualValue === "number" ? "double" : "",
+      required: 0,
+    }));
+  }
+  return [];
+};
+const extLabel = (item) => `${item.name || item.identifier}${item.unit ? `(${item.unit})` : ""}`;
+const displayPropertyText = (item) => {
+  if (!hasValue(item?.actualValue)) return "-";
+  return item.dataType === "double"
+    ? valueText(item.actualValue)
+    : displayText(item.actualValue);
+};
+const isRequiredWorkload = (item) => [true, 1, "1"].includes(item.required);
+const selectedWorkloadProperties = computed(() =>
+  normalizeExt(selectedMeeting.value?.extProperty)
+    .filter(
+      (item) =>
+        item.identifier &&
+        item.defaultValue !== "next" &&
+        isRequiredWorkload(item),
+    )
+    .sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0)),
+);
+const workloadMetricText = (item) =>
+  `${item.identifier || ""}${item.name || ""}`.toLowerCase().replace(/[\s%]/g, "");
+const workloadTone = (item, index) => {
+  const text = workloadMetricText(item);
+  if (text.includes("设备利用率") || text.includes("utilization")) return "cyan";
+  if (text.includes("环比") || text.includes("monthonmonth") || text.includes("mom")) return "rose";
+  if (text.includes("计划") || text.includes("plan")) return "blue";
+  if (text.includes("实际") || text.includes("actual")) return "green";
+  if (text.includes("完成") || text.includes("complete")) return "orange";
+  if (text.includes("累计") || text.includes("cumulative")) return "purple";
+  return ["blue", "green", "orange", "purple"][index % 4];
+};
+const workloadValue = (item) => {
+  if (!hasValue(item.actualValue)) return "-";
+  return `${item.dataType === "double" ? valueText(item.actualValue) : item.actualValue}${item.unit || ""}`;
+};
+
+onLoad(async (options) => {
+  meetingSeries.value = decodeURIComponent(options.meetingSeries || "");
+  year.value = options.year || "";
+  loading.value = true;
+  try {
+    const res = await getSummarizedProjectDetails({ meetingSeries: meetingSeries.value, year: year.value });
+    meetings.value = normalizedMeetings(Array.isArray(res?.data) ? res.data : []);
+  } finally { loading.value = false; }
+});
+</script>
+
+<style lang="scss" scoped>
+.swh-page { min-height: 100vh; padding: 24rpx; box-sizing: border-box; background: #f4f7fb; }
+.summary-head { display: flex; align-items: center; justify-content: space-between; padding: 32rpx 28rpx; color: #fff; border-radius: 22rpx; background: linear-gradient(135deg, #1d4ed8, #3b82f6); }
+.title { font-size: 38rpx; font-weight: 700; }.subtitle { margin-top: 8rpx; font-size: 24rpx; opacity: .8; }
+.company-count { padding: 10rpx 18rpx; font-size: 23rpx; border-radius: 999rpx; background: rgba(255,255,255,.18); }
+.filter-card { margin-top: 22rpx; padding: 24rpx; border-radius: 18rpx; background: #fff; }.filter-label { display: block; margin-bottom: 12rpx; color: #64748b; font-size: 24rpx; }
+.metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16rpx; margin-top: 22rpx; }
+.metric-card { padding: 22rpx; border-radius: 16rpx; background: #fff; border-left: 6rpx solid #3b82f6; }
+.metric-card.orange { border-color: #f59e0b; }.metric-card.green { border-color: #10b981; }
+.metric-card text { display: block; color: #64748b; font-size: 22rpx; }.metric-card strong { display: inline-block; margin-top: 10rpx; color: #172033; font-size: 31rpx; }.metric-card small { margin-left: 6rpx; color: #94a3b8; font-size: 20rpx; }
+.workload-section { margin-top: 24rpx; padding: 26rpx; border-radius: 20rpx; background: #fff; }
+.workload-company { color: #172033; font-size: 30rpx; font-weight: 700; }.workload-subtitle { margin-top: 6rpx; color: #94a3b8; font-size: 22rpx; }
+.workload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16rpx; margin-top: 22rpx; }
+.workload-card { min-width: 0; padding: 20rpx; border-left: 6rpx solid #3b82f6; border-radius: 14rpx; background: #f8fafc; box-sizing: border-box; }
+.workload-card.orange { border-color: #f59e0b; }.workload-card.green { border-color: #10b981; }.workload-card.purple { border-color: #8b5cf6; }.workload-card.cyan { border-color: #06b6d4; }.workload-card.rose { border-color: #f43f5e; }
+.workload-card text { display: block; overflow: hidden; color: #64748b; font-size: 22rpx; text-overflow: ellipsis; white-space: nowrap; }.workload-card strong { display: block; margin-top: 10rpx; color: #172033; font-size: 29rpx; overflow-wrap: anywhere; }
+.company-section { margin-top: 24rpx; padding: 26rpx; border-radius: 20rpx; background: #fff; }
+.company-head { display: flex; justify-content: space-between; align-items: flex-start; }.company-name { color: #172033; font-size: 32rpx; font-weight: 700; }.meeting-date { margin-top: 8rpx; color: #94a3b8; font-size: 22rpx; }.project-count { color: #2563eb; font-size: 22rpx; }
+.support { margin-top: 22rpx; padding: 20rpx; color: #475569; font-size: 25rpx; line-height: 1.6; border-radius: 14rpx; background: #fff7ed; }.support text { display: block; margin-bottom: 6rpx; color: #c2410c; font-weight: 700; }
+.project-card { margin-top: 22rpx; padding: 22rpx; border: 1rpx solid #e5edf8; border-radius: 16rpx; background: #fbfdff; }.project-title { color: #1d4ed8; font-size: 29rpx; font-weight: 700; }
+.money-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12rpx; margin-top: 18rpx; }.money-list view { padding: 14rpx; border-radius: 10rpx; background: #f1f5f9; }.money-list text,.money-list strong { display: block; }.money-list text { color: #94a3b8; font-size: 20rpx; }.money-list strong { margin-top: 6rpx; color: #334155; font-size: 22rpx; }
+.text-field { margin-top: 20rpx; color: #475569; font-size: 25rpx; line-height: 1.6; }.text-field text { display: block; margin-bottom: 4rpx; color: #172033; font-weight: 700; }
+.multiline-text { white-space: pre-wrap; word-break: break-word; word-wrap: break-word; overflow-wrap: anywhere; }
+.state { padding: 150rpx 0; color: #94a3b8; text-align: center; }
+</style>

+ 66 - 0
pages/swh/summary.vue

@@ -0,0 +1,66 @@
+<template>
+  <view class="swh-page">
+    <view class="hero-card">
+      <view class="title">数据汇总</view>
+      <view class="subtitle">按期次查看各专业公司的运营会议汇总</view>
+    </view>
+    <view v-if="loading" class="state"><uni-load-more status="loading" /></view>
+    <view v-else-if="!list.length" class="state">暂无汇总数据</view>
+    <view v-else class="list">
+      <view v-for="item in list" :key="`${item.id}-${item.meetingSeries}`" class="card" @click="viewDetail(item)">
+        <view class="card-icon"><uni-icons type="calendar-filled" size="24" color="#fff" /></view>
+        <view class="card-main">
+          <view class="series">{{ item.meetingSeries || "-" }}</view>
+          <view class="meta">{{ item.companyName || "生产运营汇总" }}</view>
+          <view class="meta">会议日期:{{ formatDate(item.meetingDate) }}</view>
+        </view>
+        <uni-icons type="right" size="18" color="#94a3b8" />
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { onShow } from "@dcloudio/uni-app";
+import dayjs from "dayjs";
+import { getIntegratedMeetingList } from "@/api/operationMeeting";
+
+const loading = ref(false);
+const list = ref([]);
+
+const loadList = async () => {
+  loading.value = true;
+  try {
+    const res = await getIntegratedMeetingList();
+    list.value = Array.isArray(res?.data) ? res.data : [];
+  } finally {
+    loading.value = false;
+  }
+};
+
+const formatDate = (value) => (value ? dayjs(value).format("YYYY-MM-DD") : "-");
+const viewDetail = (item) => {
+  const year = item.meetingDate ? dayjs(item.meetingDate).format("YYYY") : "";
+  uni.navigateTo({
+    url: `/pages/swh/summary-detail?meetingSeries=${encodeURIComponent(item.meetingSeries || "")}&year=${year}`,
+  });
+};
+
+onShow(loadList);
+</script>
+
+<style lang="scss" scoped>
+.swh-page { min-height: 100vh; padding: 24rpx; box-sizing: border-box; background: #f4f7fb; }
+.hero-card { padding: 34rpx 30rpx; border-radius: 22rpx; color: #fff; background: linear-gradient(135deg, #246bff, #4f8ff7); box-shadow: 0 14rpx 34rpx rgba(36, 107, 255, .2); }
+.title { font-size: 38rpx; font-weight: 700; }
+.subtitle { margin-top: 10rpx; font-size: 24rpx; opacity: .82; }
+.list { margin-top: 26rpx; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); }
+.card + .card { margin-top: 20rpx; }
+.card { display: flex; align-items: center; padding: 28rpx 24rpx; border-radius: 20rpx; background: #fff; box-shadow: 0 8rpx 28rpx rgba(30, 64, 175, .05); }
+.card-icon { width: 76rpx; height: 76rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-right: 22rpx; border-radius: 20rpx; background: #f59e0b; }
+.card-main { flex: 1; min-width: 0; margin-right: 12rpx; }
+.series { color: #172033; font-size: 32rpx; font-weight: 700; }
+.meta { margin-top: 8rpx; overflow: hidden; color: #64748b; font-size: 24rpx; text-overflow: ellipsis; white-space: nowrap; }
+.state { padding: 140rpx 0; color: #94a3b8; text-align: center; }
+</style>

+ 12 - 18
pages/user/index.vue

@@ -12,8 +12,11 @@
             <view class="notice-button">
               <image
                 src="~@/static/home/message.png"
-                style="width: 15px; height: 15px; background-color: transparent"
-              />
+                style="
+                  width: 15px;
+                  height: 15px;
+                  background-color: transparent;
+                " />
             </view>
           </uni-badge>
         </view>
@@ -27,8 +30,7 @@
             class="avatar"
             :src="
               userInfo?.avatar ? userInfo?.avatar : '/static/user/avatar.png'
-            "
-          ></image>
+            "></image>
           <view class="info flex-col align-center justify-start">
             <view class="text flex-row" style="align-items: center; gap: 10px">
               <text class="nickname">{{ userInfo?.nickname }}</text>
@@ -49,8 +51,7 @@
                 display: flex;
                 align-items: center;
                 gap: 5px;
-              "
-            >
+              ">
               <uni-icons type="phone-filled" size="16" color="#656f85" />
               <text>{{ userInfo?.mobile }}</text>
             </view>
@@ -62,8 +63,7 @@
                 display: flex;
                 align-items: center;
                 gap: 5px;
-              "
-            >
+              ">
               <uni-icons type="staff" size="16" color="#656f85" />
               <text>{{ userInfo?.dept.name }}</text>
             </view>
@@ -91,12 +91,10 @@
       <view class="menu-list">
         <view
           class="card-cell flex-row align-center justify-between"
-          @click="navigateToChange"
-        >
+          @click="navigateToChange">
           <image
             src="/static/user/anquanzhongxin.svg"
-            mode="aspectFill"
-          ></image>
+            mode="aspectFill"></image>
           <view class="cell-con flex-row align-center justify-between">
             <view class="cell-text flex-row align-center justify-start">
               <view class="title">
@@ -124,7 +122,6 @@
             <uni-icons type="right" :color="'#CACCCF'" size="15" />
           </view>
         </view>
-
         <!-- <view
           class="card-cell flex-row align-center justify-between"
           @click="logout"
@@ -151,12 +148,10 @@
           display: flex;
           align-items: center;
           justify-content: center;
-        "
-      >
+        ">
         <image
           style="width: 22px; height: 22px; padding-right: 8px"
-          src="/static/user/logout.png"
-        ></image>
+          src="/static/user/logout.png"></image>
         <text>{{ $t("user.logout") }}</text>
       </button>
     </view>
@@ -280,7 +275,6 @@ onShow(() => {
   width: 100%;
   height: 55px;
   padding: 15px 0;
-
   z-index: 2;
   // background-image: url('/static/common/nav-back.png');
 }

+ 39 - 130
pages/user/login.vue

@@ -3,9 +3,7 @@
     <view class="login-top">
       <image class="back-img" src="../../static/login/login-back.png"></image>
       <view class="login-text">
-        <view class="text">
-          {{ $t("login.welcome") }}
-        </view>
+        <view class="text"> {{ $t("login.welcome") }}</view>
         <view class="text">
           {{ $t("app.appName") }}
         </view>
@@ -16,8 +14,7 @@
         class="login-form"
         ref="formRef"
         :modelValue="loginData"
-        :rules="loginRules"
-      >
+        :rules="loginRules">
         <uni-forms-item name="username" class="margin-bt">
           <!-- type="number" -->
           <uni-easyinput
@@ -25,8 +22,7 @@
             v-model="loginData.username"
             :placeholder="$t('login.enterUsername')"
             :placeholderStyle="placeholderStyle"
-            :styles="inputStyles"
-          />
+            :styles="inputStyles" />
         </uni-forms-item>
         <uni-forms-item name="password" class="margin-bt">
           <uni-easyinput
@@ -34,8 +30,7 @@
             v-model="loginData.password"
             :placeholder="$t('login.enterPassword')"
             :placeholderStyle="placeholderStyle"
-            :styles="inputStyles"
-          />
+            :styles="inputStyles" />
         </uni-forms-item>
       </uni-forms>
       <button type="primary" @click="formSubmit(formRef)">
@@ -51,40 +46,6 @@
       </view>
     </view>
 
-    <!-- <view class="uni-padding-wrap">
-			<view>
-				<checkbox-group @change="handleChange">
-					<label>
-						<checkbox :value="true" :checked="isChecked" style="transform: scale(0.6)" />
-					</label>
-				</checkbox-group>
-			</view>
-
-						<view class="uni-title">
-				已阅读并同意
-				<text style="text-decoration: underline" @click="goPrivacy">《隐私政策》</text>
-
-				和
-				<text style="text-decoration: underline" @click="goAgreement">《用户服务协议》</text>
-			</view>
-
-			<uni-popup ref="privacyRef">
-				<scroll-view scroll-y="true" class="privacy" style="height: 500px; border-radius: 20rpx">
-					<view style="background-color: #fff; padding: 0 50rpx; border-radius: 20rpx">
-						<Privacy />
-					</view>
-				</scroll-view>
-			</uni-popup>
-
-			<uni-popup ref="aggRef">
-				<scroll-view scroll-y="true" class="privacy" style="height: 500px; border-radius: 20rpx">
-					<view style="background-color: #fff; padding: 0 50rpx; border-radius: 20rpx">
-						<Agg />
-					</view>
-				</scroll-view>
-			</uni-popup>
-		</view> -->
-
     <!-- 引用语言选择组件 -->
     <language-popup ref="languagePopupRef" />
     <upgrade ref="upgradeRef" />
@@ -108,6 +69,7 @@ import config from "@/utils/config";
 import { saveUser } from "@/utils/appDb";
 // 引入本地存储操作
 import { setUserId, setToken, setDeptId, setUserInfo } from "@/utils/auth.js";
+import { useDataDictStore } from "@/store/modules/dataDict";
 // 引入组件
 import Upgrade from "@/components/upgrade.vue";
 import LanguagePopup from "@/components/language-popup.vue";
@@ -124,27 +86,9 @@ const t = appContext.config.globalProperties.$t;
 const languagePopupRef = ref(null);
 
 const openLanguagePopup = () => {
-  languagePopupRef.value.open();
-};
-
-let isChecked = ref(false);
-let my_value = ref(false);
-
-const handleChange = (val) => {
-  my_value.value = val.detail.value[0];
+  languagePopupRef.value?.open();
 };
 
-// let privacyRef = ref(null);
-// let aggRef = ref(null);
-
-// const goPrivacy = () => {
-// 	privacyRef.value.open();
-// };
-
-// const goAgreement = () => {
-// 	aggRef.value.open();
-// };
-
 // 判断当前环境是否在钉钉环境
 const isDingTalk = () => {
   const ua = window.navigator.userAgent.toLowerCase();
@@ -152,7 +96,7 @@ const isDingTalk = () => {
   return ua.includes("dingtalk") || ua.includes("dingtalkwork");
 };
 
-const dingTalkAutoLogin = async () => {
+const dingTalkAutoLogin = () => {
   // 判断是否在钉钉环境
   if (!isDingTalk()) {
     console.log("当前环境不是钉钉环境,无法自动登录");
@@ -184,7 +128,6 @@ const loginWithDingTalk = async () => {
     }
   });
   // #endif
-
   // #ifdef H5
   if (isDingTalk()) {
     if (!dd) {
@@ -243,7 +186,7 @@ onLoad(async (options) => {
     "onLoad Login",
     uni.getLocale(),
     11,
-    uni.getStorageSync("language"),
+    uni.getStorageSync("language")
   );
 
   console.log(options);
@@ -317,81 +260,47 @@ const loginRules = ref({
 
 const formRef = ref();
 const formSubmit = async (formEl) => {
-  // if (!my_value.value) {
-  // 	uni.showToast({
-  // 		title: '请阅读并同意隐私政策和用户服务协议',
-  // 		icon: 'none', // 可选 success/error/loading/none
-  // 		duration: 2000, // 持续时间,单位ms
-  // 		mask: true // 是否显示透明蒙层,防止触摸穿透
-  // 	});
-
-  // 	return;
-  // }
   if (!formEl) return;
-  await formEl
-    .validate()
-    .then((res) => {
-      appLogin({
-        ...loginData,
-        // rememberMe: ,
-        // tenantName: ""
-      })
-        .then(async (result) => {
-          console.log("result,", result.data);
-          if (result) {
-            await saveUser({
-              name: loginData.username,
-              pwd: loginData.password,
-            });
-            await handleLoginSuccess(result);
-          }
-        })
-        .finally(() => {});
-    })
-    .catch((err) => {
-      console.log("err", err);
+
+  try {
+    await formEl.validate();
+    const result = await appLogin({ ...loginData });
+
+    if (!result) return;
+
+    console.log("result,", result.data);
+    await saveUser({
+      name: loginData.username,
+      pwd: loginData.password,
     });
+    await handleLoginSuccess(result);
+  } catch (err) {
+    console.log("err", err);
+  }
 };
 
 const handleLoginSuccess = async (result) => {
-  if (result) {
-    await setUserId(result.data.userId);
-    await setToken(result.data);
-    await getInfo().then(async (res) => {
-      // console.log('useres', res)
-      const data = JSON.stringify({
-        user: res.data.user,
-        roles: res.data.roles,
-      });
-      // console.log('data', data)
-      await setUserInfo(data);
-      await setDeptId(res.data.user.deptId);
+  if (!result) return;
 
-      await uni.switchTab({
-        url: "/pages/entry/index",
-      });
-    });
-  }
+  await setUserId(result.data.userId);
+  await setToken(result.data);
+
+  const res = await getInfo();
+  const data = JSON.stringify({
+    user: res.data.user,
+    roles: res.data.roles,
+  });
+
+  await setUserInfo(data);
+  await setDeptId(res.data.user.deptId);
+  await useDataDictStore().loadDataDictList();
+  await uni.switchTab({
+    url: "/pages/entry/index",
+  });
 };
 </script>
 
 <style lang="scss" scoped>
-.privacy {
-  height: 60vh;
-  width: 85vw;
-  overflow: hidden;
-}
-
-.uni-padding-wrap {
-  display: flex;
-  z-index: 999;
-  justify-content: center;
-  align-items: center;
-  padding: 0 10rpx;
-}
-.uni-title {
-  font-size: 12px;
-}
 .login-top {
   position: relative;
   width: 100%;

+ 34 - 9
store/modules/dataDict.js

@@ -2,16 +2,41 @@ import { defineStore } from 'pinia';
 import { ref } from 'vue';
 import { getAllDataDictList as getList } from '@/api';
 
-export const useDataDictStore = defineStore('dataDict', () => {
-  const dataDict = ref([]);
-  const map = new Map();
+export const useDataDictStore = defineStore('dataDict', () => {
+  const dataDict = ref([]);
+  const map = new Map();
+  let isLoaded = false;
+  let loadingPromise = null;
 
-  /**
-   * 加载数据字典
-   */
-  const loadDataDictList = async () => {
-    dataDict.value = (await getList()).data;
-  };
+  /**
+   * 加载数据字典
+   */
+  const loadDataDictList = async () => {
+    if (isLoaded) {
+      return dataDict.value;
+    }
+
+    // 应用启动和页面初始化可能同时触发,复用同一个请求,避免重复加载。
+    if (loadingPromise) {
+      return loadingPromise;
+    }
+
+    loadingPromise = getList()
+      .then(response => {
+        if (Array.isArray(response?.data)) {
+          dataDict.value = response.data;
+          map.clear();
+          isLoaded = true;
+        }
+        return dataDict.value;
+      })
+      .catch(() => dataDict.value)
+      .finally(() => {
+        loadingPromise = null;
+      });
+
+    return loadingPromise;
+  };
   /**
    * 获取type对应字典列表
    * @param type

+ 76 - 48
utils/hot-update.js

@@ -1,27 +1,30 @@
 import config from "@/utils/config";
 
 /**
- * 这里用本地存储做一个非常轻量的“启动期热更新锁”。
- *
- * 为什么不用纯内存变量:
- * 1. App.vue 的 onLaunch 和页面组件 mounted 不在同一个文件里,直接共享内存状态不直观。
- * 2. 现有项目已经在登录页、首页挂了旧的整包升级弹窗组件,我们需要一个跨文件、最小改动的互斥标记。
- * 3. 用 storage 的好处是组件侧只要读一个 key 就能知道“启动阶段是否已经有热更新流程在跑”。
- *
- * 注意:
- * 这个锁只是为了解决“启动期并发弹窗”的问题,不是分布式锁,也不是强一致锁。
- * 对当前这个 uni-app 项目来说,这样的复杂度已经足够,而且侵入性最低。
+ * 旧版本使用过的持久化锁 key,仅用于启动时清理遗留值。
+ * 当前版本的互斥状态由模块内 hotUpdatePromise 管理,不会跨进程形成死锁。
  */
 export const HOT_UPDATE_LOCK_KEY = "__APP_WGT_HOT_UPDATE_RUNNING__";
 export const HOT_UPDATE_FALLBACK_APP_CHECK_EVENT =
   "__APP_WGT_HOT_UPDATE_FALLBACK_APP_CHECK__";
+const HOT_UPDATE_FALLBACK_PENDING_KEY =
+  "__APP_WGT_HOT_UPDATE_FALLBACK_PENDING__";
+
+/**
+ * 页面 onShow、App onShow 和路由切换可能在很短时间内连续触发。
+ * 默认三十秒只向服务端检查一次;显式传入 forceCheck 时可用于用户手动检查。
+ */
+export const HOT_UPDATE_CHECK_INTERVAL = 30 * 1000;
+
+let hotUpdatePromise = null;
+let lastCheckStartedAt = 0;
 
 /**
  * 当用户点击“稍后再说”时,我们把当前版本记下来。
  *
  * 这样做的原因:
- * 1. 热更新检查放在 onLaunch,意味着每次冷启动都会检查一次。
- * 2. 如果用户已经明确拒绝了某个版本,下一次冷启动继续弹同一个版本,体验会比较差。
+ * 1. 热更新会在冷启动、回到前台和页面显示时检查。
+ * 2. 如果用户已经明确拒绝了某个版本,后续页面切换继续弹同一个版本,体验会比较差。
  * 3. 只记录“被跳过的目标版本”,新版本来了仍然会重新提示。
  */
 const HOT_UPDATE_SKIP_VERSION_KEY = "__APP_WGT_HOT_UPDATE_SKIP_VERSION__";
@@ -39,33 +42,39 @@ const HOT_UPDATE_API_URL =
   config.default.apiUrl + config.default.apiUrlSuffix + "/rq/iot-app/newWgt";
 
 /**
- * 读取启动期热更新锁。
+ * 读取当前进程的热更新运行状态。
  *
  * 对页面组件的意义:
- * 旧的整包升级逻辑挂在页面里,页面 mounted 时如果发现启动热更新还在跑,
+ * 旧的整包升级逻辑挂在页面里,页面 mounted 时如果发现热更新还在跑,
  * 就应该先让路,避免同时弹两个升级框。
  */
 export const isHotUpdateRunning = () => {
-  return uni.getStorageSync(HOT_UPDATE_LOCK_KEY) === "1";
+  return Boolean(hotUpdatePromise);
 };
 
 /**
- * 设置/清理热更新锁。
- *
- * 输入:
- * - running: Boolean,true 表示流程开始,false 表示流程结束。
- *
- * 输出:
- * - 无返回值,副作用是修改 storage 中的互斥标记。
+ * 旧版本把运行锁写进了持久化 storage。如果 App 在下载或安装过程中被系统
+ * 强杀,这个值不会进入 finally 清理,后续每次启动都会永久跳过更新。
+ * 新实现使用模块内 Promise 作为进程锁;这里仅清理由旧版本遗留的死锁。
  */
-const setHotUpdateRunning = (running) => {
-  if (running) {
-    uni.setStorageSync(HOT_UPDATE_LOCK_KEY, "1");
-  } else {
+const clearLegacyHotUpdateLock = () => {
+  if (uni.getStorageSync(HOT_UPDATE_LOCK_KEY)) {
     uni.removeStorageSync(HOT_UPDATE_LOCK_KEY);
   }
 };
 
+/**
+ * 整包回退事件可能早于 upgrade 组件创建。额外保存一次待处理标记,组件挂载后
+ * 仍能消费它,避免事件丢失。
+ */
+export const consumePendingAppUpgradeCheck = () => {
+  const pending = uni.getStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY) === "1";
+  if (pending) {
+    uni.removeStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY);
+  }
+  return pending;
+};
+
 /**
  * 等待 plus 对象可用。
  *
@@ -431,6 +440,7 @@ const confirmHotUpdate = (payload) => {
  * - resolve(tempFilePath) 返回下载到本地后的临时文件路径。
  */
 const downloadWgtPackage = (wgtUrl) => {
+  console.log("wgtUrl :>> ", wgtUrl);
   return new Promise((resolve, reject) => {
     uni.showLoading({
       title: "下载更新中",
@@ -507,9 +517,7 @@ const installWgtPackage = (filePath, appid) => {
  * 2. 这一步和旧的页面级整包升级不同,旧逻辑 Android 是直接安装 apk。
  * 3. 热更新的目标是“尽快让新前端资源生效”,因此安装完成后立即重启是最直接的方案。
  *
- * 注意这里在 restart 前先清掉锁:
- * - 因为 storage 会跨重启保留。
- * - 如果不先清,应用重启后旧组件会误以为“热更新一直在运行中”,造成后续检查失效。
+ * 运行锁现在是进程内 Promise;应用重启会自然释放,不再依赖持久化 storage 清锁。
  */
 const restartAppAfterInstall = (targetVersion) => {
   return new Promise((resolve) => {
@@ -518,7 +526,6 @@ const restartAppAfterInstall = (targetVersion) => {
       content: `资源版本 ${targetVersion} 已安装完成,点击确定后将立即重启应用使更新生效。`,
       showCancel: false,
       success: () => {
-        setHotUpdateRunning(false);
         plus.runtime.restart();
         resolve();
       },
@@ -530,7 +537,6 @@ const restartAppAfterInstall = (targetVersion) => {
          * - 到这里说明安装已经完成。
          * - 如果不重启,用户还会继续运行旧资源,和“安装成功”的状态不一致。
          */
-        setHotUpdateRunning(false);
         plus.runtime.restart();
         resolve();
       },
@@ -555,7 +561,7 @@ const showHotUpdateError = (error, title = "更新失败") => {
 };
 
 /**
- * 对外暴露的启动期热更新入口。
+ * 内部热更新执行流程,由公开入口负责并发合并和调用频率控制。
  *
  * 这是你后续在 App.vue onLaunch 里真正调用的函数。
  *
@@ -573,18 +579,10 @@ const showHotUpdateError = (error, title = "更新失败") => {
  * - Promise<{ status: string, ... }>
  *   主要用于日志或后续扩展,目前 App.vue 不需要依赖它的返回值。
  */
-export const runWgtHotUpdate = async () => {
+const executeWgtHotUpdate = async () => {
   // #ifdef APP-PLUS
-  if (isHotUpdateRunning()) {
-    return {
-      status: "locked",
-      message: "已有热更新流程在执行,跳过重复检查",
-    };
-  }
-
   let currentStage = "prepare";
   let finalResult = null;
-  setHotUpdateRunning(true);
 
   try {
     currentStage = "plus-ready";
@@ -655,14 +653,8 @@ export const runWgtHotUpdate = async () => {
     };
     return finalResult;
   } finally {
-    /**
-     * 正常情况下:
-     * - 无更新 / 用户取消 / 下载失败 / 安装失败,都会走到这里清锁。
-     * - 安装成功后会在 restart 之前先清锁,所以这里再次清理也没有副作用。
-     */
-    setHotUpdateRunning(false);
-
     if (finalResult?.status === "fallback-app-check-version") {
+      uni.setStorageSync(HOT_UPDATE_FALLBACK_PENDING_KEY, "1");
       uni.$emit(HOT_UPDATE_FALLBACK_APP_CHECK_EVENT, finalResult);
     }
   }
@@ -673,3 +665,39 @@ export const runWgtHotUpdate = async () => {
     message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查",
   };
 };
+
+/**
+ * 在任意页面均可调用的热更新入口。
+ *
+ * - 同一时刻只执行一个检查/下载/安装流程。
+ * - 自动触发默认受三十秒检查间隔限制。
+ * - 用户主动点击“检查更新”时传 { forceCheck: true } 可忽略时间间隔。
+ */
+export const runWgtHotUpdate = ({ forceCheck = false } = {}) => {
+  // #ifdef APP-PLUS
+  clearLegacyHotUpdateLock();
+
+  if (hotUpdatePromise) {
+    return hotUpdatePromise;
+  }
+
+  const now = Date.now();
+  if (!forceCheck && now - lastCheckStartedAt < HOT_UPDATE_CHECK_INTERVAL) {
+    return Promise.resolve({
+      status: "throttled",
+      message: "距离上次热更新检查时间较短,跳过重复检查",
+    });
+  }
+
+  lastCheckStartedAt = now;
+  hotUpdatePromise = executeWgtHotUpdate().finally(() => {
+    hotUpdatePromise = null;
+  });
+  return hotUpdatePromise;
+  // #endif
+
+  return Promise.resolve({
+    status: "skip-non-app-plus",
+    message: "当前不是 APP-PLUS 环境,跳过 .wgt 热更新检查",
+  });
+};