Просмотр исходного кода

feat(swh): 新增生产运营双周会填报与汇总功能

- 新增双周会数据填报、编辑、查看及汇总页面
- 新增双周会接口封装和页面路由
- 增加填报与汇总入口选择弹窗
- 支持经营数据及工作量完成情况汇总
- 优化真机滚动、页面间距和安全区域适配
- 支持长文本换行及数值固定保留两位小数
Zimo 1 месяц назад
Родитель
Сommit
5ba28768f1
7 измененных файлов с 1153 добавлено и 6 удалено
  1. 31 0
      api/operationMeeting.js
  2. 27 3
      pages.json
  3. 75 3
      pages/entry/index.vue
  4. 542 0
      pages/swh/form.vue
  5. 167 0
      pages/swh/index.vue
  6. 245 0
      pages/swh/summary-detail.vue
  7. 66 0
      pages/swh/summary.vue

+ 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" });

+ 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;

+ 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>