Zimo 3 hari lalu
induk
melakukan
d9b949f812

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

@@ -38,6 +38,12 @@ 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`
+    })
+  },
+
   getPreviousWorkPlan: async (params: {
     projectName: string
     id?: string | number

+ 186 - 27
src/views/pms/operation-meeting/components/meeting-detail-drawer.vue

@@ -1,6 +1,6 @@
 <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'
 
@@ -9,6 +9,7 @@ interface Props {
   detail?: DetailItem
   type: 'create' | 'edit' | 'view'
   formType: 'create' | 'edit'
+  extProperties?: ExtPropertyItem[]
 }
 
 interface ProjectNameSuggestion {
@@ -44,6 +45,19 @@ const projectNameSuggestions = computed<ProjectNameSuggestion[]>(() =>
   projectNameOptions.value.map((item) => ({ value: item }))
 )
 
+const hasExtProperties = computed(() => Boolean(props.extProperties?.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: {},
@@ -60,7 +74,8 @@ function createDetailItem(): DetailItem {
     keyWorkCompletion: '',
     problemsAnalysis: '',
     nextPlannedWorkload: '',
-    priorityTasks: ''
+    priorityTasks: '',
+    extProperty: cloneExtProperties(props.extProperties)
   }
 }
 
@@ -79,9 +94,73 @@ const cloneDetailItem = (data?: Partial<DetailItem>): DetailItem => ({
   keyWorkCompletion: data?.keyWorkCompletion || '',
   problemsAnalysis: data?.problemsAnalysis || '',
   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 cloneExtProperties(data?: ExtPropertyItem[] | unknown): ExtPropertyItem[] {
+  return Array.isArray(data)
+    ? data
+        .map((item) => normalizeExtProperty(item as Partial<ExtPropertyItem>))
+        .filter((item) => item.identifier)
+    : []
+}
+
+function mergeExtProperties(
+  defaults: ExtPropertyItem[],
+  current?: ExtPropertyItem[] | unknown
+): ExtPropertyItem[] {
+  if (!defaults.length) return []
+
+  const currentItems = cloneExtProperties(current)
+  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,
@@ -170,6 +249,32 @@ const loadEquipmentUtilizationRateRule = async () => {
   }
 }
 
+const isRequiredExtProperty = (item: ExtPropertyItem) =>
+  item.required === true || item.required === 1 || item.required === '1'
+
+const isDoubleExtProperty = (item: ExtPropertyItem) => item.dataType === 'double'
+
+const getExtPropertyLabel = (item: ExtPropertyItem) =>
+  item.unit ? `${item.name}(${item.unit})` : item.name
+
+const getExtPropertyProp = (item: ExtPropertyItem) => {
+  const index = detailForm.value.extProperty.findIndex(
+    (property) => property.identifier === item.identifier
+  )
+
+  return `extProperty.${Math.max(index, 0)}.actualValue`
+}
+
+const getExtPropertyRules = (item: ExtPropertyItem) => {
+  if (!isRequiredExtProperty(item)) return []
+
+  return isDoubleExtProperty(item)
+    ? requiredNumberRule(`请输入${item.name}`)
+    : requiredTextRule(`请输入${item.name}`)
+}
+
+const getExtPropertyNumberBoundary = (value: unknown) => parseNumberValue(value)
+
 const queryProjectNameSearch = (
   queryString: string,
   cb: (results: ProjectNameSuggestion[]) => void
@@ -199,6 +304,8 @@ const updatePlannedWorkloadFromPreviousPlan = (data?: Record<string, unknown>) =
 }
 
 const loadPreviousWorkPlan = async () => {
+  if (hasExtProperties.value) return
+
   const projectName = detailForm.value.projectName.trim()
 
   if (!projectName) {
@@ -383,30 +490,58 @@ onMounted(() => {
       <section 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-input-number
-              v-model="detailForm.equipmentUtilizationRate"
-              class="w-full!"
-              :controls="false"
-              :precision="2"
-            />
-          </el-form-item>
+          <template v-if="hasExtProperties">
+            <el-form-item
+              v-for="item in currentPeriodExtProperties"
+              :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"
+                :precision="2"
+                :min="getExtPropertyNumberBoundary(item.minValue)"
+                :max="getExtPropertyNumberBoundary(item.maxValue)"
+              />
+              <el-input
+                v-else
+                v-model="item.actualValue"
+                type="textarea"
+                :rows="3"
+                :placeholder="`请输入${item.name}`"
+              />
+            </el-form-item>
+          </template>
+          <template v-else>
+            <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-input-number
+                v-model="detailForm.equipmentUtilizationRate"
+                class="w-full!"
+                :controls="false"
+                :precision="2"
+              />
+            </el-form-item>
+          </template>
         </div>
       </section>
 
@@ -451,6 +586,30 @@ onMounted(() => {
               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"
+              :precision="2"
+              :min="getExtPropertyNumberBoundary(item.minValue)"
+              :max="getExtPropertyNumberBoundary(item.maxValue)"
+            />
+            <el-input
+              v-else
+              v-model="item.actualValue"
+              type="textarea"
+              :rows="4"
+              :placeholder="`请输入${item.name}`"
+            />
+          </el-form-item>
         </div>
       </section>
     </el-form>

+ 427 - 78
src/views/pms/operation-meeting/components/operation-meeting-content.vue

@@ -1,26 +1,33 @@
 <script lang="ts" setup>
 import type { CSSProperties } from 'vue'
-import type { DeptOption, DetailItem, OperationMeeting } from '../types'
+import type {
+  DeptOption,
+  DetailItem,
+  ExtPropertyItem,
+  OperationMeetingDrawerMode,
+  OperationMeetingForm,
+  OperationMeetingFormItem,
+  SummaryDetailItem
+} from '../types'
 import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 
-interface OperationMeetingForm
-  extends Omit<Partial<OperationMeeting>, 'meetingDate' | 'meetingSeries'> {
-  meetingDate?: number | string | Date
-  meetingSeries?: number
-}
-
 interface Props {
   meeting: OperationMeetingForm
   details: DetailItem[]
   type: 'create' | 'edit' | 'view'
+  mode?: OperationMeetingDrawerMode
+  meetings?: OperationMeetingFormItem[]
+  extProperties?: ExtPropertyItem[]
   deptOptions?: DeptOption[]
   loading?: boolean
   showMeetingMeta?: boolean
+  companyFilterValue?: string
 }
 
 interface DetailCardField {
   label: string
-  prop: keyof DetailItem
+  prop?: keyof DetailItem
+  extIdentifier?: string
   unit?: string
   numeric?: boolean
 }
@@ -32,13 +39,18 @@ interface MeetingTableCellStyleProps {
 }
 
 const props = withDefaults(defineProps<Props>(), {
+  mode: 'fill',
+  meetings: () => [],
+  extProperties: () => [],
   deptOptions: () => [],
   loading: false,
-  showMeetingMeta: true
+  showMeetingMeta: true,
+  companyFilterValue: ''
 })
 
 const emits = defineEmits<{
   'update:meeting': [meeting: OperationMeetingForm]
+  'update:companyFilterValue': [value: string]
   'add-detail': []
   'edit-detail': [row: DetailItem, index: number]
   'delete-detail': [index: number]
@@ -84,59 +96,138 @@ const detailSummaryIconMap: Record<DetailSummaryField, string> = {
   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 businessDetailCardFields: DetailCardField[] = [
+  { 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 }
+]
+
+const managementDetailCardFields: DetailCardField[] = [
+  { label: '重点工作及完成情况', prop: 'keyWorkCompletion' },
+  { label: '存在问题及分析', prop: 'problemsAnalysis' }
+]
+
+const nextPlanBaseDetailCardFields: DetailCardField[] = [
+  { label: '计划工作量', prop: 'nextPlannedWorkload' },
+  { label: '重点工作事项', prop: 'priorityTasks' }
 ]
 
 const meetingTableBlueColumns = new Set<keyof DetailItem>([
   'projectName',
-  'actualCompletion',
   'keyWorkCompletion',
   'problemsAnalysis'
 ])
 
-const companyDisplayName = computed(() => {
-  if (props.meeting.companyName) {
-    return props.meeting.companyName
+const isSummaryMode = computed(() => props.mode === 'summary')
+const canEditDetails = computed(() => !isSummaryMode.value && props.type !== 'view')
+const detailPanelVisible = ref(false)
+
+const activeMeetings = computed<OperationMeetingFormItem[]>(() => {
+  if (props.meetings.length) return props.meetings
+
+  return [
+    {
+      key: 'current',
+      meeting: props.meeting,
+      details: props.details
+    }
+  ]
+})
+
+const summaryMeetingMeta = computed<OperationMeetingForm>(
+  () => activeMeetings.value[0]?.meeting || props.meeting || {}
+)
+
+const getCompanyDisplayName = (meeting: OperationMeetingForm) => {
+  if (meeting.companyName) {
+    return meeting.companyName
   }
 
-  if (props.meeting.deptId) {
-    return props.deptOptions.find((item) => item.value === props.meeting.deptId)?.label || ''
+  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 companyDisplayName = computed(() => getCompanyDisplayName(props.meeting))
+
+const companyFilterModel = computed({
+  get: () => props.companyFilterValue,
+  set: (value) => emits('update:companyFilterValue', value || '')
+})
+
+const companyOptions = computed(() => {
+  const optionMap = new Map<string, { label: string; value: string }>()
+
+  activeMeetings.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[]>(() =>
+  activeMeetings.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 filteredSummaryDetailRows = computed(() => {
+  if (!props.companyFilterValue) return summaryDetailRows.value
+
+  return summaryDetailRows.value.filter(
+    (item) => item.companyFilterValue === props.companyFilterValue
+  )
+})
+
+const displayDetails = computed<DetailItem[]>(() =>
+  isSummaryMode.value ? filteredSummaryDetailRows.value : props.details
+)
+
+const currentSummaryScopeName = computed(
+  () =>
+    companyOptions.value.find((item) => item.value === props.companyFilterValue)?.label ||
+    '全部公司'
+)
+
+const currentSummarySupport = computed(() => {
+  if (!props.companyFilterValue) return ''
+
+  return (
+    activeMeetings.value.find(
+      (item, index) => getCompanyFilterValue(item.meeting, index) === props.companyFilterValue
+    )?.meeting.support || ''
+  )
 })
 
 const meetingSeriesModel = computed({
@@ -160,8 +251,19 @@ const formatSummaryNumber = (value: number) =>
     minimumFractionDigits: Number.isInteger(value) ? 0 : 2
   })
 
-const getDetailSummaryTotal = (field: DetailSummaryField) =>
-  props.details.reduce((sum, item) => sum + Number(item[field] || 0), 0)
+const getDetailSummaryTotal = (field: DetailSummaryField) => {
+  if (!isSummaryMode.value) {
+    return props.details.reduce((sum, item) => sum + Number(item[field] || 0), 0)
+  }
+
+  return filteredSummaryDetailRows.value.reduce((sum, item) => {
+    if (item.companyFilterValue === props.companyFilterValue || item.cumulative) {
+      return sum + Number(item[field] || 0)
+    }
+
+    return sum
+  }, 0)
+}
 
 const detailSummaryCards = computed(() =>
   detailSummaryFields.map((field) => ({
@@ -173,8 +275,124 @@ const detailSummaryCards = computed(() =>
 )
 
 const showMeetingMetaFields = computed(() => props.type !== 'view' || props.showMeetingMeta)
+const showSupportPanel = computed(() => !isSummaryMode.value || Boolean(currentSummarySupport.value))
+const hasExtProperties = computed(() => props.extProperties.length > 0)
+
+const isNextExtProperty = (item: ExtPropertyItem) => item.defaultValue === 'next'
+
+const getExtPropertyLabel = (item: ExtPropertyItem) =>
+  item.unit ? `${item.name}(${item.unit})` : item.name
+
+const getSortedExtProperties = (items: ExtPropertyItem[]) =>
+  items
+    .filter((item) => item.identifier)
+    .sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
+
+const getDetailExtProperty = (item: DetailItem, identifier: string) =>
+  item.extProperty?.find((property) => property.identifier === identifier)
+
+const getDisplayExtProperties = (next: boolean) => {
+  if (!hasExtProperties.value) return []
+
+  const hasDetailExtProperties = displayDetails.value.some((detail) => detail.extProperty?.length)
+
+  if (!isSummaryMode.value || !hasDetailExtProperties) {
+    return getSortedExtProperties(
+      props.extProperties.filter((item) =>
+        next ? isNextExtProperty(item) : !isNextExtProperty(item)
+      )
+    )
+  }
+
+  const propertyMap = new Map<string, ExtPropertyItem>()
+
+  displayDetails.value.forEach((detail) => {
+    const extProperty = detail.extProperty || []
+
+    extProperty
+      .filter((item) => (next ? isNextExtProperty(item) : !isNextExtProperty(item)))
+      .forEach((item) => {
+        if (!propertyMap.has(item.identifier)) {
+          propertyMap.set(item.identifier, item)
+        }
+      })
+  })
+
+  return getSortedExtProperties(Array.from(propertyMap.values()))
+}
+
+const currentPeriodExtProperties = computed(() => getDisplayExtProperties(false))
+const nextPlanExtProperties = computed(() => getDisplayExtProperties(true))
+
+const toExtDetailCardField = (item: ExtPropertyItem): DetailCardField => ({
+  label: item.name,
+  extIdentifier: item.identifier,
+  unit: item.unit,
+  numeric: item.dataType === 'double'
+})
+
+const detailCardGroups = computed(() => [
+  {
+    title: '经营情况',
+    fields: businessDetailCardFields
+  },
+  {
+    title: '本期生产运行情况',
+    fields: hasExtProperties.value
+      ? currentPeriodExtProperties.value.map(toExtDetailCardField)
+      : [
+          { label: '计划工作量', prop: 'plannedWorkload' },
+          { label: '实际完成', prop: 'actualCompletion' },
+          { label: '设备利用率', prop: 'equipmentUtilizationRate', unit: '%', numeric: true }
+        ]
+  },
+  {
+    title: '生产管理情况及重点工作',
+    fields: managementDetailCardFields
+  },
+  {
+    title: '下期工作计划',
+    fields: [
+      ...nextPlanBaseDetailCardFields,
+      ...nextPlanExtProperties.value.map(toExtDetailCardField)
+    ]
+  }
+])
+
+const formatExtPropertyValue = (item: DetailItem, property: ExtPropertyItem | DetailCardField) => {
+  const extIdentifier =
+    'identifier' in property ? property.identifier : property.extIdentifier || ''
+  const matchedProperty = getDetailExtProperty(item, extIdentifier)
+  const value = matchedProperty?.actualValue
+
+  if (value === undefined || value === null || value === '') {
+    return '-'
+  }
+
+  const isNumeric = 'dataType' in property ? property.dataType === 'double' : property.numeric
+
+  if (!isNumeric) {
+    return String(value)
+  }
+
+  const numericValue = Number(value)
+
+  if (Number.isNaN(numericValue)) {
+    return '-'
+  }
+
+  const unit = matchedProperty?.unit || property.unit || ''
+
+  return `${formatSummaryNumber(numericValue)}${unit}`
+}
 
 const formatDetailCardValue = (item: DetailItem, field: DetailCardField) => {
+  if (field.extIdentifier) {
+    return formatExtPropertyValue(item, field)
+  }
+
+  if (!field.prop) return '-'
+
   const value = item[field.prop]
 
   if (value === undefined || value === null || value === '') {
@@ -194,6 +412,22 @@ const formatDetailCardValue = (item: DetailItem, field: DetailCardField) => {
   return `${formatSummaryNumber(numericValue)}${field.unit || ''}`
 }
 
+const formatEquipmentUtilizationRate = (row: DetailItem) => {
+  if (row.equipmentUtilizationRate === undefined || row.equipmentUtilizationRate === null) {
+    return '-'
+  }
+
+  return `${formatSummaryNumber(Number(row.equipmentUtilizationRate))}%`
+}
+
+const getDetailCardKey = (item: DetailItem, index: number) =>
+  isSummaryMode.value && 'summaryDetailKey' in item
+    ? (item as SummaryDetailItem).summaryDetailKey
+    : index
+
+const getSummaryDetailCompanyName = (item: DetailItem) =>
+  'companyName' in item ? String((item as SummaryDetailItem).companyName || '-') : '-'
+
 const getMeetingTableCellStyle: any = ({
   column
 }: MeetingTableCellStyleProps): CSSProperties | undefined => {
@@ -216,8 +450,6 @@ const getMeetingTableCellClassName = ({ column }: MeetingTableCellStyleProps) =>
 
   return property ? 'meeting-table__cell' : ''
 }
-
-const visible = ref(false)
 </script>
 
 <template>
@@ -239,6 +471,15 @@ const visible = ref(false)
           class="mb-0! min-w-0"
         >
           <el-input-number
+            v-if="isSummaryMode"
+            :model-value="summaryMeetingMeta.meetingSeries"
+            class="w-full!"
+            placeholder="暂无会议期次"
+            disabled
+            :controls="false"
+          />
+          <el-input-number
+            v-else
             v-model="meetingSeriesModel"
             class="w-full!"
             placeholder="请输入会议期次"
@@ -251,13 +492,29 @@ const visible = ref(false)
         </el-form-item>
 
         <el-form-item label="专业公司" label-position="left" class="mb-0! min-w-0">
+          <el-select
+            v-if="isSummaryMode"
+            v-model="companyFilterModel"
+            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-input
+            v-else
             :model-value="companyDisplayName"
             class="w-full!"
             placeholder="新建保存后系统自动填充"
             disabled
           />
-          <div v-if="type === 'create'" class="absolute text-xs text-gray top-8">
+          <div v-if="!isSummaryMode && type === 'create'" class="absolute text-xs text-gray top-8">
             新建保存后由系统自动填充。
           </div>
         </el-form-item>
@@ -270,6 +527,15 @@ const visible = ref(false)
           class="mb-0! min-w-0"
         >
           <el-date-picker
+            v-if="isSummaryMode"
+            :model-value="summaryMeetingMeta.meetingDate"
+            type="date"
+            placeholder="暂无会议日期"
+            disabled
+            class="w-full!"
+          />
+          <el-date-picker
+            v-else
             v-model="meetingDateModel"
             type="date"
             placeholder="请选择会议日期"
@@ -282,7 +548,7 @@ const visible = ref(false)
 
     <section class="meeting-summary-strip">
       <div class="meeting-summary-strip__title">
-        <span>公司整体</span>
+        <span>{{ isSummaryMode ? currentSummaryScopeName : '公司整体' }}</span>
         <small>经营数据汇总</small>
       </div>
       <div class="meeting-summary-strip__grid">
@@ -299,20 +565,32 @@ const visible = ref(false)
     </section>
 
     <section class="meeting-detail-panel">
-      <el-button class="mb-2" size="default" link @click="() => (visible = !visible)">
+      <el-button
+        v-if="!isSummaryMode"
+        class="mb-2"
+        size="default"
+        link
+        @click="() => (detailPanelVisible = !detailPanelVisible)"
+      >
         <div class="flex items-center gap-1">
           <div
             :class="[
               'i-lucide:chevron-right size-4 meeting-detail-toggle__icon',
-              { 'meeting-detail-toggle__icon--expanded': visible }
+              { 'meeting-detail-toggle__icon--expanded': detailPanelVisible }
             ]"
           ></div>
           <!-- {{ visible ? '收起' : '展开' }} -->
         </div>
       </el-button>
-      <div v-show="visible" class="flex items-center justify-between gap-4 mb-4">
+      <div
+        v-show="isSummaryMode || detailPanelVisible"
+        class="flex items-center justify-between gap-4 mb-4"
+      >
         <h3 class="text-lg font-bold m-0">会议明细</h3>
-        <el-button v-if="type !== 'view'" type="primary" @click="emits('add-detail')">
+        <el-tag v-if="isSummaryMode" size="small" effect="plain">
+          共 {{ displayDetails.length }} 项
+        </el-tag>
+        <el-button v-else-if="canEditDetails" type="primary" @click="emits('add-detail')">
           <Icon icon="ep:plus" class="mr-5px" />
           新增一行
         </el-button>
@@ -320,7 +598,7 @@ const visible = ref(false)
       <div class="meeting-detail-table-view">
         <ZmTable
           class="meeting-table"
-          :data="details"
+          :data="displayDetails"
           :loading="loading"
           :max-height="660"
           align="left"
@@ -329,24 +607,61 @@ const visible = ref(false)
           :row-style="getMeetingTableRowStyle"
           :cell-class-name="getMeetingTableCellClassName"
         >
-          <zm-table-column min-width="8%" align="center" label="项目名称" prop="projectName" />
+          <zm-table-column
+            v-if="isSummaryMode"
+            min-width="6%"
+            align="center"
+            label="专业公司"
+            prop="companyName"
+            fixed="left"
+          />
+          <zm-table-column
+            :min-width="isSummaryMode ? '6%' : '8%'"
+            align="center"
+            label="项目名称"
+            prop="projectName"
+          />
           <zm-table-column min-width="27%" label="本期生产运行情况">
-            <zm-table-column min-width="10%" label="计划工作量" prop="plannedWorkload" />
-            <zm-table-column min-width="10%" label="实际完成" prop="actualCompletion" />
-            <zm-table-column
-              min-width="7%"
-              label="设备利用率"
-              prop="equipmentUtilizationRate"
-              :formatter="(row) => `${row.equipmentUtilizationRate}%`"
-            />
+            <template v-if="hasExtProperties">
+              <zm-table-column
+                v-for="item in currentPeriodExtProperties"
+                :key="item.identifier"
+                min-width="8%"
+                :label="getExtPropertyLabel(item)"
+                :prop="item.identifier"
+                :formatter="(row) => formatExtPropertyValue(row, item)"
+              />
+            </template>
+            <template v-else>
+              <zm-table-column min-width="10%" label="计划工作量" prop="plannedWorkload" />
+              <zm-table-column min-width="10%" label="实际完成" prop="actualCompletion" />
+              <zm-table-column
+                min-width="7%"
+                label="设备利用率"
+                prop="equipmentUtilizationRate"
+                :formatter="formatEquipmentUtilizationRate"
+              />
+            </template>
           </zm-table-column>
           <zm-table-column min-width="31%" label="生产管理情况及重点工作	">
-            <zm-table-column min-width="19%" label="重点工作及完成情况" prop="keyWorkCompletion" />
+            <zm-table-column
+              min-width="19%"
+              label="重点工作及完成情况"
+              prop="keyWorkCompletion"
+            />
             <zm-table-column min-width="12%" label="存在问题及分析" prop="problemsAnalysis" />
           </zm-table-column>
           <zm-table-column min-width="29%" label="下期工作计划		">
             <zm-table-column min-width="10%" label="计划工作量" prop="nextPlannedWorkload" />
             <zm-table-column min-width="19%" label="重点工作事项" prop="priorityTasks" />
+            <zm-table-column
+              v-for="item in nextPlanExtProperties"
+              :key="item.identifier"
+              min-width="8%"
+              :label="getExtPropertyLabel(item)"
+              :prop="item.identifier"
+              :formatter="(row) => formatExtPropertyValue(row, item)"
+            />
           </zm-table-column>
           <zm-table-column label="操作" min-width="5%" align="center" fixed="right">
             <template #default="{ row, $index }">
@@ -357,10 +672,10 @@ const visible = ref(false)
                   type="primary"
                   @click="emits('edit-detail', row, $index)"
                 >
-                  {{ type === 'view' ? '查看' : '编辑' }}
+                  {{ type === 'view' || isSummaryMode ? '查看' : '编辑' }}
                 </el-button>
                 <el-button
-                  v-if="type !== 'view'"
+                  v-if="canEditDetails"
                   link
                   size="default"
                   type="danger"
@@ -375,8 +690,12 @@ const visible = ref(false)
       </div>
 
       <div v-loading="loading" class="meeting-detail-card-view">
-        <template v-if="details.length">
-          <article v-for="(item, index) in details" :key="index" class="meeting-detail-card">
+        <template v-if="displayDetails.length">
+          <article
+            v-for="(item, index) in displayDetails"
+            :key="getDetailCardKey(item, index)"
+            class="meeting-detail-card"
+          >
             <div class="meeting-detail-card__header">
               <div class="meeting-detail-card__title">
                 <span>项目名称</span>
@@ -385,6 +704,11 @@ const visible = ref(false)
               <el-tag size="small" effect="plain">第 {{ index + 1 }} 项</el-tag>
             </div>
 
+            <div v-if="isSummaryMode" class="meeting-detail-card__company">
+              <span>专业公司</span>
+              <strong>{{ getSummaryDetailCompanyName(item) }}</strong>
+            </div>
+
             <section
               v-for="group in detailCardGroups"
               :key="group.title"
@@ -410,10 +734,10 @@ const visible = ref(false)
                 type="primary"
                 @click="emits('edit-detail', item, index)"
               >
-                {{ type === 'view' ? '查看' : '编辑' }}
+                {{ type === 'view' || isSummaryMode ? '查看' : '编辑' }}
               </el-button>
               <el-button
-                v-if="type !== 'view'"
+                v-if="canEditDetails"
                 link
                 size="small"
                 type="danger"
@@ -428,13 +752,21 @@ const visible = ref(false)
       </div>
     </section>
 
-    <section class="meeting-support-panel">
+    <section v-if="showSupportPanel" class="meeting-support-panel">
       <el-form-item
         label="其他重点事项及需要集团协调事项"
-        prop="support"
+        :prop="isSummaryMode ? undefined : 'support'"
         class="meeting-form-item min-w-0"
       >
         <el-input
+          v-if="isSummaryMode"
+          :model-value="currentSummarySupport"
+          type="textarea"
+          :rows="4"
+          readonly
+        />
+        <el-input
+          v-else
           v-model="supportModel"
           type="textarea"
           :rows="4"
@@ -721,6 +1053,7 @@ const visible = ref(false)
 }
 
 .meeting-detail-card__title span,
+.meeting-detail-card__company span,
 .meeting-detail-card__field span {
   font-size: 12px;
   line-height: 1.4;
@@ -736,6 +1069,22 @@ const visible = ref(false)
   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);

+ 1 - 0
src/views/pms/operation-meeting/index.vue

@@ -254,6 +254,7 @@ function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
   </div>
   <meeting-form
     v-model:visible="visible"
+    mode="fill"
     :id="currentId"
     :type="type"
     :dept-options="deptOptions"

+ 227 - 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 {
@@ -57,7 +74,22 @@ function createDetailItem(): DetailItem {
     keyWorkCompletion: '',
     problemsAnalysis: '',
     nextPlannedWorkload: '',
-    priorityTasks: ''
+    priorityTasks: '',
+    extProperty: cloneExtProperties(extProperties.value)
+  }
+}
+
+function createMeetingFormItem(
+  meeting: OperationMeetingForm = {},
+  details: DetailItem[] = [],
+  index = 0
+): OperationMeetingFormItem {
+  const id = meeting.id === undefined || meeting.id === null ? 'new' : String(meeting.id)
+
+  return {
+    key: `${id}-${index}`,
+    meeting,
+    details
   }
 }
 
@@ -76,7 +108,8 @@ const cloneDetailItem = (data?: Partial<DetailItem>): DetailItem => ({
   keyWorkCompletion: data?.keyWorkCompletion || '',
   problemsAnalysis: data?.problemsAnalysis || '',
   nextPlannedWorkload: data?.nextPlannedWorkload || '',
-  priorityTasks: data?.priorityTasks || ''
+  priorityTasks: data?.priorityTasks || '',
+  extProperty: cloneExtProperties(data?.extProperty)
 })
 
 const parseNumberValue = (value: unknown) => {
@@ -93,24 +126,114 @@ 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 cloneExtProperties(data?: ExtPropertyItem[] | unknown): ExtPropertyItem[] {
+  return Array.isArray(data)
+    ? data
+        .map((item) => normalizeExtProperty(item as Partial<ExtPropertyItem>))
+        .filter((item) => item.identifier)
+    : []
+}
+
+function mergeExtProperties(
+  defaults: ExtPropertyItem[],
+  current?: ExtPropertyItem[] | unknown
+): ExtPropertyItem[] {
+  if (!defaults.length) return []
+
+  const currentItems = cloneExtProperties(current)
+  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),
   currentOnAccount: parseNumberValue(data?.currentOnAccount),
   cumulativeOnAccount: parseNumberValue(data?.cumulativeOnAccount),
   currentPayment: parseNumberValue(data?.currentPayment),
   cumulativePayment: parseNumberValue(data?.cumulativePayment),
-  plannedWorkload: String(data?.plannedWorkload || ''),
-  actualCompletion: String(data?.actualCompletion || ''),
+  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),
+  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>))
+  }
+}
+
+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 +263,8 @@ const operationMeetingRules = reactive<FormRules>({
 })
 
 const handleAddDetailItem = () => {
+  if (isSummaryMode.value) return
+
   detailFormType.value = 'create'
   detailEditingIndex.value = -1
   detailForm.value = createDetailItem()
@@ -148,13 +273,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 +295,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 +382,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 +401,10 @@ watch(
 
     if (id) {
       loadOperationMeetingDetail(id)
+      return
     }
+
+    resetForm()
   }
 )
 
@@ -273,7 +442,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 +476,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 +522,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>

+ 4 - 2
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)
@@ -156,8 +156,10 @@ onMounted(() => {
     </div>
   </div>
 
-  <summary-form
+  <meeting-form
     v-model:visible="visible"
+    mode="summary"
+    type="view"
     :meeting-series="currentMeetingSeries"
     :year="currentYear"
     :dept-options="deptOptions"

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

@@ -13,6 +13,35 @@ 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[]
+}
+
+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
@@ -29,6 +58,16 @@ export interface DetailItem {
   problemsAnalysis: 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 +80,4 @@ export interface OperationMeetingListItem {
 }
 
 export type OperationMeetingOpenType = 'create' | 'edit' | 'readonly'
+export type OperationMeetingDrawerMode = 'fill' | 'summary'