Parcourir la source

事故事件详情

yanghao il y a 2 jours
Parent
commit
090622a6a9

+ 3 - 0
src/api/pms/device/index.ts

@@ -143,6 +143,9 @@ export const IotDeviceApi = {
   createIotDevice: async (data: IotDeviceVO) => {
     return await request.post({ url: `/rq/iot-device/create`, data })
   },
+  createLyDevice: async (data: any) => {
+    return await request.post({ url: `/rq/ly-device/create`, data })
+  },
 
   // 保存 设备-状态 的关联关系
   saveDeviceStatuses: async (data: any) => {

+ 7 - 0
src/api/pms/meeting/index.ts

@@ -38,6 +38,13 @@ export const OperationMeetingApi = {
     return await request.get({ url: `/pms/iot-operation-meeting/cachedProjects` })
   },
 
+  getCurrentCompanyExtProperties: async () => {
+    return await request.get({
+      url: `/pms/iot-operation-meeting/currentCompanyExtProperties`,
+      hideErrorNotification: true
+    })
+  },
+
   getPreviousWorkPlan: async (params: {
     projectName: string
     id?: string | number

+ 15 - 0
src/api/pms/stat/index.ts

@@ -120,6 +120,21 @@ export const IotStatApi = {
     })
   },
 
+  getDeviceInfoChartly: async (deviceCode: any, identifier: any, begin: string, end: string) => {
+    return await request.get({
+      url:
+        `/rq/stat/td/chart/ly/` +
+        deviceCode +
+        '/' +
+        identifier +
+        '?beginTime=' +
+        begin +
+        '&endTime=' +
+        end,
+      signal: globalController.signal
+    })
+  },
+
   getDeviceCount: async (params?: any) => {
     return await request.get({ url: `/rq/stat/home/device/count/` + params })
   },

+ 4 - 1
src/config/axios/service.ts

@@ -28,6 +28,9 @@ let isRefreshToken = false
 // 请求白名单,无须token的接口
 const whiteList: string[] = ['/login', '/refresh-token']
 
+const shouldHideErrorNotification = (config?: InternalAxiosRequestConfig | AxiosError['config']) =>
+  Boolean((config as Record<string, unknown> | undefined)?.hideErrorNotification)
+
 // 创建axios实例
 const service: AxiosInstance = axios.create({
   baseURL: base_url, // api 的 base_url
@@ -185,7 +188,7 @@ service.interceptors.response.use(
         // hard coding:忽略这个提示,直接登出
         console.log(msg)
         return handleAuthorized()
-      } else {
+      } else if (!shouldHideErrorNotification(config)) {
         ElNotification.error({ title: msg })
       }
       return Promise.reject('error')

+ 263 - 8
src/views/oli-connection/monitoring-board/chart.vue

@@ -1,6 +1,7 @@
 <script lang="ts" setup>
 import { IotDeviceApi } from '@/api/pms/device'
 import { cancelAllRequests, IotStatApi } from '@/api/pms/stat'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { useMqtt } from '@/utils/useMqtt'
 import {
   Dimensions,
@@ -8,9 +9,11 @@ import {
   IotTdItem,
   mapToDimension,
   normalizeRangeValue,
+  RangeSettingDraft,
   withDisplayStyle
 } from '@/utils/useSocketBus'
 import { useDebounceFn } from '@vueuse/core'
+import { Setting } from '@element-plus/icons-vue'
 import dayjs from 'dayjs'
 import * as echarts from 'echarts'
 
@@ -27,6 +30,10 @@ const props = defineProps({
     type: String,
     required: true
   },
+  otherName: {
+    type: String,
+    default: ''
+  },
   mqttUrl: {
     type: String,
     required: true
@@ -65,8 +72,24 @@ const props = defineProps({
   }
 })
 
+const emit = defineEmits<{
+  (event: 'other-name-updated', payload: { id: number; otherName: string }): void
+}>()
+
 const dimensions = ref<Dimensions[]>([])
 const selectedDimension = ref<Record<string, boolean>>({})
+const message = useMessage()
+
+const currentOtherName = ref(props.otherName)
+
+const displayDeviceName = computed(() => currentOtherName.value || props.deviceName)
+
+watch(
+  () => props.otherName,
+  (value) => {
+    currentOtherName.value = value
+  }
+)
 
 const { connect, destroy, isConnected, subscribe } = useMqtt()
 
@@ -603,7 +626,7 @@ async function initLoadChartData(real_time: boolean = true) {
       dimensions.value.map(async (item) => {
         item.response = true
         try {
-          const res = await IotStatApi.getDeviceInfoChart(
+          const res = await IotStatApi.getDeviceInfoChartly(
             props.deviceCode,
             item.identifier,
             props.date[0],
@@ -696,7 +719,7 @@ function handleDetailClick() {
       ifInline: props.ifInline,
       carOnline: props.carOnline,
       time: props.lastInlineTime,
-      name: props.deviceName,
+      name: displayDeviceName.value,
       code: props.deviceCode,
       dept: props.deptName,
       vehicle: props.vehicleName,
@@ -704,19 +727,148 @@ function handleDetailClick() {
     }
   })
 }
+
+const { ZmTable, ZmTableColumn } = useTableComponents<RangeSettingDraft>()
+const settingsDialogVisible = ref(false)
+const settingsSaving = ref(false)
+const deviceNameDraft = ref('')
+const rangeSettingDrafts = ref<RangeSettingDraft[]>([])
+
+function buildRangeSettingDrafts() {
+  rangeSettingDrafts.value = dimensions.value.map((item) => ({
+    name: item.name,
+    identifier: item.identifier,
+    color: item.color,
+    minValue: normalizeRangeValue(item.minValue),
+    maxValue: normalizeRangeValue(item.maxValue)
+  }))
+}
+
+function openSettingsDialog() {
+  deviceNameDraft.value = currentOtherName.value || props.deviceName
+  buildRangeSettingDrafts()
+  settingsDialogVisible.value = true
+}
+
+async function saveDeviceName() {
+  const nextName = deviceNameDraft.value.trim()
+
+  if (!nextName) {
+    throw new Error('设备名称不能为空')
+  }
+
+  if (nextName === (currentOtherName.value || props.deviceName)) return
+
+  await IotDeviceApi.createLyDevice({
+    deviceId: props.id,
+    deviceCode: props.deviceCode,
+    deviceName: props.deviceName,
+    otherName: nextName
+  })
+
+  currentOtherName.value = nextName
+  emit('other-name-updated', {
+    id: props.id,
+    otherName: nextName
+  })
+}
+
+async function saveRangeSetting(item: Dimensions, minValue?: number, maxValue?: number) {
+  const min = normalizeRangeValue(minValue)
+  const max = normalizeRangeValue(maxValue)
+
+  if (min === undefined && max === undefined) {
+    if (item.id) {
+      await IotDeviceApi.deleteMaxMin({ id: item.id })
+    }
+
+    item.minValue = undefined
+    item.maxValue = undefined
+    item.id = undefined
+    return
+  }
+
+  if (min === undefined || max === undefined) {
+    throw new Error(`${item.name} 的最大值和最小值需要同时填写`)
+  }
+
+  if (min > max) {
+    throw new Error(`${item.name} 的最小值不能大于最大值`)
+  }
+
+  if (min === max) {
+    throw new Error(`${item.name} 的最大值和最小值不能相等`)
+  }
+
+  const body = {
+    minValue: min,
+    maxValue: max,
+    deviceId: props.id,
+    propertyCode: item.identifier,
+    alarmProperty: item.name,
+    deviceName: props.deviceName,
+    id: item.id
+  }
+
+  const res = await IotDeviceApi.saveMaxMin(body)
+
+  if (res.id) item.id = res.id
+  item.minValue = min
+  item.maxValue = max
+}
+
+async function handleSettingsSave() {
+  settingsSaving.value = true
+  try {
+    await saveDeviceName()
+
+    for (const draft of rangeSettingDrafts.value) {
+      const item = dimensions.value.find((dimension) => dimension.identifier === draft.identifier)
+      if (!item) continue
+
+      await saveRangeSetting(item, draft.minValue, draft.maxValue)
+    }
+
+    updateSeriesByNames(dimensions.value.map((item) => item.name))
+    settingsDialogVisible.value = false
+    message.success('设置成功')
+  } catch (error: any) {
+    message.warning(error?.message || '设置失败')
+  } finally {
+    settingsSaving.value = false
+  }
+}
+
+function handleRangeDialogReset() {
+  rangeSettingDrafts.value = rangeSettingDrafts.value.map((item) => ({
+    ...item,
+    minValue: undefined,
+    maxValue: undefined
+  }))
+}
+
+function clearRangeDraft(row: RangeSettingDraft) {
+  row.minValue = undefined
+  row.maxValue = undefined
+}
 </script>
 <template>
   <div class="rounded-lg chart-container flex flex-col">
     <header class="chart-header justify-between">
       <div class="flex items-center">
         <div class="title-icon"></div>
-        <div>{{ `${props.deviceCode}-${props.deviceName}` }}</div>
+        <div>{{ `${props.deviceCode}-${displayDeviceName}` }}</div>
+      </div>
+      <div class="chart-header__actions">
+        <el-button link type="primary" :icon="Setting" @click.stop="openSettingsDialog">
+          设置
+        </el-button>
+        <el-button link type="primary" class="group" @click.stop="handleDetailClick">
+          详情
+          <div
+            class="i-material-symbols:arrow-right-alt-rounded size-4 transition-transform group-hover:translate-x-1"></div>
+        </el-button>
       </div>
-      <el-button link type="primary" class="group" @click="handleDetailClick">
-        详情
-        <div
-          class="i-material-symbols:arrow-right-alt-rounded size-4 transition-transform group-hover:translate-x-1"></div>
-      </el-button>
     </header>
     <main class="chart-main" v-loading="chartLoading" element-loading-background="transparent">
       <div class="chart-legend">
@@ -744,6 +896,87 @@ function handleDetailClick() {
         <div ref="chartRef" class="chart-canvas"></div>
       </div>
     </main>
+
+    <el-dialog
+      v-model="settingsDialogVisible"
+      title="设备设置"
+      width="900px"
+      class="board-settings-dialog"
+      modal-class="board-settings-overlay"
+      append-to-body
+      destroy-on-close>
+      <el-form label-width="90px" class="board-settings-form">
+        <el-form-item label="设备名称" required>
+          <el-input
+            v-model="deviceNameDraft"
+            maxlength="50"
+            show-word-limit
+            placeholder="请输入设备名称" />
+        </el-form-item>
+      </el-form>
+
+      <div class="board-settings-section">
+        <div class="board-settings-section__title">曲线量程设置</div>
+        <ZmTable :data="rangeSettingDrafts" :loading="false" :max-height="420" :show-border="true">
+          <ZmTableColumn label="曲线" min-width="180">
+            <template #default="{ row }">
+              <div class="flex items-center gap-2 min-w-0">
+                <span
+                  class="inline-block size-2.5 rounded-full shrink-0"
+                  :style="{ backgroundColor: row.color }"></span>
+                <span class="board-settings-line-name">{{ row.name }}</span>
+              </div>
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="最小值" width="180">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.minValue"
+                class="!w-full"
+                :controls="false"
+                :placeholder="formatChartValue(getSeriesRange(row.name).labelMin)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="最大值" width="180">
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.maxValue"
+                class="!w-full"
+                :controls="false"
+                :placeholder="formatChartValue(getSeriesRange(row.name).labelMax)" />
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="当前使用范围" width="170">
+            <template #default="{ row }">
+              <span class="board-settings-range-text">
+                {{ formatChartValue(getSeriesRange(row.name).labelMin) }}
+                -
+                {{ formatChartValue(getSeriesRange(row.name).labelMax) }}
+              </span>
+            </template>
+          </ZmTableColumn>
+          <ZmTableColumn label="操作" width="90">
+            <template #default="{ row }">
+              <el-button size="small" link type="primary" @click="clearRangeDraft(row)">
+                清空
+              </el-button>
+            </template>
+          </ZmTableColumn>
+        </ZmTable>
+      </div>
+
+      <template #footer>
+        <div class="flex justify-between">
+          <el-button @click="handleRangeDialogReset">全部清空</el-button>
+          <div class="flex gap-2">
+            <el-button @click="settingsDialogVisible = false">取消</el-button>
+            <el-button type="primary" :loading="settingsSaving" @click="handleSettingsSave">
+              保存
+            </el-button>
+          </div>
+        </div>
+      </template>
+    </el-dialog>
   </div>
 </template>
 <style scoped>
@@ -791,6 +1024,13 @@ function handleDetailClick() {
   align-items: center;
 }
 
+.chart-header__actions {
+  display: flex;
+  flex-shrink: 0;
+  gap: 10px;
+  align-items: center;
+}
+
 .title-icon {
   width: 4px;
   height: 16px;
@@ -799,6 +1039,21 @@ function handleDetailClick() {
   box-shadow: 0 0 8px #22d3ee;
 }
 
+.board-settings-form {
+  padding-right: 16px;
+}
+
+.board-settings-section {
+  margin-top: 14px;
+}
+
+.board-settings-section__title {
+  margin-bottom: 10px;
+  font-size: 14px;
+  font-weight: 700;
+  color: #334155;
+}
+
 .chart-main {
   display: grid;
   grid-template-columns: auto minmax(0, 1fr);

+ 37 - 7
src/views/oli-connection/monitoring-board/index.vue

@@ -20,6 +20,7 @@ interface DeviceData {
   lastInlineTime: string
   deviceCode: string
   deviceName: string
+  otherName: string
   deptName: string
   vehicleName: string
   carOnline: string
@@ -95,6 +96,14 @@ async function loadDeptOptions() {
 const deviceLoading = ref(false)
 const deviceOptions = ref<any[]>([])
 
+function getDeviceDisplayName(device: { deviceName?: string; otherName?: string }) {
+  return device.otherName || device.deviceName || ''
+}
+
+function getDeviceLabel(device: { deviceCode?: string; deviceName?: string; otherName?: string }) {
+  return `${device.deviceCode || ''}-${getDeviceDisplayName(device)}`
+}
+
 async function loadDeviceOptions() {
   if (!deviceQuery.value.deptId) return
 
@@ -111,7 +120,7 @@ async function loadDeviceOptions() {
     //   pageSize: 100
     // })
     deviceOptions.value = data.list.map((item: any) => ({
-      label: item.deviceCode + '-' + item.deviceName,
+      label: getDeviceLabel(item),
       value: item.id,
       raw: item
     }))
@@ -211,6 +220,7 @@ async function handleDeviceChange(selectedIds: number[]) {
         carOnline: option.raw.carOnline ?? '',
         deviceCode: option.raw.deviceCode,
         deviceName: option.raw.deviceName,
+        otherName: option.raw.otherName ?? '',
         mqttUrl: option.raw.mqttUrl
       })
     }
@@ -269,6 +279,19 @@ function handleTimeChange() {
   isRealTime.value = false
 }
 
+function handleOtherNameUpdated(payload: { id: number; otherName: string }) {
+  const device = deviceList.value.find((item) => item.id === payload.id)
+  if (device) {
+    device.otherName = payload.otherName
+  }
+
+  const option = deviceOptions.value.find((item) => item.value === payload.id)
+  if (option) {
+    option.raw.otherName = payload.otherName
+    option.label = getDeviceLabel(option.raw)
+  }
+}
+
 const token = ref('')
 const showSearchDialog = ref(false)
 
@@ -340,7 +363,9 @@ onMounted(() => {
       <div class="monitor-board-toolbar">
         <div class="monitor-board-status">
           <span>当前第 {{ pageIndex + 1 }} 组 / 共 {{ totalPages }} 组</span>
-          <span v-if="mainCard">主看板:{{ mainCard.deviceCode }}-{{ mainCard.deviceName }}</span>
+          <span v-if="mainCard">
+            主看板:{{ mainCard.deviceCode }}-{{ getDeviceDisplayName(mainCard) }}
+          </span>
         </div>
         <div class="flex items-center gap-3">
           <el-button class="custom-btn reset-btn" :disabled="pageIndex === 0" @click="goPrevGroup">
@@ -366,7 +391,8 @@ onMounted(() => {
               v-bind="sideCards[0]"
               :date="query.time"
               :is-real-time="isRealTime"
-              :token="token" />
+              :token="token"
+              @other-name-updated="handleOtherNameUpdated" />
           </div>
 
           <div
@@ -378,7 +404,8 @@ onMounted(() => {
               v-bind="sideCards[1]"
               :date="query.time"
               :is-real-time="isRealTime"
-              :token="token" />
+              :token="token"
+              @other-name-updated="handleOtherNameUpdated" />
           </div>
 
           <div class="monitor-card-shell monitor-card-main" @click="setMainCard(mainCard)">
@@ -387,7 +414,8 @@ onMounted(() => {
               v-bind="mainCard"
               :date="query.time"
               :is-real-time="isRealTime"
-              :token="token" />
+              :token="token"
+              @other-name-updated="handleOtherNameUpdated" />
           </div>
 
           <div
@@ -399,7 +427,8 @@ onMounted(() => {
               v-bind="sideCards[2]"
               :date="query.time"
               :is-real-time="isRealTime"
-              :token="token" />
+              :token="token"
+              @other-name-updated="handleOtherNameUpdated" />
           </div>
 
           <div
@@ -411,7 +440,8 @@ onMounted(() => {
               v-bind="sideCards[3]"
               :date="query.time"
               :is-real-time="isRealTime"
-              :token="token" />
+              :token="token"
+              @other-name-updated="handleOtherNameUpdated" />
           </div>
         </div>
       </div>

+ 0 - 778
src/views/oli-connection/monitoring/detail copy.vue

@@ -1,778 +0,0 @@
-<script setup lang="ts">
-import { Odometer, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
-import { IotDeviceApi } from '@/api/pms/device'
-import dayjs from 'dayjs'
-import { rangeShortcuts } from '@/utils/formatTime'
-import { IotStatApi, cancelAllRequests } from '@/api/pms/stat'
-
-import * as echarts from 'echarts'
-import { colors } from '@/utils/td-color'
-import { useSocketBus } from '@/utils/useSocketBus'
-
-const { query } = useRoute()
-
-const data = ref({
-  deviceCode: query.code || '',
-  deviceName: query.name || '',
-  lastInlineTime: query.time || '',
-  ifInline: query.ifInline || '',
-  dept: query.dept || '',
-  vehicle: query.vehicle || '',
-  carOnline: query.carOnline || ''
-})
-
-const { open: connect, onAny, close } = useSocketBus(data.value.deviceCode as string)
-
-onAny((msg) => {
-  if (!Array.isArray(msg) || msg.length === 0) return
-
-  const valueMap = new Map<string, number>()
-
-  for (const item of msg) {
-    const { identity, modelName, readTime, logValue } = item
-
-    const value = logValue ? Number(logValue) : 0
-
-    if (identity) {
-      valueMap.set(identity, value)
-    }
-
-    if (modelName && chartData.value[modelName]) {
-      chartData.value[modelName].push({
-        ts: dayjs(readTime).valueOf(),
-        value
-      })
-
-      updateSingleSeries(modelName)
-    }
-  }
-
-  const updateDimensions = (list) => {
-    list.forEach((item) => {
-      const v = valueMap.get(item.identifier)
-      if (v !== undefined) {
-        item.value = v
-      }
-    })
-  }
-
-  updateDimensions(dimensions.value)
-  updateDimensions(gatewayDimensions.value)
-  updateDimensions(carDimensions.value)
-
-  // 3️⃣ 统一一次调用
-  genderIntervalArr()
-})
-
-interface Dimensions {
-  identifier: string
-  name: string
-  value: string
-  color?: string
-  response?: boolean
-}
-
-const dimensions = ref<Dimensions[]>([])
-const gatewayDimensions = ref<Dimensions[]>([])
-const carDimensions = ref<Dimensions[]>([])
-
-const disabledDimensions = ref<string[]>(['online', 'vehicle_name'])
-
-interface SelectedDimension {
-  [key: Dimensions['name']]: boolean
-}
-
-const selectedDimension = ref<SelectedDimension>({})
-
-const dimensionLoading = ref(false)
-
-const disabledDimension = computed(() => (identifier: string) => {
-  const response = dimensions.value.find((item) => item.identifier === identifier)?.response
-
-  return { disabled: disabledDimensions.value.includes(identifier) || response, loading: response }
-})
-
-async function loadDimensions() {
-  if (!query.id) return
-
-  dimensionLoading.value = true
-
-  const gateway = (((await IotDeviceApi.getIotDeviceTds(Number(query.id))) as any[]) ?? [])
-    .sort((a, b) => b.modelOrder - a.modelOrder)
-    .map((item) => ({
-      identifier: item.identifier,
-      name: item.modelName,
-      value: item.value
-    }))
-  const car = (((await IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))) as any[]) ?? [])
-    .sort((a, b) => b.modelOrder - a.modelOrder)
-    .map((item) => ({
-      identifier: item.identifier,
-      name: item.modelName,
-      value: item.value
-    }))
-
-  dimensions.value = [...gateway, ...car]
-    .filter((item) => !disabledDimensions.value.includes(item.identifier))
-    .map((item, index) => ({
-      ...item,
-      color: colors[index]
-    }))
-
-  gatewayDimensions.value = gateway
-  carDimensions.value = car
-
-  selectedDimension.value = Object.fromEntries(dimensions.value.map((item) => [item.name, false]))
-
-  selectedDimension.value[dimensions.value[0].name] = true
-
-  dimensionLoading.value = false
-}
-
-// async function updateDimensionValues() {
-//   if (!query.id) return
-
-//   try {
-//     // 1. 并行获取最新数据
-//     const [gatewayRes, carRes] = await Promise.all([
-//       IotDeviceApi.getIotDeviceTds(Number(query.id)),
-//       IotDeviceApi.getIotDeviceZHBDTds(Number(query.id))
-//     ])
-
-//     // 2. 创建一个 Map 用于快速查找 (Identifier -> Value)
-//     // 这样可以将复杂度从 O(N*M) 降低到 O(N)
-//     const newValueMap = new Map<string, any>()
-
-//     const addToMap = (data: any[]) => {
-//       if (!data) return
-//       data.forEach((item) => {
-//         if (item.identifier) {
-//           newValueMap.set(item.identifier, item.value)
-//         }
-//       })
-//     }
-
-//     addToMap(gatewayRes as any[])
-//     addToMap(carRes as any[])
-
-//     // 3. 更新 dimensions.value (保留了之前的 color 和其他属性)
-//     dimensions.value.forEach((item) => {
-//       if (newValueMap.has(item.identifier)) {
-//         item.value = newValueMap.get(item.identifier)
-//       }
-//     })
-
-//     // 4. 如果还需要同步更新 gatewayDimensions 和 carDimensions
-//     // (假设这些是引用类型,如果它们引用的是同一个对象,上面更新 dimensions 时可能已经同步了。
-//     // 如果它们是独立的对象数组,则需要显式更新)
-
-//     // 更新 Gateway 原始列表
-//     gatewayDimensions.value.forEach((item) => {
-//       if (newValueMap.has(item.identifier)) {
-//         item.value = newValueMap.get(item.identifier)
-//       }
-//     })
-
-//     // 更新 Car 原始列表
-//     carDimensions.value.forEach((item) => {
-//       if (newValueMap.has(item.identifier)) {
-//         item.value = newValueMap.get(item.identifier)
-//       }
-//     })
-//   } catch (error) {
-//     console.error('Failed to update dimension values:', error)
-//   }
-// }
-
-const selectedDate = ref<string[]>([
-  dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
-  dayjs().format('YYYY-MM-DD HH:mm:ss')
-])
-
-interface ChartData {
-  [key: Dimensions['name']]: { ts: number; value: number }[]
-}
-
-const chartData = ref<ChartData>({})
-
-let intervalArr = ref<number[]>([])
-let maxInterval = ref(0)
-let minInterval = ref(0)
-
-const chartRef = ref<HTMLDivElement | null>(null)
-let chart: echarts.ECharts | null = null
-
-// const genderIntervalArrDebounce = useDebounceFn(
-//   (init: boolean = false) => genderIntervalArr(init),
-//   300
-// )
-
-function genderIntervalArr(init: boolean = false) {
-  const values: number[] = []
-
-  for (const [key, value] of Object.entries(selectedDimension.value)) {
-    if (value) {
-      values.push(...(chartData.value[key]?.map((item) => item.value) ?? []))
-    }
-  }
-
-  const maxVal = values.length === 0 ? 10000 : Math.max(...values)
-  const minVal = values.length === 0 ? 0 : Math.min(...values) > 0 ? 0 : Math.min(...values)
-
-  const maxDigits = (Math.floor(maxVal) + '').length
-  const minDigits = minVal === 0 ? 0 : (Math.floor(Math.abs(minVal)) + '').length
-
-  const interval = Math.max(maxDigits, minDigits)
-
-  maxInterval.value = interval
-  minInterval.value = minDigits
-
-  intervalArr.value = [0]
-  for (let i = 1; i <= interval; i++) {
-    intervalArr.value.push(Math.pow(10, i))
-  }
-
-  if (!init) {
-    chart?.setOption({
-      yAxis: {
-        min: -minInterval.value,
-        max: maxInterval.value
-      }
-    })
-  }
-}
-
-function chartInit() {
-  if (!chart) return
-
-  chart.on('legendselectchanged', (params: any) => {
-    selectedDimension.value = params.selected
-  })
-
-  window.addEventListener('resize', () => {
-    if (chart) chart.resize()
-  })
-}
-
-function render() {
-  if (!chartRef.value) return
-
-  if (!chart) chart = echarts.init(chartRef.value, undefined, { renderer: 'canvas' })
-
-  chartInit()
-
-  genderIntervalArr(true)
-
-  chart.setOption({
-    animation: true,
-    animationDuration: 200,
-    animationEasing: 'linear',
-    animationDurationUpdate: 200,
-    animationEasingUpdate: 'linear',
-    grid: {
-      left: '6%',
-      top: '5%',
-      right: '6%',
-      bottom: '12%'
-    },
-    tooltip: {
-      trigger: 'axis',
-      axisPointer: {
-        type: 'line'
-      },
-      formatter: (params) => {
-        let d = `${params[0].axisValueLabel}<br>`
-        const exist: string[] = []
-        params = params.filter((el) => {
-          if (exist.includes(el.seriesName)) return false
-          exist.push(el.seriesName)
-          return true
-        })
-        let item = params.map(
-          (el) => `<div class="flex items-center justify-between mt-1">
-            <span>${el.marker} ${el.seriesName}</span>
-            <span>${el.value[2]?.toFixed(2)}</span>
-          </div>`
-        )
-
-        return d + item.join('')
-      }
-    },
-    xAxis: {
-      type: 'time',
-      axisLabel: {
-        formatter: (v) => dayjs(v).format('YYYY-MM-DD\nHH:mm:ss'),
-        rotate: 0,
-        align: 'left'
-      }
-    },
-    dataZoom: [
-      { type: 'inside', xAxisIndex: 0 },
-      { type: 'slider', xAxisIndex: 0 }
-    ],
-    yAxis: {
-      type: 'value',
-      min: -minInterval.value,
-      max: maxInterval.value,
-      interval: 1,
-      axisLabel: {
-        formatter: (v) => {
-          const num = v === 0 ? 0 : v > 0 ? Math.pow(10, v) : -Math.pow(10, -v)
-
-          return num.toLocaleString()
-        }
-      },
-      show: false
-    },
-    legend: {
-      data: dimensions.value.map((item) => item.name),
-      selected: selectedDimension.value,
-      show: false
-    },
-    // series: dimensions.value.map((item) => ({
-    //   name: item.name,
-    //   type: 'line',
-    //   smooth: true,
-    //   showSymbol: false,
-    //   color: item.color,
-    //   data: [] // 占位数组
-    // }))
-    series: dimensions.value.map((item) => ({
-      name: item.name,
-      type: 'line',
-
-      smooth: 0.2,
-
-      showSymbol: false,
-
-      endLabel: {
-        show: true,
-        formatter: (params) => params.value[2]?.toFixed(2),
-        offset: [6, 0],
-        color: item.color,
-        fontSize: 12
-      },
-
-      emphasis: {
-        focus: 'series'
-      },
-
-      lineStyle: {
-        width: 2
-      },
-
-      color: item.color,
-      data: [] // 占位数组
-    }))
-  })
-}
-
-function mapData({ value, ts }) {
-  if (!value) return [ts, 0, 0]
-
-  const isPositive = value > 0
-  const absItem = Math.abs(value)
-
-  const min_value = Math.max(...intervalArr.value.filter((v) => v <= absItem))
-  const min_index = intervalArr.value.findIndex((v) => v === min_value)
-
-  const new_value =
-    (absItem - min_value) / (intervalArr.value[min_index + 1] - intervalArr.value[min_index]) +
-    min_index
-
-  return [ts, isPositive ? new_value : -new_value, value]
-}
-
-function updateSingleSeries(name: string) {
-  if (!chart) render()
-  if (!chart) return
-
-  const idx = dimensions.value.findIndex((item) => item.name === name)
-  if (idx === -1) return
-
-  const data = chartData.value[name].map((v) => mapData(v))
-
-  chart.setOption({
-    series: [{ name, data }]
-  })
-}
-
-const lastTsMap = ref<Record<Dimensions['name'], number>>({})
-
-// async function fetchIncrementData() {
-//   for (const item of dimensions.value) {
-//     const { identifier, name } = item
-
-//     const lastTs = lastTsMap.value[name]
-//     if (!lastTs) continue
-
-//     item.response = true
-
-//     IotStatApi.getDeviceInfoChart(
-//       data.value.deviceCode,
-//       identifier,
-//       dayjs(lastTs).format('YYYY-MM-DD HH:mm:ss'),
-//       dayjs().format('YYYY-MM-DD HH:mm:ss')
-//     )
-//       .then((res) => {
-//         if (!res.length) return
-
-//         const sorted = res
-//           .sort((a, b) => a.ts - b.ts)
-//           .map((item) => ({ ts: item.ts, value: item.value }))
-//         // push 到本地
-//         chartData.value[name].push(...sorted)
-//         // 更新 lastTs
-//         lastTsMap.value[identifier] = sorted.at(-1).ts
-
-//         // 更新图表
-//         updateSingleSeries(name)
-//       })
-//       .finally(() => {
-//         item.response = false
-//       })
-//   }
-// }
-
-// const timer = ref<NodeJS.Timeout | null>(null)
-
-// function startAutoFetch() {
-//   timer.value = setInterval(() => {
-//     updateDimensionValues()
-//     fetchIncrementData()
-//   }, 10000)
-// }
-
-// function stopAutoFetch() {
-//   cancelAllRequests()
-//   if (timer.value) clearInterval(timer.value)
-//   timer.value = null
-// }
-
-const chartLoading = ref(false)
-
-async function initLoadChartData(real_time: boolean = true) {
-  if (!dimensions.value.length) return
-
-  chartData.value = Object.fromEntries(dimensions.value.map((item) => [item.name, []]))
-
-  chartLoading.value = true
-
-  dimensions.value = dimensions.value.map((item) => {
-    item.response = true
-    return item
-  })
-
-  for (const item of dimensions.value) {
-    const { identifier, name } = item
-    try {
-      const res = await IotStatApi.getDeviceInfoChart(
-        data.value.deviceCode,
-        identifier,
-        selectedDate.value[0],
-        selectedDate.value[1]
-      )
-
-      const sorted = res
-        .sort((a, b) => a.ts - b.ts)
-        .map((item) => ({ ts: item.ts, value: item.value }))
-
-      chartData.value[name] = sorted
-
-      lastTsMap.value[name] = sorted.at(-1)?.ts ?? 0
-
-      updateSingleSeries(name)
-
-      chartLoading.value = false
-
-      if (selectedDimension.value[name]) {
-        genderIntervalArr()
-      }
-    } finally {
-      item.response = false
-    }
-  }
-
-  if (real_time) {
-    // startAutoFetch()
-    connect()
-  }
-}
-
-async function initfn(load: boolean = true, real_time: boolean = true) {
-  if (load) await loadDimensions()
-  render()
-  initLoadChartData(real_time)
-}
-
-onMounted(() => {
-  initfn()
-})
-
-function reset() {
-  cancelAllRequests().then(() => {
-    selectedDate.value = [
-      dayjs().subtract(5, 'minute').format('YYYY-MM-DD HH:mm:ss'),
-      dayjs().format('YYYY-MM-DD HH:mm:ss')
-    ]
-
-    close()
-    // stopAutoFetch()
-    if (chart) chart.clear()
-    initfn(false)
-  })
-}
-
-function handleDateChange() {
-  cancelAllRequests().then(() => {
-    close()
-    // stopAutoFetch()
-    if (chart) chart.clear()
-    initfn(false, false)
-  })
-}
-
-function handleClickSpec(modelName: string) {
-  selectedDimension.value[modelName] = !selectedDimension.value[modelName]
-  chart?.setOption({
-    legend: {
-      selected: selectedDimension.value
-    }
-  })
-  chart?.resize()
-  genderIntervalArr()
-}
-
-const exportChart = () => {
-  if (!chart) return
-  let img = new Image()
-  img.src = chart.getDataURL({
-    type: 'png',
-    pixelRatio: 1,
-    backgroundColor: '#fff'
-  })
-
-  img.onload = function () {
-    let canvas = document.createElement('canvas')
-    canvas.width = img.width
-    canvas.height = img.height
-    let ctx = canvas.getContext('2d')
-    ctx?.drawImage(img, 0, 0)
-    let dataURL = canvas.toDataURL('image/png')
-
-    let a = document.createElement('a')
-
-    let event = new MouseEvent('click')
-
-    a.href = dataURL
-    a.download = `${data.value.deviceName}-设备监控-${dayjs().format('YYYY-MM-DD HH:mm:ss')}.png`
-    a.dispatchEvent(event)
-  }
-}
-
-const maxmin = computed(() => {
-  if (!dimensions.value.length) return []
-  return dimensions.value
-    .filter((v) => selectedDimension.value[v.name])
-    .map((v) => ({
-      name: v.name,
-      color: v.color,
-      max: Math.max(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2),
-      min: Math.min(...(chartData.value[v.name]?.map((v) => v.value) ?? [])).toFixed(2)
-    }))
-})
-
-onUnmounted(() => {
-  // stopAutoFetch()
-  close()
-
-  window.removeEventListener('resize', () => {
-    if (chart) chart.resize()
-  })
-})
-</script>
-
-<template>
-  <div
-    class="w-full bg-gradient-to-r from-blue-100 to-white rounded-lg p-4 shadow"
-    id="td-device-info"
-  >
-    <h2 class="flex items-center gap-2">
-      <el-icon class="text-sky!"><Odometer /></el-icon> 设备基础信息
-    </h2>
-    <el-form size="default" label-position="top" class="mt-4 grid grid-cols-4 gap-2">
-      <el-form-item label="资产编码"> {{ data.deviceCode }} </el-form-item>
-      <el-form-item label="设备类别"> {{ data.deviceName }} </el-form-item>
-      <el-form-item label="所在部门"> {{ data.dept }} </el-form-item>
-      <el-form-item label="网关状态" class="online" type="plain">
-        <el-tag
-          v-if="data.ifInline === '3'"
-          type="success"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
-          在线
-        </el-tag>
-
-        <el-tag v-if="data.ifInline === '4'" type="danger" size="default" class="flex items-center">
-          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
-          离线
-        </el-tag>
-      </el-form-item>
-      <el-form-item v-if="data.carOnline" label="中航北斗" class="online" type="plain">
-        <el-tag
-          v-if="data.carOnline === 'true'"
-          type="success"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-emerald!"><CircleCheckFilled /></el-icon>
-          在线
-        </el-tag>
-
-        <el-tag
-          v-if="data.carOnline === 'false'"
-          type="danger"
-          size="default"
-          class="flex items-center"
-        >
-          <el-icon class="text-rose"><CircleCloseFilled /></el-icon>
-          离线
-        </el-tag>
-      </el-form-item>
-      <el-form-item label="最后数据时间"> {{ data.lastInlineTime }} </el-form-item>
-      <el-form-item v-if="data.vehicle" label="车牌号码"> {{ data.vehicle }} </el-form-item>
-    </el-form>
-  </div>
-  <div class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow min-h-50">
-    <header class="font-medium text-center w-full">网关数采</header>
-    <div
-      v-loading="dimensionLoading"
-      element-loading-background="transparent"
-      class="w-full mt-4 grid grid-cols-5 gap-2 min-h-30"
-      id="dimension"
-    >
-      <button
-        v-for="item in gatewayDimensions"
-        :key="item.identifier"
-        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-8 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
-        :disabled="disabledDimension(item.identifier).disabled"
-        @click="handleClickSpec(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2 relative">
-          <!-- <i
-            v-show="disabledDimension(item.identifier).loading"
-            class="i-line-md:loading-loop size-5 absolute -left-6"
-          ></i> -->
-          {{ item.name }}
-        </span>
-        <!-- <span class="text-lg font-medium ms-a">{{ item.value }}</span> -->
-        <animated-count-to :value="item.value" will-change class="text-lg font-medium ms-a" />
-      </button>
-    </div>
-  </div>
-  <div
-    v-if="carDimensions.length"
-    class="mt-4 w-full rounded-lg bg-gradient-to-r from-blue-100 to-white p-4 shadow"
-  >
-    <header class="font-medium text-center w-full">中航北斗</header>
-    <div class="w-full mt-4 grid grid-cols-5 gap-2 min-h-30" id="dimension">
-      <button
-        v-for="item in carDimensions"
-        :key="item.identifier"
-        class="border-none h-12 bg-white dark:bg-[#1d1e1f] rounded-lg px-6 shadow flex items-center hover:scale-103 transition-all cursor-pointer"
-        :class="{ 'bg-blue-200': selectedDimension[item.name] }"
-        :disabled="disabledDimension(item.identifier).disabled"
-        @click="handleClickSpec(item.name)"
-      >
-        <span class="text-sm text-[var(--el-text-color-regular)] flex items-center gap-2">
-          <!-- <i
-            v-show="disabledDimension(item.identifier).loading"
-            class="i-line-md:loading-loop size-5"
-          ></i> -->
-          {{ item.name }}
-        </span>
-        <span class="text-lg font-medium ms-a">{{ item.value }}</span>
-      </button>
-    </div>
-  </div>
-  <div class="w-full mt-4 bg-gradient-to-r from-blue-100 to-white min-h-175 rounded-lg p-6 shadow">
-    <header class="flex items-center justify-between">
-      <h3 class="flex items-center gap-2">
-        <div class="i-material-symbols:area-chart-outline-rounded text-sky size-6" text-sky></div>
-        数据趋势
-      </h3>
-      <div class="flex gap-4">
-        <el-button type="primary" size="default" @click="exportChart">导出为图片</el-button>
-        <el-button size="default" @click="reset">重置</el-button>
-        <el-date-picker
-          v-model="selectedDate"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          type="datetimerange"
-          unlink-panels
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
-          :shortcuts="rangeShortcuts"
-          size="default"
-          class="w-100!"
-          placement="bottom-end"
-          @change="handleDateChange"
-        />
-      </div>
-    </header>
-    <div class="flex h-160 mt-4">
-      <div class="flex gap-1">
-        <button
-          v-for="item of maxmin"
-          :key="item.name"
-          class="w-8 h-full flex flex-col items-center justify-between py-2 gap-1 rounded bg-transparent border-none"
-          @click="handleClickSpec(item.name)"
-        >
-          <span class="[writing-mode:sideways-lr]">{{ item.max }}</span>
-          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
-          <span class="[writing-mode:sideways-lr]">{{ item.name }}</span>
-          <div class="flex-1 w-1" :style="{ backgroundColor: item.color }"></div>
-          <span class="[writing-mode:sideways-lr]">{{ item.min }}</span>
-        </button>
-      </div>
-      <div class="flex flex-1">
-        <div
-          v-loading="chartLoading"
-          element-loading-background="transparent"
-          ref="chartRef"
-          class="flex-1 h-full"
-        >
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<style lang="scss" scoped>
-:deep(.el-form-item) {
-  margin-bottom: 0;
-
-  .el-form-item__label {
-    margin-bottom: 0;
-  }
-
-  .el-form-item__content {
-    font-size: 1rem;
-    font-weight: 500;
-  }
-
-  &.online {
-    .el-form-item__content {
-      height: 2.5rem;
-
-      .el-tag__content {
-        display: flex;
-        align-items: center;
-        gap: 2px;
-      }
-    }
-  }
-}
-</style>

+ 1 - 1
src/views/oli-connection/monitoring/detail.vue

@@ -622,7 +622,7 @@ async function initLoadChartData({
       dimensions.value.map(async (item) => {
         item.response = true
         try {
-          const res = await IotStatApi.getDeviceInfoChart(
+          const res = await IotStatApi.getDeviceInfoChartly(
             data.value.deviceCode,
             item.identifier,
             selectedDate.value[0],

+ 355 - 142
src/views/pms/operation-meeting/components/meeting-detail-drawer.vue

@@ -1,14 +1,15 @@
 <script lang="ts" setup>
 import type { FormInstance, FormRules } from 'element-plus'
-import type { DetailItem } from '../types'
+import type { DetailItem, ExtPropertyItem } from '../types'
 import { OperationMeetingApi } from '@/api/pms/meeting'
-import { useDebounceFn, useWindowSize } from '@vueuse/core'
+import { useWindowSize } from '@vueuse/core'
 
 interface Props {
   visible: boolean
   detail?: DetailItem
   type: 'create' | 'edit' | 'view'
   formType: 'create' | 'edit'
+  extProperties?: ExtPropertyItem[]
 }
 
 interface ProjectNameSuggestion {
@@ -27,12 +28,11 @@ const emits = defineEmits<{
 const { width } = useWindowSize()
 const detailFormRef = ref<FormInstance>()
 const detailForm = ref<DetailItem>(createDetailItem())
-const previousWorkPlanQueryKey = ref('')
-const equipmentUtilizationRateRequired = ref(true)
 
 const detailDrawerSize = computed(() => (width.value <= 768 ? '100%' : '50%'))
+const isReadonly = computed(() => props.type === 'view')
 const detailDrawerTitle = computed(() => {
-  if (props.type === 'view') {
+  if (isReadonly.value) {
     return '查看会议明细'
   }
 
@@ -44,23 +44,42 @@ const projectNameSuggestions = computed<ProjectNameSuggestion[]>(() =>
   projectNameOptions.value.map((item) => ({ value: item }))
 )
 
+const hasExtProperties = computed(() =>
+  Boolean(props.extProperties?.length || detailForm.value.extProperty.length)
+)
+const currentPeriodExtProperties = computed(() =>
+  hasExtProperties.value
+    ? detailForm.value.extProperty.filter((item) => item.defaultValue !== 'next')
+    : []
+)
+const nextPlanExtProperties = computed(() =>
+  hasExtProperties.value
+    ? detailForm.value.extProperty.filter((item) => item.defaultValue === 'next')
+    : []
+)
+
 function createDetailItem(): DetailItem {
   return {
     raw: {},
     projectName: '',
     currentRevenue: undefined,
     cumulativeRevenue: undefined,
+    beforeRevenue: undefined,
     currentOnAccount: undefined,
     cumulativeOnAccount: undefined,
+    beforeOnAccount: undefined,
     currentPayment: undefined,
     cumulativePayment: undefined,
+    beforePayment: undefined,
     plannedWorkload: '',
     actualCompletion: '',
     equipmentUtilizationRate: undefined,
     keyWorkCompletion: '',
     problemsAnalysis: '',
+    qhse: '',
     nextPlannedWorkload: '',
-    priorityTasks: ''
+    priorityTasks: '',
+    extProperty: cloneExtProperties(props.extProperties)
   }
 }
 
@@ -69,19 +88,108 @@ const cloneDetailItem = (data?: Partial<DetailItem>): DetailItem => ({
   projectName: data?.projectName || '',
   currentRevenue: data?.currentRevenue,
   cumulativeRevenue: data?.cumulativeRevenue,
+  beforeRevenue: data?.beforeRevenue,
   currentOnAccount: data?.currentOnAccount,
   cumulativeOnAccount: data?.cumulativeOnAccount,
+  beforeOnAccount: data?.beforeOnAccount,
   currentPayment: data?.currentPayment,
   cumulativePayment: data?.cumulativePayment,
+  beforePayment: data?.beforePayment,
   plannedWorkload: data?.plannedWorkload || '',
   actualCompletion: data?.actualCompletion || '',
   equipmentUtilizationRate: data?.equipmentUtilizationRate,
   keyWorkCompletion: data?.keyWorkCompletion || '',
   problemsAnalysis: data?.problemsAnalysis || '',
+  qhse: data?.qhse || '',
   nextPlannedWorkload: data?.nextPlannedWorkload || '',
-  priorityTasks: data?.priorityTasks || ''
+  priorityTasks: data?.priorityTasks || '',
+  extProperty: mergeExtProperties(props.extProperties || [], data?.extProperty)
 })
 
+function normalizeTextValue(value: unknown) {
+  return String(value ?? '').replace(/\\r\\n|\\n/g, '\n')
+}
+
+function parseNumberValue(value: unknown) {
+  if (value === undefined || value === null || value === '') return undefined
+  const parsed = Number(String(value).replace('%', ''))
+
+  return Number.isNaN(parsed) ? undefined : parsed
+}
+
+function normalizeExtProperty(data?: Partial<ExtPropertyItem>): ExtPropertyItem {
+  const dataType = normalizeTextValue(data?.dataType)
+
+  return {
+    name: normalizeTextValue(data?.name),
+    identifier: normalizeTextValue(data?.identifier),
+    dataType,
+    required: data?.required ?? 0,
+    unit: normalizeTextValue(data?.unit),
+    accessMode: normalizeTextValue(data?.accessMode),
+    defaultValue: normalizeTextValue(data?.defaultValue),
+    maxValue: data?.maxValue,
+    minValue: data?.minValue,
+    sort: parseNumberValue(data?.sort),
+    actualValue: dataType === 'double' ? parseNumberValue(data?.actualValue) : data?.actualValue,
+    dropdownList: data?.dropdownList
+  }
+}
+
+function parseExtPropertyList(data?: ExtPropertyItem[] | unknown): unknown[] {
+  if (Array.isArray(data)) return data
+
+  if (typeof data === 'string') {
+    const text = data.trim()
+    if (!text) return []
+
+    try {
+      return parseExtPropertyList(JSON.parse(text))
+    } catch {
+      return []
+    }
+  }
+
+  if (data && typeof data === 'object') {
+    const values = Object.values(data as Record<string, unknown>)
+
+    return values.every((item) => item && typeof item === 'object') ? values : []
+  }
+
+  return []
+}
+
+function cloneExtProperties(data?: ExtPropertyItem[] | unknown): ExtPropertyItem[] {
+  return parseExtPropertyList(data)
+    .map((item) => normalizeExtProperty(item as Partial<ExtPropertyItem>))
+    .filter((item) => item.identifier)
+}
+
+function mergeExtProperties(
+  defaults: ExtPropertyItem[],
+  current?: ExtPropertyItem[] | unknown
+): ExtPropertyItem[] {
+  const currentItems = cloneExtProperties(current)
+  if (!defaults.length) return currentItems
+
+  const currentMap = new Map(currentItems.map((item) => [item.identifier, item]))
+  const defaultIdentifiers = new Set(defaults.map((item) => item.identifier))
+  const mergedDefaults = defaults.map((item) => {
+    const currentItem = currentMap.get(item.identifier)
+
+    return {
+      ...normalizeExtProperty(item),
+      actualValue:
+        currentItem && 'actualValue' in currentItem ? currentItem.actualValue : item.actualValue
+    }
+  })
+  const extraItems = currentItems.filter((item) => !defaultIdentifiers.has(item.identifier))
+
+  return [...mergedDefaults, ...extraItems].sort(
+    (a, b) => Number(a.sort || 0) - Number(b.sort || 0)
+  )
+}
+
 const requiredTextRule = (message: string) => [
   {
     required: true,
@@ -110,32 +218,6 @@ const nonNegativeNumberRule = (requiredMessage: string, fieldLabel: string) => [
   }
 ]
 
-const numberRangeRule = (requiredMessage: string, fieldLabel: string, min: number, max: number) => [
-  ...requiredNumberRule(requiredMessage),
-  {
-    type: 'number' as const,
-    min,
-    max,
-    message: `${fieldLabel}需在${min}到${max}之间`,
-    trigger: ['blur', 'change']
-  }
-]
-
-const optionalNumberRangeRule = (fieldLabel: string, min: number, max: number) => [
-  {
-    type: 'number' as const,
-    min,
-    max,
-    message: `${fieldLabel}需在${min}到${max}之间`,
-    trigger: ['blur', 'change']
-  }
-]
-
-const getEquipmentUtilizationRateRules = () =>
-  equipmentUtilizationRateRequired.value
-    ? numberRangeRule('请输入设备利用率', '设备利用率', 0, 100)
-    : optionalNumberRangeRule('设备利用率', 0, 100)
-
 const detailRules = reactive<FormRules>({
   projectName: requiredTextRule('请选择或者输入项目名称'),
   currentRevenue: nonNegativeNumberRule('请输入收入-本期', '收入-本期'),
@@ -144,107 +226,220 @@ const detailRules = reactive<FormRules>({
   cumulativeOnAccount: nonNegativeNumberRule('请输入挂帐-累计', '挂帐-累计'),
   currentPayment: nonNegativeNumberRule('请输入回款-本期', '回款-本期'),
   cumulativePayment: nonNegativeNumberRule('请输入回款-累计', '回款-累计'),
-  plannedWorkload: requiredTextRule('请输入计划工作量'),
-  actualCompletion: requiredTextRule('请输入实际完成'),
-  equipmentUtilizationRate: getEquipmentUtilizationRateRules(),
   keyWorkCompletion: requiredTextRule('请输入重点工作及完成情况'),
   problemsAnalysis: requiredTextRule('请输入存在问题及分析'),
+  qhse: requiredTextRule('请输入设备、安全管理工作'),
   nextPlannedWorkload: requiredTextRule('请输入下期计划工作量'),
   priorityTasks: requiredTextRule('请输入重点工作事项')
 })
 
-const updateEquipmentUtilizationRateRule = (required: boolean) => {
-  equipmentUtilizationRateRequired.value = required
-  detailRules.equipmentUtilizationRate = getEquipmentUtilizationRateRules()
-  nextTick(() => detailFormRef.value?.clearValidate('equipmentUtilizationRate'))
+const isRequiredExtProperty = (item: ExtPropertyItem) =>
+  item.required === true || item.required === 1 || item.required === '1'
+
+const isDoubleExtProperty = (item: ExtPropertyItem) => item.dataType === 'double'
+
+const normalizeExtPropertyKeyword = (value: unknown) =>
+  String(value ?? '')
+    .toLowerCase()
+    .replace(/[\s%()()]/g, '')
+
+const getExtPropertySearchText = (item: ExtPropertyItem) =>
+  `${normalizeExtPropertyKeyword(item.identifier)}${normalizeExtPropertyKeyword(item.name)}`
+
+const isEquipmentUtilizationExtProperty = (item: ExtPropertyItem) => {
+  const text = getExtPropertySearchText(item)
+
+  return (
+    text.includes('设备利用率') ||
+    text.includes('equipmentutilization') ||
+    text.includes('utilizationrate')
+  )
 }
 
-const loadEquipmentUtilizationRateRule = async () => {
-  updateEquipmentUtilizationRateRule(true)
+const isConstructionEquipmentCountExtProperty = (item: ExtPropertyItem) => {
+  const text = getExtPropertySearchText(item)
 
-  try {
-    const data = await OperationMeetingApi.getMandatoryOrNot()
-    updateEquipmentUtilizationRateRule(data?.mandatory !== false)
-  } catch {
-    updateEquipmentUtilizationRateRule(true)
-  }
+  return (
+    text.includes('施工设备数量') ||
+    text.includes('施工设备数') ||
+    text.includes('constructionequipmentcount') ||
+    text.includes('constructiondevicecount')
+  )
 }
 
-const queryProjectNameSearch = (
-  queryString: string,
-  cb: (results: ProjectNameSuggestion[]) => void
-) => {
-  const keyword = queryString.trim().toLowerCase()
+const isOperatingEquipmentCountExtProperty = (item: ExtPropertyItem) => {
+  const text = getExtPropertySearchText(item)
 
-  if (!keyword) {
-    cb(projectNameSuggestions.value)
-    return
-  }
+  return (
+    text.includes('投运设备数量') ||
+    text.includes('投运设备数') ||
+    text.includes('operatingequipmentcount') ||
+    text.includes('operationequipmentcount') ||
+    text.includes('runningequipmentcount')
+  )
+}
 
-  cb(projectNameSuggestions.value.filter((item) => item.value.toLowerCase().includes(keyword)))
+const equipmentUtilizationExtProperty = computed(() =>
+  currentPeriodExtProperties.value.find(isEquipmentUtilizationExtProperty)
+)
+
+const constructionEquipmentCountExtProperty = computed(() =>
+  currentPeriodExtProperties.value.find(isConstructionEquipmentCountExtProperty)
+)
+
+const operatingEquipmentCountExtProperty = computed(() =>
+  currentPeriodExtProperties.value.find(isOperatingEquipmentCountExtProperty)
+)
+
+const updateEquipmentUtilizationExtProperty = () => {
+  const utilizationItem = equipmentUtilizationExtProperty.value
+  const constructionItem = constructionEquipmentCountExtProperty.value
+  const operatingItem = operatingEquipmentCountExtProperty.value
+  if (!utilizationItem || !constructionItem || !operatingItem) return
+
+  const constructionCount = parseNumberValue(constructionItem.actualValue)
+  const operatingCount = parseNumberValue(operatingItem.actualValue)
+
+  utilizationItem.actualValue =
+    constructionCount === undefined || operatingCount === undefined || operatingCount === 0
+      ? undefined
+      : Number(((constructionCount / operatingCount) * 100).toFixed(2))
+
+  nextTick(() => detailFormRef.value?.clearValidate(getExtPropertyProp(utilizationItem)))
 }
 
-const getDetailRawQueryValue = (key: 'id' | 'meetingId') => {
-  const value = detailForm.value.raw?.[key]
+const hasExtPropertyActualValue = (item: ExtPropertyItem) => {
+  const value = item.actualValue
 
-  if (value === undefined || value === null) {
-    return ''
-  }
+  return typeof value === 'string' ? value.trim() !== '' : value !== undefined && value !== null
+}
+
+const getExtPropertyScope = (item: ExtPropertyItem) =>
+  item.defaultValue === 'next' ? 'next' : 'current'
+
+const getExtPropertyAlternativeGroupKey = (item: ExtPropertyItem) => {
+  const value = String(item.maxValue ?? '').trim()
+
+  if (!value || parseNumberValue(value) !== undefined) return ''
+
+  return value
+}
+
+const getExtPropertyAlternativeGroups = (scope: string) => {
+  const groups = new Map<string, ExtPropertyItem[]>()
+
+  detailForm.value.extProperty.forEach((property) => {
+    if (!isRequiredExtProperty(property) || getExtPropertyScope(property) !== scope) return
+
+    const groupKey = getExtPropertyAlternativeGroupKey(property)
+    if (!groupKey) return
 
-  return typeof value === 'string' || typeof value === 'number' ? value : String(value)
+    groups.set(groupKey, [...(groups.get(groupKey) || []), property])
+  })
+
+  return groups
 }
 
-const updatePlannedWorkloadFromPreviousPlan = (data?: Record<string, unknown>) => {
-  detailForm.value.plannedWorkload = String(data?.nextPlannedWorkload || '')
+const hasCompleteExtPropertyAlternativeGroup = (scope: string) =>
+  Array.from(getExtPropertyAlternativeGroups(scope).values()).some((items) =>
+    items.every(hasExtPropertyActualValue)
+  )
+
+const isAlternativeExtProperty = (item: ExtPropertyItem) => {
+  if (!getExtPropertyAlternativeGroupKey(item)) return false
+
+  return getExtPropertyAlternativeGroups(getExtPropertyScope(item)).size > 1
 }
 
-const loadPreviousWorkPlan = async () => {
-  const projectName = detailForm.value.projectName.trim()
+const getExtPropertyAlternativeProps = (item: ExtPropertyItem) =>
+  Array.from(getExtPropertyAlternativeGroups(getExtPropertyScope(item)).values())
+    .flat()
+    .map((property) => getExtPropertyProp(property))
 
-  if (!projectName) {
-    previousWorkPlanQueryKey.value = ''
+const handleExtPropertyValueChange = (item: ExtPropertyItem) => {
+  updateEquipmentUtilizationExtProperty()
+
+  if (
+    !isAlternativeExtProperty(item) ||
+    !hasCompleteExtPropertyAlternativeGroup(getExtPropertyScope(item))
+  ) {
     return
   }
 
-  const params = {
-    projectName,
-    id: getDetailRawQueryValue('id'),
-    meetingId: getDetailRawQueryValue('meetingId')
-  }
-  const queryKey = JSON.stringify(params)
+  nextTick(() => detailFormRef.value?.clearValidate(getExtPropertyAlternativeProps(item)))
+}
 
-  if (previousWorkPlanQueryKey.value === queryKey) return
+const getExtPropertyLabel = (item: ExtPropertyItem) =>
+  item.unit ? `${item.name}(${item.unit})` : item.name
 
-  previousWorkPlanQueryKey.value = queryKey
+const getExtPropertyProp = (item: ExtPropertyItem) => {
+  const index = detailForm.value.extProperty.findIndex(
+    (property) => property.identifier === item.identifier
+  )
 
-  try {
-    const data = await OperationMeetingApi.getPreviousWorkPlan(params)
-    if (detailForm.value.projectName.trim() !== projectName) return
-    updatePlannedWorkloadFromPreviousPlan(data)
-  } catch {
-    previousWorkPlanQueryKey.value = ''
+  return `extProperty.${Math.max(index, 0)}.actualValue`
+}
+
+const getExtPropertyRules = (item: ExtPropertyItem) => {
+  if (!isRequiredExtProperty(item)) return []
+
+  if (isAlternativeExtProperty(item)) {
+    return [
+      {
+        validator: (_rule, _value, callback) => {
+          if (hasCompleteExtPropertyAlternativeGroup(getExtPropertyScope(item))) {
+            callback()
+            return
+          }
+
+          callback(new Error('请至少完整填写一种工作量'))
+        },
+        trigger: ['blur', 'change']
+      }
+    ]
   }
+
+  return isDoubleExtProperty(item)
+    ? requiredNumberRule(`请输入${item.name}`)
+    : requiredTextRule(`请输入${item.name}`)
 }
 
-const handleProjectNameComplete = useDebounceFn(loadPreviousWorkPlan, 300)
+const queryProjectNameSearch = (
+  queryString: string,
+  cb: (results: ProjectNameSuggestion[]) => void
+) => {
+  const keyword = queryString.trim().toLowerCase()
 
-const handleProjectNameSelect = (item: ProjectNameSuggestion) => {
-  detailForm.value.projectName = item.value
-  void handleProjectNameComplete()
+  if (!keyword) {
+    cb(projectNameSuggestions.value)
+    return
+  }
+
+  cb(projectNameSuggestions.value.filter((item) => item.value.toLowerCase().includes(keyword)))
 }
 
+watch(
+  () =>
+    [
+      constructionEquipmentCountExtProperty.value?.actualValue,
+      operatingEquipmentCountExtProperty.value?.actualValue,
+      equipmentUtilizationExtProperty.value?.identifier
+    ] as const,
+  () => updateEquipmentUtilizationExtProperty(),
+  { immediate: true }
+)
+
 const handleVisibleChange = (visible: boolean) => {
   emits('update:visible', visible)
 
   if (!visible) {
     detailForm.value = createDetailItem()
-    previousWorkPlanQueryKey.value = ''
     nextTick(() => detailFormRef.value?.clearValidate())
   }
 }
 
 const saveDetailItem = async () => {
-  if (props.type === 'view' || !detailFormRef.value) return
+  if (isReadonly.value || !detailFormRef.value) return
 
   const valid = await detailFormRef.value.validate().catch(() => false)
 
@@ -268,7 +463,6 @@ watch(
     if (!visible) return
 
     detailForm.value = cloneDetailItem(props.detail)
-    previousWorkPlanQueryKey.value = ''
     nextTick(() => detailFormRef.value?.clearValidate())
   },
   { immediate: true }
@@ -276,7 +470,6 @@ watch(
 
 onMounted(() => {
   loadProjectNameOptions()
-  loadEquipmentUtilizationRateRule()
 })
 </script>
 
@@ -291,8 +484,7 @@ onMounted(() => {
     :show-close="false"
     header-class="mb-0! p-4!"
     body-class="bg-gray-100"
-    footer-class="p-4!"
-  >
+    footer-class="p-4!">
     <template #header>
       <div class="flex items-center">
         <span class="font-bold text-xl">{{ detailDrawerTitle }}</span>
@@ -305,10 +497,9 @@ onMounted(() => {
       size="default"
       :model="detailForm"
       :rules="detailRules"
-      :disabled="type === 'view'"
+      :disabled="isReadonly"
       scroll-to-error
-      require-asterisk-position="right"
-    >
+      require-asterisk-position="right">
       <section class="detail-section">
         <div class="detail-section__grid detail-section__grid--single">
           <el-form-item label="项目名称" prop="projectName">
@@ -318,10 +509,7 @@ onMounted(() => {
               placeholder="请选择或输入项目名称"
               clearable
               :fetch-suggestions="queryProjectNameSearch"
-              :trigger-on-focus="true"
-              @change="handleProjectNameComplete"
-              @select="handleProjectNameSelect"
-            />
+              :trigger-on-focus="true" />
           </el-form-item>
         </div>
       </section>
@@ -334,78 +522,71 @@ onMounted(() => {
               v-model="detailForm.currentRevenue"
               class="w-full!"
               :controls="false"
-              :precision="2"
-            />
+              :precision="2" />
           </el-form-item>
           <el-form-item label="收入-累计" prop="cumulativeRevenue">
             <el-input-number
               v-model="detailForm.cumulativeRevenue"
               class="w-full!"
               :controls="false"
-              :precision="2"
-            />
+              :precision="2" />
           </el-form-item>
           <el-form-item label="挂帐-本期" prop="currentOnAccount">
             <el-input-number
               v-model="detailForm.currentOnAccount"
               class="w-full!"
               :controls="false"
-              :precision="2"
-            />
+              :precision="2" />
           </el-form-item>
           <el-form-item label="挂帐-累计" prop="cumulativeOnAccount">
             <el-input-number
               v-model="detailForm.cumulativeOnAccount"
               class="w-full!"
               :controls="false"
-              :precision="2"
-            />
+              :precision="2" />
           </el-form-item>
           <el-form-item label="回款-本期" prop="currentPayment">
             <el-input-number
               v-model="detailForm.currentPayment"
               class="w-full!"
               :controls="false"
-              :precision="2"
-            />
+              :precision="2" />
           </el-form-item>
           <el-form-item label="回款-累计" prop="cumulativePayment">
             <el-input-number
               v-model="detailForm.cumulativePayment"
               class="w-full!"
               :controls="false"
-              :precision="2"
-            />
+              :precision="2" />
           </el-form-item>
         </div>
       </section>
 
-      <section class="detail-section">
+      <section v-if="currentPeriodExtProperties.length" class="detail-section">
         <h4 class="detail-section__title">本期生产运行情况</h4>
         <div class="detail-section__grid">
-          <el-form-item label="计划工作量" prop="plannedWorkload">
-            <el-input
-              v-model="detailForm.plannedWorkload"
-              type="textarea"
-              :rows="3"
-              placeholder="请输入计划工作量"
-            />
-          </el-form-item>
-          <el-form-item label="实际完成" prop="actualCompletion">
-            <el-input
-              v-model="detailForm.actualCompletion"
-              type="textarea"
-              :rows="3"
-              placeholder="请输入实际完成"
-            />
-          </el-form-item>
-          <el-form-item label="设备利用率(%)" prop="equipmentUtilizationRate">
+          <el-form-item
+            v-for="item in currentPeriodExtProperties"
+            :key="item.identifier"
+            :label="getExtPropertyLabel(item)"
+            :prop="getExtPropertyProp(item)"
+            :rules="getExtPropertyRules(item)">
             <el-input-number
-              v-model="detailForm.equipmentUtilizationRate"
+              v-if="isDoubleExtProperty(item)"
+              v-model="item.actualValue"
               class="w-full!"
               :controls="false"
+              :disabled="isReadonly || isEquipmentUtilizationExtProperty(item)"
               :precision="2"
-            />
+              @change="handleExtPropertyValueChange(item)" />
+            <el-input
+              v-else
+              v-model="item.actualValue"
+              type="textarea"
+              :rows="3"
+              :disabled="isReadonly"
+              :placeholder="`请输入${item.name}`"
+              @input="handleExtPropertyValueChange(item)" />
           </el-form-item>
         </div>
       </section>
@@ -418,16 +599,27 @@ onMounted(() => {
               v-model="detailForm.keyWorkCompletion"
               type="textarea"
               :rows="4"
-              placeholder="请输入重点工作及完成情况"
-            />
+              placeholder="请输入重点工作及完成情况" />
           </el-form-item>
           <el-form-item label="存在问题及分析" prop="problemsAnalysis">
             <el-input
               v-model="detailForm.problemsAnalysis"
               type="textarea"
               :rows="4"
-              placeholder="请输入存在问题及分析"
-            />
+              placeholder="请输入存在问题及分析" />
+          </el-form-item>
+        </div>
+      </section>
+
+      <section class="detail-section">
+        <h4 class="detail-section__title">设备、安全管理工作</h4>
+        <div class="detail-section__grid detail-section__grid--single">
+          <el-form-item prop="qhse">
+            <el-input
+              v-model="detailForm.qhse"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入设备、安全管理工作" />
           </el-form-item>
         </div>
       </section>
@@ -440,16 +632,37 @@ onMounted(() => {
               v-model="detailForm.nextPlannedWorkload"
               type="textarea"
               :rows="4"
-              placeholder="请输入下期计划工作量"
-            />
+              placeholder="请输入下期计划工作量" />
           </el-form-item>
           <el-form-item label="重点工作事项" prop="priorityTasks">
             <el-input
               v-model="detailForm.priorityTasks"
               type="textarea"
               :rows="4"
-              placeholder="请输入重点工作事项"
-            />
+              placeholder="请输入重点工作事项" />
+          </el-form-item>
+          <el-form-item
+            v-for="item in nextPlanExtProperties"
+            :key="item.identifier"
+            :label="getExtPropertyLabel(item)"
+            :prop="getExtPropertyProp(item)"
+            :rules="getExtPropertyRules(item)">
+            <el-input-number
+              v-if="isDoubleExtProperty(item)"
+              v-model="item.actualValue"
+              class="w-full!"
+              :controls="false"
+              :disabled="isReadonly"
+              :precision="2"
+              @change="handleExtPropertyValueChange(item)" />
+            <el-input
+              v-else
+              v-model="item.actualValue"
+              type="textarea"
+              :rows="4"
+              :disabled="isReadonly"
+              :placeholder="`请输入${item.name}`"
+              @input="handleExtPropertyValueChange(item)" />
           </el-form-item>
         </div>
       </section>
@@ -457,7 +670,7 @@ onMounted(() => {
 
     <template #footer>
       <el-button size="default" @click="handleVisibleChange(false)">取消</el-button>
-      <el-button size="default" v-if="type !== 'view'" type="primary" @click="saveDetailItem">
+      <el-button size="default" v-if="!isReadonly" type="primary" @click="saveDetailItem">
         保存
       </el-button>
     </template>

Fichier diff supprimé car celui-ci est trop grand
+ 710 - 101
src/views/pms/operation-meeting/components/operation-meeting-content.vue


+ 14 - 25
src/views/pms/operation-meeting/index.vue

@@ -67,7 +67,7 @@ function handleSizeChange(val: number) {
 
 function handleCurrentChange(val: number) {
   query.value.pageNo = val
-  handleQuery()
+  getList()
 }
 
 function resetQuery() {
@@ -104,25 +104,21 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
 </script>
 <template>
   <div
-    class="operation-meeting-page min-w-0 overflow-x-hidden grid grid-rows-[auto_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
-  >
+    class="operation-meeting-page min-w-0 overflow-x-hidden grid grid-rows-[auto_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <el-form
       size="default"
       label-position="left"
-      class="operation-meeting-query min-w-0 overflow-hidden rounded-lg bg-white p-4 shadow dark:bg-[#1d1e1f]"
-    >
+      class="operation-meeting-query min-w-0 overflow-hidden rounded-lg bg-white p-4 shadow dark:bg-[#1d1e1f]">
       <div class="min-w-0 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
         <div
-          class="operation-meeting-query__fields min-w-0 flex-1 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-[repeat(2,minmax(0,320px))]"
-        >
+          class="operation-meeting-query__fields min-w-0 flex-1 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-[repeat(2,minmax(0,320px))]">
           <el-form-item label="专业公司" class="operation-meeting-query__item mb-0! min-w-0">
             <el-select
               v-model="query.deptId"
               class="w-full!"
               placeholder="请选择专业公司"
               :options="deptOptions"
-              clearable
-            />
+              clearable />
           </el-form-item>
 
           <el-form-item label="会议日期" class="operation-meeting-query__item mb-0! min-w-0">
@@ -135,14 +131,12 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
               end-placeholder="结束日期"
               :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
               :shortcuts="rangeShortcuts"
-              class="w-full!"
-            />
+              class="w-full!" />
           </el-form-item>
         </div>
 
         <div
-          class="operation-meeting-query__actions min-w-0 flex flex-col gap-3 sm:flex-row lg:shrink-0"
-        >
+          class="operation-meeting-query__actions min-w-0 flex flex-col gap-3 sm:flex-row lg:shrink-0">
           <el-button type="primary" class="!ml-0 w-full sm:w-auto" @click="handleQuery">
             搜索
           </el-button>
@@ -154,8 +148,7 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
       </div>
     </el-form>
     <div
-      class="operation-meeting-data-panel bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-4 min-h-0"
-    >
+      class="operation-meeting-data-panel bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-4 min-h-0">
       <div class="flex-1 min-h-0 relative">
         <el-auto-resizer class="operation-meeting-table-view absolute">
           <template #default="{ width, height }">
@@ -165,8 +158,7 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
               :width="width"
               :max-height="height"
               :height="height"
-              show-border
-            >
+              show-border>
               <ZmTableColumn label="会议期次" prop="meetingSeries" width="180" />
               <ZmTableColumn label="公司名称" prop="companyName" :min-width="220" />
               <ZmTableColumn
@@ -174,8 +166,7 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
                 prop="meetingDate"
                 cover-formatter
                 :real-value="formatMeetingDate"
-                :min-width="160"
-              />
+                :min-width="160" />
               <ZmTableColumn label="操作" width="120" fixed="right">
                 <template #default="{ row }">
                   <el-button size="default" link type="primary" @click="handleEdit(row.id)">
@@ -236,8 +227,7 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
           :total="total"
           layout="total, sizes, prev, pager, next, jumper"
           @size-change="handleSizeChange"
-          @current-change="handleCurrentChange"
-        />
+          @current-change="handleCurrentChange" />
         <el-pagination
           class="operation-meeting-pagination__mobile"
           size="small"
@@ -247,19 +237,18 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
           :background="true"
           :total="total"
           layout="prev, pager, next"
-          @current-change="handleCurrentChange"
-        />
+          @current-change="handleCurrentChange" />
       </div>
     </div>
   </div>
   <meeting-form
     v-model:visible="visible"
+    mode="fill"
     :id="currentId"
     :type="type"
     :dept-options="deptOptions"
     @update:visible="visible = $event"
-    @success="getList"
-  />
+    @success="getList" />
 </template>
 
 <style scoped lang="scss">

+ 269 - 54
src/views/pms/operation-meeting/meeting-form.vue

@@ -1,6 +1,14 @@
 <script lang="ts" setup>
 import type { FormInstance, FormRules } from 'element-plus'
-import type { DeptOption, DetailItem, OperationMeeting } from './types'
+import type {
+  DeptOption,
+  DetailItem,
+  ExtPropertyItem,
+  OperationMeeting,
+  OperationMeetingDrawerMode,
+  OperationMeetingForm,
+  OperationMeetingFormItem
+} from './types'
 import MeetingDetailDrawer from './components/meeting-detail-drawer.vue'
 import OperationMeetingContent from './components/operation-meeting-content.vue'
 import { OperationMeetingApi } from '@/api/pms/meeting'
@@ -10,36 +18,45 @@ interface Props {
   visible: boolean
   id?: number
   type: 'create' | 'edit' | 'view'
+  mode?: OperationMeetingDrawerMode
+  meetingSeries?: string
+  year?: string | number
   deptOptions?: DeptOption[]
 }
 
-interface OperationMeetingForm
-  extends Omit<Partial<OperationMeeting>, 'meetingDate' | 'meetingSeries'> {
-  meetingDate?: number | string | Date
-  meetingSeries?: number
-}
-
 const props = withDefaults(defineProps<Props>(), {
   type: 'create',
+  mode: 'fill',
+  meetingSeries: '',
+  year: '',
   deptOptions: () => []
 })
 
 const emits = defineEmits(['update:visible', 'success'])
 const message = useMessage()
 
-const operationMeeting = ref<OperationMeetingForm>({})
 const operationMeetingRef = ref<FormInstance>()
 const loading = ref(false)
 const { width } = useWindowSize()
 
-const detailItems = ref<DetailItem[]>([])
+const extProperties = ref<ExtPropertyItem[]>([])
+const extPropertiesLoaded = ref(false)
+const form = ref<OperationMeetingFormItem[]>([createMeetingFormItem()])
 const detailDrawerVisible = ref(false)
 const detailEditingIndex = ref(-1)
 const detailFormType = ref<'create' | 'edit'>('create')
 const detailForm = ref<DetailItem>(createDetailItem())
+const filterForm = reactive({
+  companyFilterValue: ''
+})
 
 const isMobile = computed(() => width.value <= 768)
 const drawerSize = computed(() => (isMobile.value ? '100%' : '100%'))
+const isSummaryMode = computed(() => props.mode === 'summary')
+const activeFormItem = computed(() => form.value[0] || createMeetingFormItem())
+const operationMeeting = computed(() => activeFormItem.value.meeting)
+const detailItems = computed(() => activeFormItem.value.details)
+const detailDrawerType = computed(() => (isSummaryMode.value ? 'view' : props.type))
 
 function createDetailItem(): DetailItem {
   return {
@@ -47,17 +64,41 @@ function createDetailItem(): DetailItem {
     projectName: '',
     currentRevenue: undefined,
     cumulativeRevenue: undefined,
+    beforeRevenue: undefined,
     currentOnAccount: undefined,
     cumulativeOnAccount: undefined,
+    beforeOnAccount: undefined,
     currentPayment: undefined,
     cumulativePayment: undefined,
+    beforePayment: undefined,
     plannedWorkload: '',
     actualCompletion: '',
     equipmentUtilizationRate: undefined,
     keyWorkCompletion: '',
     problemsAnalysis: '',
+    qhse: '',
     nextPlannedWorkload: '',
-    priorityTasks: ''
+    priorityTasks: '',
+    extProperty: cloneExtProperties(extProperties.value)
+  }
+}
+
+function createMeetingFormItem(
+  meeting: OperationMeetingForm = {},
+  details: DetailItem[] = [],
+  extProperty: ExtPropertyItem[] = [],
+  index = 0
+): OperationMeetingFormItem {
+  const id = meeting.id === undefined || meeting.id === null ? 'new' : String(meeting.id)
+
+  return {
+    key: `${id}-${index}`,
+    meeting,
+    details,
+    extProperty,
+    beforeRevenue: undefined,
+    beforeOnAccount: undefined,
+    beforePayment: undefined
   }
 }
 
@@ -66,17 +107,22 @@ const cloneDetailItem = (data?: Partial<DetailItem>): DetailItem => ({
   projectName: data?.projectName || '',
   currentRevenue: data?.currentRevenue,
   cumulativeRevenue: data?.cumulativeRevenue,
+  beforeRevenue: data?.beforeRevenue,
   currentOnAccount: data?.currentOnAccount,
   cumulativeOnAccount: data?.cumulativeOnAccount,
+  beforeOnAccount: data?.beforeOnAccount,
   currentPayment: data?.currentPayment,
   cumulativePayment: data?.cumulativePayment,
+  beforePayment: data?.beforePayment,
   plannedWorkload: data?.plannedWorkload || '',
   actualCompletion: data?.actualCompletion || '',
   equipmentUtilizationRate: data?.equipmentUtilizationRate,
   keyWorkCompletion: data?.keyWorkCompletion || '',
   problemsAnalysis: data?.problemsAnalysis || '',
+  qhse: data?.qhse || '',
   nextPlannedWorkload: data?.nextPlannedWorkload || '',
-  priorityTasks: data?.priorityTasks || ''
+  priorityTasks: data?.priorityTasks || '',
+  extProperty: cloneExtProperties(data?.extProperty)
 })
 
 const parseNumberValue = (value: unknown) => {
@@ -93,24 +139,143 @@ const parseMeetingSeries = (value: unknown) => {
   return matched ? Number(matched[0]) : undefined
 }
 
+const normalizeTextValue = (value: unknown) => String(value ?? '').replace(/\\r\\n|\\n/g, '\n')
+
+function normalizeExtProperty(data?: Partial<ExtPropertyItem>): ExtPropertyItem {
+  const dataType = normalizeTextValue(data?.dataType)
+
+  return {
+    name: normalizeTextValue(data?.name),
+    identifier: normalizeTextValue(data?.identifier),
+    dataType,
+    required: data?.required ?? 0,
+    unit: normalizeTextValue(data?.unit),
+    accessMode: normalizeTextValue(data?.accessMode),
+    defaultValue: normalizeTextValue(data?.defaultValue),
+    maxValue: data?.maxValue,
+    minValue: data?.minValue,
+    sort: parseNumberValue(data?.sort),
+    actualValue: dataType === 'double' ? parseNumberValue(data?.actualValue) : data?.actualValue,
+    dropdownList: data?.dropdownList
+  }
+}
+
+function parseExtPropertyList(data?: ExtPropertyItem[] | unknown): unknown[] {
+  if (Array.isArray(data)) return data
+
+  if (typeof data === 'string') {
+    const text = data.trim()
+    if (!text) return []
+
+    try {
+      return parseExtPropertyList(JSON.parse(text))
+    } catch {
+      return []
+    }
+  }
+
+  if (data && typeof data === 'object') {
+    const values = Object.values(data as Record<string, unknown>)
+
+    return values.every((item) => item && typeof item === 'object') ? values : []
+  }
+
+  return []
+}
+
+function cloneExtProperties(data?: ExtPropertyItem[] | unknown): ExtPropertyItem[] {
+  return parseExtPropertyList(data)
+    .map((item) => normalizeExtProperty(item as Partial<ExtPropertyItem>))
+    .filter((item) => item.identifier)
+}
+
+function mergeExtProperties(
+  defaults: ExtPropertyItem[],
+  current?: ExtPropertyItem[] | unknown
+): ExtPropertyItem[] {
+  const currentItems = cloneExtProperties(current)
+  if (!defaults.length) return currentItems
+
+  const currentMap = new Map(currentItems.map((item) => [item.identifier, item]))
+  const defaultIdentifiers = new Set(defaults.map((item) => item.identifier))
+  const mergedDefaults = defaults.map((item) => {
+    const currentItem = currentMap.get(item.identifier)
+
+    return {
+      ...normalizeExtProperty(item),
+      actualValue:
+        currentItem && 'actualValue' in currentItem ? currentItem.actualValue : item.actualValue
+    }
+  })
+  const extraItems = currentItems.filter((item) => !defaultIdentifiers.has(item.identifier))
+
+  return [...mergedDefaults, ...extraItems].sort(
+    (a, b) => Number(a.sort || 0) - Number(b.sort || 0)
+  )
+}
+
 const normalizeDetailItem = (data?: Record<string, unknown>): DetailItem => ({
   raw: data || {},
-  projectName: String(data?.projectName || ''),
+  projectName: normalizeTextValue(data?.projectName),
   currentRevenue: parseNumberValue(data?.currentRevenue),
   cumulativeRevenue: parseNumberValue(data?.cumulativeRevenue),
+  beforeRevenue: parseNumberValue(data?.beforeRevenue),
   currentOnAccount: parseNumberValue(data?.currentOnAccount),
   cumulativeOnAccount: parseNumberValue(data?.cumulativeOnAccount),
+  beforeOnAccount: parseNumberValue(data?.beforeOnAccount),
   currentPayment: parseNumberValue(data?.currentPayment),
   cumulativePayment: parseNumberValue(data?.cumulativePayment),
-  plannedWorkload: String(data?.plannedWorkload || ''),
-  actualCompletion: String(data?.actualCompletion || ''),
+  beforePayment: parseNumberValue(data?.beforePayment),
+  plannedWorkload: normalizeTextValue(data?.plannedWorkload),
+  actualCompletion: normalizeTextValue(data?.actualCompletion),
   equipmentUtilizationRate: parseNumberValue(data?.equipmentUtilizationRate),
-  keyWorkCompletion: String(data?.keyWorkCompletion || ''),
-  problemsAnalysis: String(data?.problemsAnalysis || ''),
-  nextPlannedWorkload: String(data?.nextPlannedWorkload || ''),
-  priorityTasks: String(data?.priorityTasks || '')
+  keyWorkCompletion: normalizeTextValue(data?.keyWorkCompletion),
+  problemsAnalysis: normalizeTextValue(data?.problemsAnalysis),
+  qhse: normalizeTextValue(data?.qhse),
+  nextPlannedWorkload: normalizeTextValue(data?.nextPlannedWorkload),
+  priorityTasks: normalizeTextValue(data?.priorityTasks),
+  extProperty: mergeExtProperties(extProperties.value, data?.extProperty)
 })
 
+const normalizeMeetingFormItem = (
+  data: Record<string, unknown> = {},
+  index: number
+): OperationMeetingFormItem => {
+  const details = Array.isArray(data.details) ? data.details : []
+  const id = data.id === undefined || data.id === null ? index : String(data.id)
+
+  return {
+    key: `${id}-${index}`,
+    meeting: {
+      id: parseNumberValue(data.id),
+      deptId: parseNumberValue(data.deptId),
+      companyName: normalizeTextValue(data.companyName),
+      meetingDate: data.meetingDate as number | string | Date | undefined,
+      support: normalizeTextValue(data.support),
+      cumulative: data.cumulative as boolean | undefined,
+      meetingSeries: parseMeetingSeries(data.meetingSeries)
+    },
+    details: details.map((item) => normalizeDetailItem(item as Record<string, unknown>)),
+    extProperty: cloneExtProperties(data.extProperty),
+    beforeRevenue: parseNumberValue(data.beforeRevenue),
+    beforeOnAccount: parseNumberValue(data.beforeOnAccount),
+    beforePayment: parseNumberValue(data.beforePayment)
+  }
+}
+
+const loadCurrentCompanyExtProperties = async () => {
+  if (extPropertiesLoaded.value) return
+
+  try {
+    const data = await OperationMeetingApi.getCurrentCompanyExtProperties()
+    extProperties.value = cloneExtProperties(data)
+  } catch {
+    extProperties.value = []
+  } finally {
+    extPropertiesLoaded.value = true
+  }
+}
+
 const requiredTextRule = (message: string) => [
   {
     required: true,
@@ -140,6 +305,8 @@ const operationMeetingRules = reactive<FormRules>({
 })
 
 const handleAddDetailItem = () => {
+  if (isSummaryMode.value) return
+
   detailFormType.value = 'create'
   detailEditingIndex.value = -1
   detailForm.value = createDetailItem()
@@ -148,13 +315,15 @@ const handleAddDetailItem = () => {
 
 const handleEditDetailItem = (row: DetailItem, index: number) => {
   detailFormType.value = 'edit'
-  detailEditingIndex.value = index
+  detailEditingIndex.value = isSummaryMode.value ? -1 : index
   detailForm.value = cloneDetailItem(row)
   detailDrawerVisible.value = true
 }
 
 const handleDeleteDetailItem = (index: number) => {
-  detailItems.value.splice(index, 1)
+  if (isSummaryMode.value) return
+
+  activeFormItem.value.details.splice(index, 1)
 }
 
 const handleDetailDrawerChange = (visible: boolean) => {
@@ -168,48 +337,79 @@ const handleDetailDrawerChange = (visible: boolean) => {
 }
 
 const updateOperationMeeting = (nextOperationMeeting: OperationMeetingForm) => {
-  operationMeeting.value = nextOperationMeeting
+  activeFormItem.value.meeting = nextOperationMeeting
 }
 
 const saveDetailItem = (item: DetailItem) => {
+  if (isSummaryMode.value) return
+
   const nextItem = cloneDetailItem(item)
+  const currentDetails = activeFormItem.value.details
 
   if (detailEditingIndex.value > -1) {
-    detailItems.value.splice(detailEditingIndex.value, 1, nextItem)
+    currentDetails.splice(detailEditingIndex.value, 1, nextItem)
   } else {
-    detailItems.value.push(nextItem)
+    currentDetails.push(nextItem)
   }
 
   handleDetailDrawerChange(false)
 }
 
 const resetForm = () => {
-  operationMeeting.value = {}
-  detailItems.value = []
+  form.value = isSummaryMode.value ? [] : [createMeetingFormItem()]
+  filterForm.companyFilterValue = ''
   loading.value = false
   handleDetailDrawerChange(false)
   nextTick(() => operationMeetingRef.value?.clearValidate())
 }
 
 const loadOperationMeetingDetail = async (id: number) => {
+  await loadCurrentCompanyExtProperties()
+
   loading.value = true
   try {
     const data = await OperationMeetingApi.getOperationMeeting(id)
 
     if (!props.visible) return
 
-    operationMeeting.value = {
-      id: data?.id,
-      deptId: data?.deptId,
-      companyName: data?.companyName || '',
-      meetingDate: data?.meetingDate,
-      support: data?.support || '',
-      meetingSeries: parseMeetingSeries(data?.meetingSeries)
+    form.value = [normalizeMeetingFormItem((data || {}) as Record<string, unknown>, 0)]
+    nextTick(() => operationMeetingRef.value?.clearValidate())
+  } finally {
+    loading.value = false
+  }
+}
+
+const loadSummaryDetails = async () => {
+  await loadCurrentCompanyExtProperties()
+
+  if (!props.meetingSeries || !props.year) {
+    form.value = []
+    return
+  }
+
+  const meetingSeries = props.meetingSeries
+  const year = props.year
+  loading.value = true
+  try {
+    const res = await OperationMeetingApi.getSummarizedProjectDetails({
+      meetingSeries,
+      year
+    })
+
+    if (
+      !props.visible ||
+      props.mode !== 'summary' ||
+      props.meetingSeries !== meetingSeries ||
+      props.year !== year
+    ) {
+      return
     }
 
-    const details = Array.isArray(data?.details) ? data.details : []
-    detailItems.value = details.map((item) => normalizeDetailItem(item as Record<string, unknown>))
-    nextTick(() => operationMeetingRef.value?.clearValidate())
+    const meetings = Array.isArray(res) ? res : []
+    form.value = meetings.map((item, index) =>
+      normalizeMeetingFormItem(item as Record<string, unknown>, index)
+    )
+    filterForm.companyFilterValue = ''
   } finally {
     loading.value = false
   }
@@ -224,10 +424,18 @@ const handleVisibleChange = (visible: boolean) => {
 }
 
 watch(
-  () => [props.visible, props.id, props.type] as const,
-  ([visible, id, type]) => {
+  () => [props.visible, props.id, props.type, props.mode, props.meetingSeries, props.year] as const,
+  async ([visible, id, type, mode]) => {
     if (!visible) return
 
+    await loadCurrentCompanyExtProperties()
+    if (!props.visible) return
+
+    if (mode === 'summary') {
+      loadSummaryDetails()
+      return
+    }
+
     if (type === 'create') {
       resetForm()
       return
@@ -235,7 +443,10 @@ watch(
 
     if (id) {
       loadOperationMeetingDetail(id)
+      return
     }
+
+    resetForm()
   }
 )
 
@@ -273,7 +484,7 @@ const buildDetailPayload = (item: DetailItem): Omit<DetailItem, 'raw'> => {
 }
 
 const submitForm = async () => {
-  if (props.type === 'view' || !operationMeetingRef.value) return
+  if (isSummaryMode.value || props.type === 'view' || !operationMeetingRef.value) return
 
   const valid = await operationMeetingRef.value.validate().catch(() => false)
 
@@ -307,41 +518,45 @@ const submitForm = async () => {
     body-class="bg-gray-100"
     footer-class="p-4!"
     :size="drawerSize"
-    :with-header="false"
-  >
+    :with-header="false">
     <el-form
       ref="operationMeetingRef"
       label-width="auto"
       label-position="top"
       size="default"
-      :model="operationMeeting"
-      :rules="operationMeetingRules"
+      :model="isSummaryMode ? filterForm : operationMeeting"
+      :rules="isSummaryMode ? undefined : operationMeetingRules"
       v-loading="loading"
       scroll-to-error
-      require-asterisk-position="right"
-    >
+      require-asterisk-position="right">
       <OperationMeetingContent
+        v-if="!isSummaryMode || form.length"
         :meeting="operationMeeting"
         :details="detailItems"
-        :type="type"
+        :type="detailDrawerType"
+        :mode="mode"
+        :meetings="form"
+        :ext-properties="extProperties"
         :dept-options="deptOptions"
         :loading="loading"
+        v-model:company-filter-value="filterForm.companyFilterValue"
         @update:meeting="updateOperationMeeting"
         @add-detail="handleAddDetailItem"
         @edit-detail="handleEditDetailItem"
-        @delete-detail="handleDeleteDetailItem"
-      />
+        @delete-detail="handleDeleteDetailItem" />
+      <el-empty v-else description="暂无汇总会议详情" :image-size="100" />
     </el-form>
 
     <template #footer>
-      <el-button size="default" @click="handleVisibleChange(false)">取消</el-button>
+      <el-button size="default" @click="handleVisibleChange(false)">
+        {{ isSummaryMode ? '关闭' : '取消' }}
+      </el-button>
       <el-button
-        v-if="type !== 'view'"
+        v-if="!isSummaryMode && type !== 'view'"
         size="default"
         type="primary"
         :loading="loading"
-        @click="submitForm"
-      >
+        @click="submitForm">
         保存
       </el-button>
     </template>
@@ -349,10 +564,10 @@ const submitForm = async () => {
     <MeetingDetailDrawer
       :visible="detailDrawerVisible"
       :detail="detailForm"
-      :type="type"
+      :type="detailDrawerType"
       :form-type="detailFormType"
+      :ext-properties="extProperties"
       @update:visible="handleDetailDrawerChange"
-      @save="saveDetailItem"
-    />
+      @save="saveDetailItem" />
   </el-drawer>
 </template>

+ 0 - 1116
src/views/pms/operation-meeting/summary-form.vue

@@ -1,1116 +0,0 @@
-<script lang="ts" setup>
-import type { CSSProperties } from 'vue'
-import type { DeptOption, DetailItem, OperationMeeting } from './types'
-import MeetingDetailDrawer from './components/meeting-detail-drawer.vue'
-import { OperationMeetingApi } from '@/api/pms/meeting'
-import { useTableComponents } from '@/components/ZmTable/useTableComponents'
-import { useWindowSize } from '@vueuse/core'
-
-interface Props {
-  visible: boolean
-  meetingSeries?: string
-  year?: string | number
-  deptOptions?: DeptOption[]
-}
-
-interface OperationMeetingForm
-  extends Omit<Partial<OperationMeeting>, 'meetingDate' | 'meetingSeries'> {
-  meetingDate?: number | string | Date
-  meetingSeries?: number
-}
-
-interface SummaryMeetingItem {
-  key: string
-  meeting: OperationMeetingForm
-  details: DetailItem[]
-}
-
-interface SummaryDetailItem extends DetailItem {
-  summaryDetailKey: string
-  meetingKey: string
-  companyName: string
-  companyFilterValue: string
-  deptId?: number
-}
-
-interface CompanyFilterOption {
-  label: string
-  value: string
-}
-
-interface DetailCardField {
-  label: string
-  prop: keyof DetailItem
-  unit?: string
-  numeric?: boolean
-}
-
-interface MeetingTableCellStyleProps {
-  column: {
-    property?: string
-  }
-}
-
-const props = withDefaults(defineProps<Props>(), {
-  meetingSeries: '',
-  year: '',
-  deptOptions: () => []
-})
-
-const emits = defineEmits<{
-  'update:visible': [visible: boolean]
-}>()
-
-const { ZmTable, ZmTableColumn } = useTableComponents<SummaryDetailItem>()
-const { width } = useWindowSize()
-const drawerSize = computed(() => (width.value <= 768 ? '100%' : '100%'))
-
-const loading = ref(false)
-const summaryMeetings = ref<SummaryMeetingItem[]>([])
-const detailDrawerVisible = ref(false)
-const detailForm = ref<DetailItem>(createDetailItem())
-const filterForm = reactive({
-  companyFilterValue: ''
-})
-
-const detailSummaryFields = [
-  'currentRevenue',
-  'cumulativeRevenue',
-  'currentOnAccount',
-  'cumulativeOnAccount',
-  'currentPayment',
-  'cumulativePayment'
-] as const
-
-type DetailSummaryField = (typeof detailSummaryFields)[number]
-
-const detailSummaryLabelMap: Record<DetailSummaryField, string> = {
-  currentRevenue: '收入-本期',
-  cumulativeRevenue: '收入-累计',
-  currentOnAccount: '挂帐-本期',
-  cumulativeOnAccount: '挂帐-累计',
-  currentPayment: '回款-本期',
-  cumulativePayment: '回款-累计'
-}
-
-const detailSummaryToneMap: Record<DetailSummaryField, 'revenue' | 'account' | 'payment'> = {
-  currentRevenue: 'revenue',
-  cumulativeRevenue: 'revenue',
-  currentOnAccount: 'account',
-  cumulativeOnAccount: 'account',
-  currentPayment: 'payment',
-  cumulativePayment: 'payment'
-}
-
-const detailSummaryIconMap: Record<DetailSummaryField, string> = {
-  currentRevenue: 'i-lucide:badge-japanese-yen',
-  cumulativeRevenue: 'i-lucide:badge-japanese-yen',
-  currentOnAccount: 'i-lucide:badge-alert',
-  cumulativeOnAccount: 'i-lucide:badge-alert',
-  currentPayment: 'i-lucide:badge-check',
-  cumulativePayment: 'i-lucide:badge-check'
-}
-
-const detailCardGroups: { title: string; fields: DetailCardField[] }[] = [
-  {
-    title: '经营情况',
-    fields: [
-      { label: '收入-本期', prop: 'currentRevenue', unit: '万元', numeric: true },
-      { label: '收入-累计', prop: 'cumulativeRevenue', unit: '万元', numeric: true },
-      { label: '挂帐-本期', prop: 'currentOnAccount', unit: '万元', numeric: true },
-      { label: '挂帐-累计', prop: 'cumulativeOnAccount', unit: '万元', numeric: true },
-      { label: '回款-本期', prop: 'currentPayment', unit: '万元', numeric: true },
-      { label: '回款-累计', prop: 'cumulativePayment', unit: '万元', numeric: true }
-    ]
-  },
-  {
-    title: '本期生产运行情况',
-    fields: [
-      { label: '计划工作量', prop: 'plannedWorkload' },
-      { label: '实际完成', prop: 'actualCompletion' },
-      { label: '设备利用率', prop: 'equipmentUtilizationRate', unit: '%', numeric: true }
-    ]
-  },
-  {
-    title: '生产管理情况及重点工作',
-    fields: [
-      { label: '重点工作及完成情况', prop: 'keyWorkCompletion' },
-      { label: '存在问题及分析', prop: 'problemsAnalysis' }
-    ]
-  },
-  {
-    title: '下期工作计划',
-    fields: [
-      { label: '计划工作量', prop: 'nextPlannedWorkload' },
-      { label: '重点工作事项', prop: 'priorityTasks' }
-    ]
-  }
-]
-
-const meetingTableBlueColumns = new Set<keyof DetailItem>([
-  'projectName',
-  'actualCompletion',
-  'keyWorkCompletion',
-  'problemsAnalysis'
-])
-
-function createDetailItem(): DetailItem {
-  return {
-    raw: {},
-    projectName: '',
-    currentRevenue: undefined,
-    cumulativeRevenue: undefined,
-    currentOnAccount: undefined,
-    cumulativeOnAccount: undefined,
-    currentPayment: undefined,
-    cumulativePayment: undefined,
-    plannedWorkload: '',
-    actualCompletion: '',
-    equipmentUtilizationRate: undefined,
-    keyWorkCompletion: '',
-    problemsAnalysis: '',
-    nextPlannedWorkload: '',
-    priorityTasks: ''
-  }
-}
-
-const cloneDetailItem = (data?: Partial<DetailItem>): DetailItem => ({
-  raw: data?.raw || {},
-  projectName: data?.projectName || '',
-  currentRevenue: data?.currentRevenue,
-  cumulativeRevenue: data?.cumulativeRevenue,
-  currentOnAccount: data?.currentOnAccount,
-  cumulativeOnAccount: data?.cumulativeOnAccount,
-  currentPayment: data?.currentPayment,
-  cumulativePayment: data?.cumulativePayment,
-  plannedWorkload: data?.plannedWorkload || '',
-  actualCompletion: data?.actualCompletion || '',
-  equipmentUtilizationRate: data?.equipmentUtilizationRate,
-  keyWorkCompletion: data?.keyWorkCompletion || '',
-  problemsAnalysis: data?.problemsAnalysis || '',
-  nextPlannedWorkload: data?.nextPlannedWorkload || '',
-  priorityTasks: data?.priorityTasks || ''
-})
-
-const parseNumberValue = (value: unknown) => {
-  if (value === undefined || value === null || value === '') return undefined
-  const parsed = Number(String(value).replace('%', ''))
-
-  return Number.isNaN(parsed) ? undefined : parsed
-}
-
-const parseMeetingSeries = (value: unknown) => {
-  if (value === undefined || value === null || value === '') return undefined
-  const matched = String(value).match(/\d+/)
-
-  return matched ? Number(matched[0]) : undefined
-}
-
-const normalizeTextValue = (value: unknown) => String(value ?? '').replace(/\\r\\n|\\n/g, '\n')
-
-const normalizeDetailItem = (data?: Record<string, unknown>): DetailItem => ({
-  raw: data || {},
-  projectName: normalizeTextValue(data?.projectName),
-  currentRevenue: parseNumberValue(data?.currentRevenue),
-  cumulativeRevenue: parseNumberValue(data?.cumulativeRevenue),
-  currentOnAccount: parseNumberValue(data?.currentOnAccount),
-  cumulativeOnAccount: parseNumberValue(data?.cumulativeOnAccount),
-  currentPayment: parseNumberValue(data?.currentPayment),
-  cumulativePayment: parseNumberValue(data?.cumulativePayment),
-  plannedWorkload: normalizeTextValue(data?.plannedWorkload),
-  actualCompletion: normalizeTextValue(data?.actualCompletion),
-  equipmentUtilizationRate: parseNumberValue(data?.equipmentUtilizationRate),
-  keyWorkCompletion: normalizeTextValue(data?.keyWorkCompletion),
-  problemsAnalysis: normalizeTextValue(data?.problemsAnalysis),
-  nextPlannedWorkload: normalizeTextValue(data?.nextPlannedWorkload),
-  priorityTasks: normalizeTextValue(data?.priorityTasks)
-})
-
-const normalizeSummaryMeeting = (
-  data: Record<string, unknown>,
-  index: number
-): SummaryMeetingItem => {
-  const details = Array.isArray(data.details) ? data.details : []
-  const id = data.id === undefined || data.id === null ? index : String(data.id)
-
-  return {
-    key: `${id}-${index}`,
-    meeting: {
-      id: parseNumberValue(data.id),
-      deptId: parseNumberValue(data.deptId),
-      companyName: String(data.companyName || ''),
-      meetingDate: data.meetingDate as number | string | Date | undefined,
-      support: String(data.support || ''),
-      cumulative: data.cumulative as boolean | undefined,
-      meetingSeries: parseMeetingSeries(data.meetingSeries)
-    },
-    details: details.map((item) => normalizeDetailItem(item as Record<string, unknown>))
-  }
-}
-
-const getCompanyDisplayName = (meeting: OperationMeetingForm) => {
-  if (meeting.companyName) return meeting.companyName
-
-  if (meeting.deptId) {
-    return props.deptOptions.find((item) => item.value === meeting.deptId)?.label || ''
-  }
-
-  return ''
-}
-
-const getCompanyFilterValue = (meeting: OperationMeetingForm, index: number) => {
-  if (meeting.deptId !== undefined) return `dept:${meeting.deptId}`
-
-  const companyName = getCompanyDisplayName(meeting).trim()
-
-  return companyName ? `company:${companyName}` : `unknown:${index}`
-}
-
-const summaryMeetingMeta = computed<OperationMeetingForm>(
-  () => summaryMeetings.value[0]?.meeting || {}
-)
-
-const companyOptions = computed<CompanyFilterOption[]>(() => {
-  const optionMap = new Map<string, CompanyFilterOption>()
-
-  summaryMeetings.value.forEach((item, index) => {
-    const value = getCompanyFilterValue(item.meeting, index)
-
-    if (optionMap.has(value)) return
-
-    optionMap.set(value, {
-      value,
-      label: getCompanyDisplayName(item.meeting) || '未知公司'
-    })
-  })
-
-  return Array.from(optionMap.values())
-})
-
-const summaryDetailRows = computed<SummaryDetailItem[]>(() =>
-  summaryMeetings.value.flatMap((item, meetingIndex) => {
-    const companyFilterValue = getCompanyFilterValue(item.meeting, meetingIndex)
-    const companyName = getCompanyDisplayName(item.meeting) || '未知公司'
-
-    return item.details.map((detail, detailIndex) => ({
-      ...detail,
-      summaryDetailKey: `${item.key}-${detailIndex}`,
-      meetingKey: item.key,
-      companyName,
-      cumulative: item.meeting.cumulative,
-      companyFilterValue,
-      deptId: item.meeting.deptId
-    }))
-  })
-)
-
-const filteredDetailRows = computed(() => {
-  if (!filterForm.companyFilterValue) return summaryDetailRows.value
-
-  return summaryDetailRows.value.filter(
-    (item) => item.companyFilterValue === filterForm.companyFilterValue
-  )
-})
-
-const currentSummaryScopeName = computed(
-  () =>
-    companyOptions.value.find((item) => item.value === filterForm.companyFilterValue)?.label ||
-    '全部公司'
-)
-
-const currentSummarySupport = computed(() => {
-  if (!filterForm.companyFilterValue) return ''
-
-  return (
-    summaryMeetings.value.find(
-      (item, index) => getCompanyFilterValue(item.meeting, index) === filterForm.companyFilterValue
-    )?.meeting.support || ''
-  )
-})
-
-const formatSummaryNumber = (value: number) =>
-  value.toLocaleString('zh-CN', {
-    maximumFractionDigits: 2,
-    minimumFractionDigits: Number.isInteger(value) ? 0 : 2
-  })
-
-const getDetailSummaryTotal = (field: DetailSummaryField) =>
-  filteredDetailRows.value.reduce((sum, item) => {
-    if (item.companyFilterValue === filterForm.companyFilterValue || (item as any).cumulative) {
-      return sum + Number(item[field] || 0)
-    }
-    return sum
-  }, 0)
-
-const detailSummaryCards = computed(() =>
-  detailSummaryFields.map((field) => ({
-    label: detailSummaryLabelMap[field],
-    value: formatSummaryNumber(getDetailSummaryTotal(field)),
-    tone: detailSummaryToneMap[field],
-    icon: detailSummaryIconMap[field]
-  }))
-)
-
-const formatDetailCardValue = (item: SummaryDetailItem, field: DetailCardField) => {
-  const value = item[field.prop]
-
-  if (value === undefined || value === null || value === '') {
-    return '-'
-  }
-
-  if (!field.numeric) {
-    return String(value)
-  }
-
-  const numericValue = Number(value)
-
-  if (Number.isNaN(numericValue)) {
-    return '-'
-  }
-
-  return `${formatSummaryNumber(numericValue)}${field.unit || ''}`
-}
-
-const formatEquipmentUtilizationRate = (row: SummaryDetailItem) => {
-  if (row.equipmentUtilizationRate === undefined || row.equipmentUtilizationRate === null) {
-    return '-'
-  }
-
-  return `${formatSummaryNumber(Number(row.equipmentUtilizationRate))}%`
-}
-
-const getMeetingTableCellStyle: any = ({
-  column
-}: MeetingTableCellStyleProps): CSSProperties | undefined => {
-  const property = column.property as keyof DetailItem | undefined
-
-  if (property && meetingTableBlueColumns.has(property)) {
-    return { color: '#1b71f6' }
-  }
-
-  return undefined
-}
-
-const getMeetingTableRowStyle = (): CSSProperties => ({
-  height: 'auto',
-  minHeight: '40px'
-})
-
-const getMeetingTableCellClassName = ({ column }: MeetingTableCellStyleProps) => {
-  const property = column.property as keyof DetailItem | undefined
-
-  return property ? 'meeting-table__cell' : ''
-}
-
-const resetForm = () => {
-  summaryMeetings.value = []
-  filterForm.companyFilterValue = ''
-  loading.value = false
-  detailDrawerVisible.value = false
-  detailForm.value = createDetailItem()
-}
-
-const handleVisibleChange = (visible: boolean) => {
-  emits('update:visible', visible)
-
-  if (!visible) {
-    resetForm()
-  }
-}
-
-const loadSummaryDetails = async () => {
-  if (!props.meetingSeries || !props.year) {
-    summaryMeetings.value = []
-    return
-  }
-
-  const meetingSeries = props.meetingSeries
-  const year = props.year
-  loading.value = true
-  try {
-    const res = await OperationMeetingApi.getSummarizedProjectDetails({
-      meetingSeries,
-      year
-    })
-
-    if (!props.visible || props.meetingSeries !== meetingSeries || props.year !== year) return
-
-    const meetings = Array.isArray(res) ? res : []
-    summaryMeetings.value = meetings.map((item, index) =>
-      normalizeSummaryMeeting(item as Record<string, unknown>, index)
-    )
-    filterForm.companyFilterValue = ''
-  } finally {
-    loading.value = false
-  }
-}
-
-const handleViewDetail = (row: SummaryDetailItem) => {
-  detailForm.value = cloneDetailItem(row)
-  detailDrawerVisible.value = true
-}
-
-const handleDetailDrawerChange = (visible: boolean) => {
-  detailDrawerVisible.value = visible
-
-  if (!visible) {
-    detailForm.value = createDetailItem()
-  }
-}
-
-watch(
-  () => [props.visible, props.meetingSeries, props.year] as const,
-  ([visible]) => {
-    if (!visible) return
-    loadSummaryDetails()
-  }
-)
-</script>
-
-<template>
-  <el-drawer
-    :model-value="visible"
-    @update:model-value="handleVisibleChange"
-    body-class="bg-gray-100"
-    footer-class="p-4!"
-    :size="drawerSize"
-    :with-header="false"
-  >
-    <div v-loading="loading" class="summary-form">
-      <template v-if="summaryMeetings.length">
-        <el-form
-          label-width="auto"
-          label-position="top"
-          size="default"
-          :model="filterForm"
-          class="summary-form__section"
-        >
-          <div class="meeting-section__top">
-            <h2 class="meeting-section__title">生产运营双周例会汇报</h2>
-
-            <div class="meeting-section__grid">
-              <el-form-item label="会议期次" label-position="left" class="mb-0! min-w-0">
-                <el-input-number
-                  :model-value="summaryMeetingMeta.meetingSeries"
-                  class="w-full!"
-                  placeholder="暂无会议期次"
-                  disabled
-                  :controls="false"
-                />
-              </el-form-item>
-
-              <el-form-item
-                label="专业公司"
-                label-position="left"
-                prop="companyFilterValue"
-                class="mb-0! min-w-0"
-              >
-                <el-select
-                  v-model="filterForm.companyFilterValue"
-                  class="w-full!"
-                  placeholder="全部公司"
-                  clearable
-                  filterable
-                >
-                  <el-option
-                    v-for="item in companyOptions"
-                    :key="item.value"
-                    :label="item.label"
-                    :value="item.value"
-                  />
-                </el-select>
-              </el-form-item>
-
-              <el-form-item label="会议日期" label-position="left" class="mb-0! min-w-0">
-                <el-date-picker
-                  :model-value="summaryMeetingMeta.meetingDate"
-                  type="date"
-                  placeholder="暂无会议日期"
-                  disabled
-                  class="w-full!"
-                />
-              </el-form-item>
-            </div>
-          </div>
-
-          <section class="meeting-summary-strip">
-            <div class="meeting-summary-strip__title">
-              <span>{{ currentSummaryScopeName }}</span>
-              <small>经营数据汇总</small>
-            </div>
-            <div class="meeting-summary-strip__grid">
-              <div
-                v-for="item in detailSummaryCards"
-                :key="item.label"
-                :class="[
-                  'meeting-summary-strip__item',
-                  `meeting-summary-strip__item--${item.tone}`
-                ]"
-              >
-                <div :class="item.icon + ' size-5 icon'"></div>
-                <span>{{ item.label }}</span>
-                <strong>{{ item.value }}<em>万元</em></strong>
-              </div>
-            </div>
-          </section>
-
-          <section class="meeting-detail-panel">
-            <div class="meeting-detail-panel__header">
-              <h3 class="text-lg font-bold m-0">会议明细</h3>
-              <el-tag size="small" effect="plain">共 {{ filteredDetailRows.length }} 项</el-tag>
-            </div>
-
-            <div class="meeting-detail-table-view">
-              <ZmTable
-                class="meeting-table"
-                :data="filteredDetailRows"
-                :loading="loading"
-                :max-height="650"
-                align="left"
-                :show-overflow-tooltip="false"
-                :cell-style="getMeetingTableCellStyle"
-                :row-style="getMeetingTableRowStyle"
-                :cell-class-name="getMeetingTableCellClassName"
-              >
-                <zm-table-column
-                  min-width="6%"
-                  align="center"
-                  label="专业公司"
-                  prop="companyName"
-                  fixed="left"
-                />
-                <zm-table-column
-                  min-width="6%"
-                  align="center"
-                  label="项目名称"
-                  prop="projectName"
-                />
-                <zm-table-column min-width="24%" label="本期生产运行情况">
-                  <zm-table-column min-width="9%" label="计划工作量" prop="plannedWorkload" />
-                  <zm-table-column min-width="9%" label="实际完成" prop="actualCompletion" />
-                  <zm-table-column
-                    min-width="6%"
-                    label="设备利用率"
-                    prop="equipmentUtilizationRate"
-                    :formatter="formatEquipmentUtilizationRate"
-                  />
-                </zm-table-column>
-                <zm-table-column min-width="28%" label="生产管理情况及重点工作">
-                  <zm-table-column
-                    min-width="18%"
-                    label="重点工作及完成情况"
-                    prop="keyWorkCompletion"
-                  />
-                  <zm-table-column min-width="10%" label="存在问题及分析" prop="problemsAnalysis" />
-                </zm-table-column>
-                <zm-table-column min-width="27%" label="下期工作计划">
-                  <zm-table-column min-width="9%" label="计划工作量" prop="nextPlannedWorkload" />
-                  <zm-table-column min-width="18%" label="重点工作事项" prop="priorityTasks" />
-                </zm-table-column>
-                <zm-table-column label="操作" min-width="5%" align="center" fixed="right">
-                  <template #default="{ row }">
-                    <div class="meeting-table__actions">
-                      <el-button link size="default" type="primary" @click="handleViewDetail(row)">
-                        查看
-                      </el-button>
-                    </div>
-                  </template>
-                </zm-table-column>
-              </ZmTable>
-            </div>
-
-            <section v-if="currentSummarySupport" class="meeting-support-panel">
-              <el-form-item
-                label="其他重点事项及需要集团协调事项"
-                class="meeting-support-panel__item"
-              >
-                <el-input :model-value="currentSummarySupport" type="textarea" :rows="4" readonly />
-              </el-form-item>
-            </section>
-
-            <div v-loading="loading" class="meeting-detail-card-view">
-              <template v-if="filteredDetailRows.length">
-                <article
-                  v-for="(item, index) in filteredDetailRows"
-                  :key="item.summaryDetailKey"
-                  class="meeting-detail-card"
-                >
-                  <div class="meeting-detail-card__header">
-                    <div class="meeting-detail-card__title">
-                      <span>项目名称</span>
-                      <strong>{{ item.projectName || '-' }}</strong>
-                    </div>
-                    <el-tag size="small" effect="plain">第 {{ index + 1 }} 项</el-tag>
-                  </div>
-
-                  <div class="meeting-detail-card__company">
-                    <span>专业公司</span>
-                    <strong>{{ item.companyName || '-' }}</strong>
-                  </div>
-
-                  <section
-                    v-for="group in detailCardGroups"
-                    :key="group.title"
-                    class="meeting-detail-card__group"
-                  >
-                    <h4>{{ group.title }}</h4>
-                    <div class="meeting-detail-card__fields">
-                      <div
-                        v-for="field in group.fields"
-                        :key="field.prop"
-                        class="meeting-detail-card__field"
-                      >
-                        <span>{{ field.label }}</span>
-                        <strong>{{ formatDetailCardValue(item, field) }}</strong>
-                      </div>
-                    </div>
-                  </section>
-
-                  <div class="meeting-detail-card__actions">
-                    <el-button link size="small" type="primary" @click="handleViewDetail(item)">
-                      查看
-                    </el-button>
-                  </div>
-                </article>
-              </template>
-              <el-empty v-else description="暂无会议明细" :image-size="80" />
-            </div>
-          </section>
-        </el-form>
-      </template>
-
-      <el-empty v-else description="暂无汇总会议详情" :image-size="100" />
-    </div>
-
-    <template #footer>
-      <el-button size="default" @click="handleVisibleChange(false)">关闭</el-button>
-    </template>
-
-    <MeetingDetailDrawer
-      :visible="detailDrawerVisible"
-      :detail="detailForm"
-      type="view"
-      form-type="edit"
-      @update:visible="handleDetailDrawerChange"
-    />
-  </el-drawer>
-</template>
-
-<style scoped lang="scss">
-.summary-form {
-  min-height: 240px;
-}
-
-.summary-form__section {
-  padding: 8px;
-  background: #fff;
-  border: 1px solid rgb(229 231 235 / 90%);
-  border-radius: 12px;
-}
-
-.meeting-section__grid {
-  display: grid;
-  grid-template-columns: repeat(3, minmax(0, 1fr));
-  gap: 18px 20px;
-}
-
-.meeting-section__top {
-  display: grid;
-  grid-template-columns: max-content minmax(0, 1fr);
-  gap: 240px;
-  align-items: start;
-  margin-bottom: 18px;
-}
-
-.meeting-section__title {
-  padding-top: 4px;
-  margin: 0;
-  font-size: 18px;
-  font-weight: 800;
-  line-height: 32px;
-  color: #1f2937;
-  white-space: nowrap;
-  transform: translateY(-4px);
-}
-
-:deep(.el-form-item__label) {
-  font-weight: 800;
-  color: #1f2937;
-}
-
-.meeting-summary-strip {
-  display: grid;
-  grid-template-columns: 112px minmax(0, 1fr);
-  gap: 8px;
-  align-items: stretch;
-  padding: 6px 8px;
-  background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
-  border: 1px solid #dbeafe;
-  border-radius: 8px;
-}
-
-.meeting-summary-strip__title {
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  min-width: 0;
-  padding-right: 8px;
-  border-right: 1px solid #dbeafe;
-
-  span {
-    overflow: hidden;
-    font-size: 14px;
-    font-weight: 800;
-    line-height: 20px;
-    color: #1f2937;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-
-  small {
-    margin-top: 0;
-    font-size: 12px;
-    font-weight: 800;
-    line-height: 16px;
-    color: #64748b;
-  }
-}
-
-.meeting-summary-strip__grid {
-  display: grid;
-  grid-template-columns: repeat(6, minmax(0, 1fr));
-  gap: 4px;
-}
-
-.meeting-summary-strip__item {
-  display: flex;
-  min-width: 0;
-  align-items: center;
-  justify-content: space-between;
-  gap: 4px;
-  padding: 4px 6px;
-  background: rgb(255 255 255 / 72%);
-  border: 1px solid rgb(219 234 254 / 70%);
-  border-radius: 7px;
-
-  span {
-    display: block;
-    flex: 1;
-    min-width: 0;
-    overflow: hidden;
-    font-size: 12px;
-    font-weight: 800;
-    line-height: 16px;
-    color: #1f2937;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-
-  strong {
-    display: flex;
-    min-width: 0;
-    flex-shrink: 0;
-    align-items: baseline;
-    gap: 3px;
-    margin-top: 0;
-    overflow: hidden;
-    font-size: 15px;
-    font-weight: 800;
-    line-height: 18px;
-    color: #1b71f6;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-
-  em {
-    font-size: 12px;
-    font-style: normal;
-    font-weight: 800;
-    color: #64748b;
-  }
-}
-
-.meeting-summary-strip__item--revenue strong,
-.meeting-summary-strip__item--revenue .icon {
-  color: #1b71f6;
-}
-
-.meeting-summary-strip__item--revenue {
-  background: linear-gradient(180deg, #eff6ff 0%, #fff 100%);
-  border-color: #bfdbfe;
-}
-
-.meeting-summary-strip__item--account strong,
-.meeting-summary-strip__item--account .icon {
-  color: #f59e0b;
-}
-
-.meeting-summary-strip__item--account {
-  background: linear-gradient(180deg, #fffbeb 0%, #fff 100%);
-  border-color: #fde68a;
-}
-
-.meeting-summary-strip__item--payment strong,
-.meeting-summary-strip__item--payment .icon {
-  color: #10b981;
-}
-
-.meeting-summary-strip__item--payment {
-  background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
-  border-color: #bbf7d0;
-}
-
-.meeting-detail-panel {
-  padding-top: 12px;
-  margin-top: 12px;
-  border-top: 1px solid var(--el-border-color-lighter);
-}
-
-.meeting-support-panel {
-  padding-top: 12px;
-  margin-top: 12px;
-  border-top: 1px solid var(--el-border-color-lighter);
-}
-
-.meeting-support-panel__item {
-  margin-bottom: 0;
-
-  :deep(.el-textarea__inner) {
-    font-size: 16px;
-    font-weight: 700;
-    color: #24364d;
-  }
-}
-
-.meeting-detail-panel__header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-bottom: 12px;
-}
-
-.meeting-table {
-  --zm-table-font-size: 18px;
-  --zm-table-header-font-size: 18px;
-  --zm-table-header-font-weight: 800;
-  --zm-table-row-font-weight: 800;
-  --zm-table-header-text-color: #333;
-  --zm-table-border-color: #cbd5e1;
-  --zm-table-header-border-color: #c2ccda;
-  --zm-table-row-border-color: #d4dce8;
-
-  :deep(.header-wrapper) {
-    height: 20px;
-    justify-content: center !important;
-    text-align: center;
-
-    .truncate {
-      width: 100%;
-      height: 20px;
-      text-align: center;
-    }
-  }
-
-  :deep(.el-table__body .el-table__cell) {
-    height: auto;
-    padding-top: 7px;
-    padding-bottom: 7px;
-    vertical-align: middle;
-
-    .cell {
-      line-height: 1.5;
-      word-break: break-word;
-      white-space: pre-wrap;
-      overflow-wrap: anywhere;
-    }
-  }
-
-  :deep(.el-table__body .el-table__row) {
-    height: auto;
-  }
-
-  :deep(.el-table__body .el-table__row > .el-table__cell) {
-    min-height: 40px;
-  }
-
-  :deep(.meeting-table__cell) {
-    .cell {
-      word-break: break-word;
-      white-space: pre-wrap;
-      overflow-wrap: anywhere;
-    }
-  }
-}
-
-.meeting-table__actions {
-  display: flex;
-  width: 100%;
-  align-items: center;
-  justify-content: center;
-  gap: 8px;
-
-  :deep(.el-button) {
-    margin-left: 0;
-  }
-}
-
-.meeting-detail-table-view {
-  display: block;
-}
-
-.meeting-detail-card-view {
-  display: none;
-}
-
-.meeting-detail-card {
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-  padding: 12px;
-  padding-bottom: 4px;
-  background: var(--el-bg-color);
-  border: 1px solid var(--el-border-color-lighter);
-  border-radius: 8px;
-  box-shadow: 0 2px 8px rgb(15 23 42 / 6%);
-}
-
-.meeting-detail-card__header,
-.meeting-detail-card__actions {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-}
-
-.meeting-detail-card__title {
-  display: flex;
-  min-width: 0;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.meeting-detail-card__title span,
-.meeting-detail-card__company span,
-.meeting-detail-card__field span {
-  font-size: 12px;
-  line-height: 1.4;
-  color: var(--el-text-color-secondary);
-}
-
-.meeting-detail-card__title strong {
-  overflow: hidden;
-  font-size: 16px;
-  line-height: 1.35;
-  color: var(--el-text-color-primary);
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.meeting-detail-card__company {
-  display: flex;
-  min-width: 0;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.meeting-detail-card__company strong {
-  min-width: 0;
-  font-size: 13px;
-  font-weight: 700;
-  line-height: 1.5;
-  color: var(--el-text-color-primary);
-  overflow-wrap: anywhere;
-}
-
-.meeting-detail-card__group {
-  padding-top: 12px;
-  border-top: 1px solid var(--el-border-color-lighter);
-}
-
-.meeting-detail-card__group h4 {
-  margin: 0 0 10px;
-  font-size: 14px;
-  font-weight: 700;
-  color: var(--el-text-color-primary);
-}
-
-.meeting-detail-card__fields {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 10px 12px;
-}
-
-.meeting-detail-card__field {
-  display: flex;
-  min-width: 0;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.meeting-detail-card__field strong {
-  min-width: 0;
-  font-size: 13px;
-  font-weight: 600;
-  line-height: 1.5;
-  color: var(--el-text-color-primary);
-  overflow-wrap: anywhere;
-  white-space: pre-wrap;
-}
-
-.meeting-detail-card__actions {
-  justify-content: flex-end;
-  padding-top: 2px;
-  border-top: 1px solid var(--el-border-color-lighter);
-}
-
-@media (width <= 960px) {
-  .meeting-section__top {
-    grid-template-columns: 1fr;
-    gap: 12px;
-  }
-
-  .meeting-section__title {
-    padding-top: 0;
-    line-height: 24px;
-    transform: translateY(0);
-  }
-
-  .meeting-section__grid {
-    grid-template-columns: 1fr;
-  }
-
-  .meeting-summary-strip {
-    grid-template-columns: 1fr;
-  }
-
-  .meeting-summary-strip__title {
-    padding-right: 0;
-    padding-bottom: 12px;
-    border-right: 0;
-    border-bottom: 1px solid #dbeafe;
-  }
-
-  .meeting-summary-strip__grid {
-    grid-template-columns: repeat(3, minmax(0, 1fr));
-  }
-}
-
-@media (width <= 768px) {
-  .meeting-detail-table-view {
-    display: none;
-  }
-
-  .meeting-detail-card-view {
-    display: flex;
-    min-height: 160px;
-    flex-direction: column;
-    gap: 12px;
-  }
-
-  .meeting-summary-strip__grid,
-  .meeting-detail-card__fields {
-    grid-template-columns: 1fr;
-  }
-}
-</style>

+ 11 - 16
src/views/pms/operation-meeting/summary.vue

@@ -2,7 +2,7 @@
 import { OperationMeetingApi } from '@/api/pms/meeting'
 import type { DeptOption, OperationMeetingListItem } from './types'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
-import SummaryForm from './summary-form.vue'
+import MeetingForm from './meeting-form.vue'
 import dayjs from 'dayjs'
 
 const loading = ref(false)
@@ -69,17 +69,14 @@ onMounted(() => {
 
 <template>
   <div
-    class="operation-meeting-page min-w-0 overflow-x-hidden grid grid-rows-[auto_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
-  >
+    class="operation-meeting-page min-w-0 overflow-x-hidden grid grid-rows-[auto_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <el-form
       size="default"
       label-position="left"
-      class="operation-meeting-query min-w-0 overflow-hidden rounded-lg bg-white p-4 shadow dark:bg-[#1d1e1f]"
-    >
+      class="operation-meeting-query min-w-0 overflow-hidden rounded-lg bg-white p-4 shadow dark:bg-[#1d1e1f]">
       <div class="min-w-0 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-end">
         <div
-          class="operation-meeting-query__actions min-w-0 flex flex-col gap-3 sm:flex-row lg:shrink-0"
-        >
+          class="operation-meeting-query__actions min-w-0 flex flex-col gap-3 sm:flex-row lg:shrink-0">
           <el-button type="primary" class="!ml-0 w-full sm:w-auto" @click="handleQuery">
             搜索
           </el-button>
@@ -89,8 +86,7 @@ onMounted(() => {
     </el-form>
 
     <div
-      class="operation-meeting-data-panel bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-4 min-h-0"
-    >
+      class="operation-meeting-data-panel bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-4 min-h-0">
       <div class="flex-1 min-h-0 relative">
         <el-auto-resizer class="operation-meeting-table-view absolute">
           <template #default="{ width, height }">
@@ -100,8 +96,7 @@ onMounted(() => {
               :width="width"
               :max-height="height"
               :height="height"
-              show-border
-            >
+              show-border>
               <ZmTableColumn label="会议期次" prop="meetingSeries" width="180" />
               <ZmTableColumn label="公司名称" prop="companyName" :min-width="220" />
               <ZmTableColumn
@@ -109,8 +104,7 @@ onMounted(() => {
                 prop="meetingDate"
                 cover-formatter
                 :real-value="formatMeetingDate"
-                :min-width="160"
-              />
+                :min-width="160" />
               <ZmTableColumn label="操作" width="100" fixed="right">
                 <template #default="{ row }">
                   <el-button size="default" link type="success" @click="handleView(row)">
@@ -156,13 +150,14 @@ onMounted(() => {
     </div>
   </div>
 
-  <summary-form
+  <meeting-form
     v-model:visible="visible"
+    mode="summary"
+    type="view"
     :meeting-series="currentMeetingSeries"
     :year="currentYear"
     :dept-options="deptOptions"
-    @update:visible="visible = $event"
-  />
+    @update:visible="visible = $event" />
 </template>
 
 <style scoped lang="scss">

+ 48 - 0
src/views/pms/operation-meeting/types.ts

@@ -13,22 +13,69 @@ export interface OperationMeeting {
   cumulative: boolean
 }
 
+export interface OperationMeetingForm
+  extends Omit<Partial<OperationMeeting>, 'meetingDate' | 'meetingSeries'> {
+  meetingDate?: number | string | Date
+  meetingSeries?: number
+}
+
+export interface OperationMeetingFormItem {
+  key: string
+  meeting: OperationMeetingForm
+  details: DetailItem[]
+  extProperty: ExtPropertyItem[]
+  beforeRevenue: number | undefined // 上期收入
+  beforeOnAccount: number | undefined // 上期挂帐
+  beforePayment: number | undefined // 上期回款
+}
+
+export type ExtPropertyActualValue = string | number | null | undefined
+
+export interface ExtPropertyItem {
+  name: string
+  identifier: string
+  dataType: string
+  required: number | string | boolean
+  unit?: string
+  accessMode?: string
+  defaultValue?: string
+  maxValue?: string | number
+  minValue?: string | number
+  sort?: number
+  actualValue?: ExtPropertyActualValue
+  dropdownList?: unknown
+}
+
 export interface DetailItem {
   raw: Record<string, unknown>
   projectName: string
   currentRevenue: number | undefined // 本期收入
   cumulativeRevenue: number | undefined // 累计收入
+  beforeRevenue: number | undefined // 上期收入
   currentOnAccount: number | undefined // 本期挂帐
   cumulativeOnAccount: number | undefined // 累计挂帐
+  beforeOnAccount: number | undefined // 上期挂帐
   currentPayment: number | undefined // 本期回款
   cumulativePayment: number | undefined // 累计回款
+  beforePayment: number | undefined // 上期回款
   plannedWorkload: string // 计划工作量
   actualCompletion: string // 实际完成
   equipmentUtilizationRate: number | undefined // 设备利用率
   keyWorkCompletion: string // 重点工作及完成情况
   problemsAnalysis: string // 存在问题及分析
+  qhse: string // 设备、安全管理工作
   nextPlannedWorkload: string // 下期计划工作量
   priorityTasks: string // 重点工作事项
+  extProperty: ExtPropertyItem[] // 扩展属性
+}
+
+export interface SummaryDetailItem extends DetailItem {
+  summaryDetailKey: string
+  meetingKey: string
+  companyName: string
+  companyFilterValue: string
+  cumulative?: boolean
+  deptId?: number
 }
 
 export interface OperationMeetingListItem {
@@ -41,3 +88,4 @@ export interface OperationMeetingListItem {
 }
 
 export type OperationMeetingOpenType = 'create' | 'edit' | 'readonly'
+export type OperationMeetingDrawerMode = 'fill' | 'summary'

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff