Procházet zdrojové kódy

运营会议调整

Zimo před 2 dny
rodič
revize
30f009a6bd

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

@@ -11,6 +11,19 @@ export const OperationMeetingApi = {
     return await request.get({ url: `/pms/iot-operation-meeting/page`, params })
   },
 
+  // 查询汇总会议列表
+  getIntegratedMeetingList: async () => {
+    return await request.get({ url: `/pms/iot-operation-meeting/integratedMeeting` })
+  },
+
+  // 查询汇总会议详情
+  getSummarizedProjectDetails: async (params: { meetingSeries: string; year: string | number }) => {
+    return await request.get({
+      url: `/pms/iot-operation-meeting/summarizedProjectDetails`,
+      params
+    })
+  },
+
   // 查询会议详情
   getOperationMeeting: async (id: number) => {
     return await request.get({ url: `/pms/iot-operation-meeting/get?id=${id}` })

+ 9 - 3
src/layout/components/Menu/src/components/useRenderMenuItem.tsx

@@ -17,9 +17,15 @@ export const useRenderMenuItem = () =>
           if (currentSource === 'zhly') {
             return (
               !v.meta?.hidden &&
-              ['智慧连油', '连油监控', '监控查询', '视频告警', '分屏管理', '告警配置'].includes(
-                v.meta?.title
-              )
+              [
+                '智慧连油',
+                '连油监控',
+                '监控查询',
+                '视频告警',
+                '分屏管理',
+                '告警配置',
+                '连油看板'
+              ].includes(v.meta?.title)
             )
           } else if (currentSource === 'znzq') {
             return !v.meta?.hidden && ['/device_monitor'].includes(v.path)

+ 502 - 0
src/views/pms/operation-meeting/components/meeting-detail-drawer.vue

@@ -0,0 +1,502 @@
+<script lang="ts" setup>
+import type { FormInstance, FormRules } from 'element-plus'
+import type { DetailItem } from '../types'
+import { OperationMeetingApi } from '@/api/pms/meeting'
+import { useDebounceFn, useWindowSize } from '@vueuse/core'
+
+interface Props {
+  visible: boolean
+  detail?: DetailItem
+  type: 'create' | 'edit' | 'view'
+  formType: 'create' | 'edit'
+}
+
+interface ProjectNameSuggestion {
+  value: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  detail: undefined
+})
+
+const emits = defineEmits<{
+  'update:visible': [visible: boolean]
+  save: [item: DetailItem]
+}>()
+
+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 detailDrawerTitle = computed(() => {
+  if (props.type === 'view') {
+    return '查看会议明细'
+  }
+
+  return props.formType === 'create' ? '新增会议明细' : '编辑会议明细'
+})
+
+const projectNameOptions = ref<string[]>([])
+const projectNameSuggestions = computed<ProjectNameSuggestion[]>(() =>
+  projectNameOptions.value.map((item) => ({ value: item }))
+)
+
+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 requiredTextRule = (message: string) => [
+  {
+    required: true,
+    whitespace: true,
+    message,
+    trigger: ['blur', 'change']
+  }
+]
+
+const requiredNumberRule = (message: string) => [
+  {
+    required: true,
+    type: 'number' as const,
+    message,
+    trigger: ['blur', 'change']
+  }
+]
+
+const nonNegativeNumberRule = (requiredMessage: string, fieldLabel: string) => [
+  ...requiredNumberRule(requiredMessage),
+  {
+    type: 'number' as const,
+    min: 0,
+    message: `${fieldLabel}不能小于0`,
+    trigger: ['blur', 'change']
+  }
+]
+
+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('请输入收入-本期', '收入-本期'),
+  cumulativeRevenue: nonNegativeNumberRule('请输入收入-累计', '收入-累计'),
+  currentOnAccount: nonNegativeNumberRule('请输入挂帐-本期', '挂帐-本期'),
+  cumulativeOnAccount: nonNegativeNumberRule('请输入挂帐-累计', '挂帐-累计'),
+  currentPayment: nonNegativeNumberRule('请输入回款-本期', '回款-本期'),
+  cumulativePayment: nonNegativeNumberRule('请输入回款-累计', '回款-累计'),
+  plannedWorkload: requiredTextRule('请输入计划工作量'),
+  actualCompletion: requiredTextRule('请输入实际完成'),
+  equipmentUtilizationRate: getEquipmentUtilizationRateRules(),
+  keyWorkCompletion: requiredTextRule('请输入重点工作及完成情况'),
+  problemsAnalysis: requiredTextRule('请输入存在问题及分析'),
+  nextPlannedWorkload: requiredTextRule('请输入下期计划工作量'),
+  priorityTasks: requiredTextRule('请输入重点工作事项')
+})
+
+const updateEquipmentUtilizationRateRule = (required: boolean) => {
+  equipmentUtilizationRateRequired.value = required
+  detailRules.equipmentUtilizationRate = getEquipmentUtilizationRateRules()
+  nextTick(() => detailFormRef.value?.clearValidate('equipmentUtilizationRate'))
+}
+
+const loadEquipmentUtilizationRateRule = async () => {
+  updateEquipmentUtilizationRateRule(true)
+
+  try {
+    const data = await OperationMeetingApi.getMandatoryOrNot()
+    updateEquipmentUtilizationRateRule(data?.mandatory !== false)
+  } catch {
+    updateEquipmentUtilizationRateRule(true)
+  }
+}
+
+const queryProjectNameSearch = (
+  queryString: string,
+  cb: (results: ProjectNameSuggestion[]) => void
+) => {
+  const keyword = queryString.trim().toLowerCase()
+
+  if (!keyword) {
+    cb(projectNameSuggestions.value)
+    return
+  }
+
+  cb(projectNameSuggestions.value.filter((item) => item.value.toLowerCase().includes(keyword)))
+}
+
+const getDetailRawQueryValue = (key: 'id' | 'meetingId') => {
+  const value = detailForm.value.raw?.[key]
+
+  if (value === undefined || value === null) {
+    return ''
+  }
+
+  return typeof value === 'string' || typeof value === 'number' ? value : String(value)
+}
+
+const updatePlannedWorkloadFromPreviousPlan = (data?: Record<string, unknown>) => {
+  detailForm.value.plannedWorkload = String(data?.nextPlannedWorkload || '')
+}
+
+const loadPreviousWorkPlan = async () => {
+  const projectName = detailForm.value.projectName.trim()
+
+  if (!projectName) {
+    previousWorkPlanQueryKey.value = ''
+    return
+  }
+
+  const params = {
+    projectName,
+    id: getDetailRawQueryValue('id'),
+    meetingId: getDetailRawQueryValue('meetingId')
+  }
+  const queryKey = JSON.stringify(params)
+
+  if (previousWorkPlanQueryKey.value === queryKey) return
+
+  previousWorkPlanQueryKey.value = queryKey
+
+  try {
+    const data = await OperationMeetingApi.getPreviousWorkPlan(params)
+    if (detailForm.value.projectName.trim() !== projectName) return
+    updatePlannedWorkloadFromPreviousPlan(data)
+  } catch {
+    previousWorkPlanQueryKey.value = ''
+  }
+}
+
+const handleProjectNameComplete = useDebounceFn(loadPreviousWorkPlan, 300)
+
+const handleProjectNameSelect = (item: ProjectNameSuggestion) => {
+  detailForm.value.projectName = item.value
+  void handleProjectNameComplete()
+}
+
+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
+
+  const valid = await detailFormRef.value.validate().catch(() => false)
+
+  if (!valid) return
+
+  emits('save', cloneDetailItem(detailForm.value))
+}
+
+async function loadProjectNameOptions() {
+  try {
+    const data = await OperationMeetingApi.getProjectNameOptions()
+    projectNameOptions.value = Array.isArray(data) ? data.filter(Boolean).map(String) : []
+  } catch {
+    projectNameOptions.value = []
+  }
+}
+
+watch(
+  () => [props.visible, props.detail] as const,
+  ([visible]) => {
+    if (!visible) return
+
+    detailForm.value = cloneDetailItem(props.detail)
+    previousWorkPlanQueryKey.value = ''
+    nextTick(() => detailFormRef.value?.clearValidate())
+  },
+  { immediate: true }
+)
+
+onMounted(() => {
+  loadProjectNameOptions()
+  loadEquipmentUtilizationRateRule()
+})
+</script>
+
+<template>
+  <el-drawer
+    :model-value="visible"
+    @update:model-value="handleVisibleChange"
+    :append-to-body="true"
+    :size="detailDrawerSize"
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    :show-close="false"
+    header-class="mb-0! p-4!"
+    body-class="bg-gray-100"
+    footer-class="p-4!"
+  >
+    <template #header>
+      <div class="flex items-center">
+        <span class="font-bold text-xl">{{ detailDrawerTitle }}</span>
+      </div>
+    </template>
+
+    <el-form
+      ref="detailFormRef"
+      label-position="top"
+      size="default"
+      :model="detailForm"
+      :rules="detailRules"
+      :disabled="type === 'view'"
+      scroll-to-error
+      require-asterisk-position="right"
+    >
+      <section class="detail-section">
+        <div class="detail-section__grid detail-section__grid--single">
+          <el-form-item label="项目名称" prop="projectName">
+            <el-autocomplete
+              v-model="detailForm.projectName"
+              class="w-full!"
+              placeholder="请选择或输入项目名称"
+              clearable
+              :fetch-suggestions="queryProjectNameSearch"
+              :trigger-on-focus="true"
+              @change="handleProjectNameComplete"
+              @select="handleProjectNameSelect"
+            />
+          </el-form-item>
+        </div>
+      </section>
+
+      <section class="detail-section">
+        <h4 class="detail-section__title">经营情况(万元)</h4>
+        <div class="detail-section__grid">
+          <el-form-item label="收入-本期" prop="currentRevenue">
+            <el-input-number
+              v-model="detailForm.currentRevenue"
+              class="w-full!"
+              :controls="false"
+              :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"
+            />
+          </el-form-item>
+          <el-form-item label="挂帐-本期" prop="currentOnAccount">
+            <el-input-number
+              v-model="detailForm.currentOnAccount"
+              class="w-full!"
+              :controls="false"
+              :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"
+            />
+          </el-form-item>
+          <el-form-item label="回款-本期" prop="currentPayment">
+            <el-input-number
+              v-model="detailForm.currentPayment"
+              class="w-full!"
+              :controls="false"
+              :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"
+            />
+          </el-form-item>
+        </div>
+      </section>
+
+      <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>
+        </div>
+      </section>
+
+      <section class="detail-section">
+        <h4 class="detail-section__title">生产管理情况及重点工作</h4>
+        <div class="detail-section__grid">
+          <el-form-item label="重点工作及完成情况" prop="keyWorkCompletion">
+            <el-input
+              v-model="detailForm.keyWorkCompletion"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入重点工作及完成情况"
+            />
+          </el-form-item>
+          <el-form-item label="存在问题及分析" prop="problemsAnalysis">
+            <el-input
+              v-model="detailForm.problemsAnalysis"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入存在问题及分析"
+            />
+          </el-form-item>
+        </div>
+      </section>
+
+      <section class="detail-section">
+        <h4 class="detail-section__title">下期工作计划</h4>
+        <div class="detail-section__grid">
+          <el-form-item label="计划工作量" prop="nextPlannedWorkload">
+            <el-input
+              v-model="detailForm.nextPlannedWorkload"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入下期计划工作量"
+            />
+          </el-form-item>
+          <el-form-item label="重点工作事项" prop="priorityTasks">
+            <el-input
+              v-model="detailForm.priorityTasks"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入重点工作事项"
+            />
+          </el-form-item>
+        </div>
+      </section>
+    </el-form>
+
+    <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>
+    </template>
+  </el-drawer>
+</template>
+
+<style scoped lang="scss">
+.detail-section {
+  padding: 20px;
+  margin-bottom: 16px;
+  background: #fff;
+  border: 1px solid rgb(229 231 235 / 90%);
+  border-radius: 12px;
+}
+
+.detail-section__title {
+  margin: 0 0 16px;
+  font-size: 16px;
+  font-weight: 700;
+  color: #1f2937;
+}
+
+.detail-section__grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 18px 24px;
+
+  :deep(.el-form-item) {
+    margin-bottom: 0;
+  }
+}
+
+.detail-section__grid--single {
+  grid-template-columns: 1fr;
+}
+
+@media (width <= 960px) {
+  .detail-section__grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 665 - 0
src/views/pms/operation-meeting/components/operation-meeting-content.vue

@@ -0,0 +1,665 @@
+<script lang="ts" setup>
+import type { CSSProperties } from 'vue'
+import type { DeptOption, DetailItem, OperationMeeting } 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'
+  deptOptions?: DeptOption[]
+  loading?: boolean
+}
+
+interface DetailCardField {
+  label: string
+  prop: keyof DetailItem
+  unit?: string
+  numeric?: boolean
+}
+
+interface MeetingTableCellStyleProps {
+  column: {
+    property?: string
+  }
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  deptOptions: () => [],
+  loading: false
+})
+
+const emits = defineEmits<{
+  'update:meeting': [meeting: OperationMeetingForm]
+  'add-detail': []
+  'edit-detail': [row: DetailItem, index: number]
+  'delete-detail': [index: number]
+}>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<DetailItem>()
+
+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 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'
+])
+
+const companyDisplayName = computed(() => {
+  if (props.meeting.companyName) {
+    return props.meeting.companyName
+  }
+
+  if (props.meeting.deptId) {
+    return props.deptOptions.find((item) => item.value === props.meeting.deptId)?.label || ''
+  }
+
+  return ''
+})
+
+const meetingSeriesModel = computed({
+  get: () => props.meeting.meetingSeries,
+  set: (meetingSeries) => emits('update:meeting', { ...props.meeting, meetingSeries })
+})
+
+const meetingDateModel = computed({
+  get: () => props.meeting.meetingDate,
+  set: (meetingDate) => emits('update:meeting', { ...props.meeting, meetingDate })
+})
+
+const supportModel = computed({
+  get: () => props.meeting.support,
+  set: (support) => emits('update:meeting', { ...props.meeting, support })
+})
+
+const formatSummaryNumber = (value: number) =>
+  value.toLocaleString('zh-CN', {
+    maximumFractionDigits: 2,
+    minimumFractionDigits: Number.isInteger(value) ? 0 : 2
+  })
+
+const getDetailSummaryTotal = (field: DetailSummaryField) =>
+  props.details.reduce((sum, item) => sum + Number(item[field] || 0), 0)
+
+const detailSummaryCards = computed(() =>
+  detailSummaryFields.map((field) => ({
+    label: detailSummaryLabelMap[field],
+    value: formatSummaryNumber(getDetailSummaryTotal(field)),
+    tone: detailSummaryToneMap[field]
+  }))
+)
+
+const formatDetailCardValue = (item: DetailItem, 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 getMeetingTableCellStyle: any = ({
+  column
+}: MeetingTableCellStyleProps): CSSProperties | undefined => {
+  const property = column.property as keyof DetailItem | undefined
+
+  if (property && meetingTableBlueColumns.has(property)) {
+    return { color: '#1b71f6' }
+  }
+
+  return undefined
+}
+</script>
+
+<template>
+  <section class="py-2.5 px-4 bg-white border-solid border-1 border-gray-200/90 rounded-xl mb-4">
+    <div class="meeting-section__grid">
+      <el-form-item
+        label="会议期次"
+        label-position="left"
+        prop="meetingSeries"
+        class="mb-0! min-w-0"
+      >
+        <el-input-number
+          v-model="meetingSeriesModel"
+          class="w-full!"
+          placeholder="请输入会议期次"
+          :disabled="type === 'view'"
+          :controls="false"
+          :min="1"
+          :step="1"
+          :precision="0"
+        />
+      </el-form-item>
+
+      <el-form-item label="所属公司" label-position="left" class="mb-0! min-w-0">
+        <el-input
+          :model-value="companyDisplayName"
+          class="w-full!"
+          placeholder="新建保存后系统自动填充"
+          disabled
+        />
+        <div v-if="type === 'create'" class="absolute text-xs text-gray top-8">
+          新建保存后由系统自动填充。
+        </div>
+      </el-form-item>
+
+      <el-form-item label="会议日期" label-position="left" prop="meetingDate" class="mb-0! min-w-0">
+        <el-date-picker
+          v-model="meetingDateModel"
+          type="date"
+          placeholder="请选择会议日期"
+          :disabled="type === 'view'"
+          class="w-full!"
+        />
+      </el-form-item>
+
+      <section class="meeting-summary-strip meeting-section__grid-full">
+        <div class="meeting-summary-strip__title">
+          <span>公司整体</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}`]"
+          >
+            <span>{{ item.label }}</span>
+            <strong>{{ item.value }}<em>万元</em></strong>
+          </div>
+        </div>
+      </section>
+    </div>
+  </section>
+
+  <section class="p-6 bg-white border-solid border-1 border-gray-200/90 rounded-xl">
+    <div 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')">
+        <Icon icon="ep:plus" class="mr-5px" />
+        新增一行
+      </el-button>
+    </div>
+    <div class="meeting-detail-table-view">
+      <ZmTable
+        class="meeting-table"
+        :data="details"
+        :loading="loading"
+        :max-height="660"
+        align="left"
+        :show-overflow-tooltip="false"
+        :cell-style="getMeetingTableCellStyle"
+      >
+        <zm-table-column width="156" align="center" label="项目名称" prop="projectName" />
+        <zm-table-column label="本期生产运行情况">
+          <zm-table-column width="220" label="计划工作量" prop="plannedWorkload" />
+          <zm-table-column width="220" label="实际完成" prop="actualCompletion" />
+          <zm-table-column
+            width="120"
+            label="设备利用率"
+            prop="equipmentUtilizationRate"
+            :formatter="(row) => `${row.equipmentUtilizationRate}%`"
+          />
+        </zm-table-column>
+        <zm-table-column label="生产管理情况及重点工作	">
+          <zm-table-column label="重点工作及完成情况" prop="keyWorkCompletion" />
+          <zm-table-column width="300" label="存在问题及分析" prop="problemsAnalysis" />
+        </zm-table-column>
+        <zm-table-column label="下期工作计划		">
+          <zm-table-column width="240" label="计划工作量" prop="nextPlannedWorkload" />
+          <zm-table-column label="重点工作事项" prop="priorityTasks" />
+        </zm-table-column>
+        <zm-table-column label="操作" width="140" align="center" fixed="right">
+          <template #default="{ row, $index }">
+            <div class="meeting-table__actions">
+              <el-button
+                link
+                size="default"
+                type="primary"
+                @click="emits('edit-detail', row, $index)"
+              >
+                {{ type === 'view' ? '查看' : '编辑' }}
+              </el-button>
+              <el-button
+                v-if="type !== 'view'"
+                link
+                size="default"
+                type="danger"
+                @click="emits('delete-detail', $index)"
+              >
+                删除
+              </el-button>
+            </div>
+          </template>
+        </zm-table-column>
+      </ZmTable>
+    </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">
+          <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>
+
+          <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="emits('edit-detail', item, index)">
+              {{ type === 'view' ? '查看' : '编辑' }}
+            </el-button>
+            <el-button
+              v-if="type !== 'view'"
+              link
+              size="small"
+              type="danger"
+              @click="emits('delete-detail', index)"
+            >
+              删除
+            </el-button>
+          </div>
+        </article>
+      </template>
+      <el-empty v-else description="暂无会议明细" :image-size="80" />
+    </div>
+
+    <section class="meeting-support-panel">
+      <el-form-item
+        label="需集团协调支持的事项"
+        prop="support"
+        class="meeting-form-item mb-0! min-w-0"
+      >
+        <el-input
+          v-model="supportModel"
+          type="textarea"
+          :rows="4"
+          :disabled="type === 'view'"
+          placeholder="请输入需集团协调支持的事项"
+        />
+      </el-form-item>
+    </section>
+  </section>
+</template>
+
+<style scoped lang="scss">
+.meeting-section__grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 18px 20px;
+}
+
+.meeting-section__grid-full {
+  grid-column: 1 / -1;
+}
+
+.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;
+  padding-right: 8px;
+  border-right: 1px solid #dbeafe;
+
+  span {
+    font-size: 14px;
+    font-weight: 800;
+    line-height: 20px;
+    color: #1f2937;
+  }
+
+  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: 8px;
+  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 {
+  color: #1b71f6;
+}
+
+.meeting-summary-strip__item--account strong {
+  color: #f59e0b;
+}
+
+.meeting-summary-strip__item--payment strong {
+  color: #10b981;
+}
+
+.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;
+
+    .truncate {
+      height: 20px;
+    }
+  }
+}
+
+.meeting-table__actions {
+  display: flex;
+  width: 100%;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+
+  :deep(.el-button) {
+    margin-left: 0;
+  }
+}
+
+.meeting-support-panel {
+  padding-top: 18px;
+  margin-top: 18px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+.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__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__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__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>

+ 57 - 1051
src/views/pms/operation-meeting/meeting-form.vue

@@ -1,10 +1,10 @@
 <script lang="ts" setup>
 import type { FormInstance, FormRules } from 'element-plus'
-import type { CSSProperties } from 'vue'
 import type { DeptOption, DetailItem, OperationMeeting } from './types'
+import MeetingDetailDrawer from './components/meeting-detail-drawer.vue'
+import OperationMeetingContent from './components/operation-meeting-content.vue'
 import { OperationMeetingApi } from '@/api/pms/meeting'
-import { useTableComponents } from '@/components/ZmTable/useTableComponents'
-import { useDebounceFn, useWindowSize } from '@vueuse/core'
+import { useWindowSize } from '@vueuse/core'
 
 interface Props {
   visible: boolean
@@ -33,55 +33,33 @@ const loading = ref(false)
 const { width } = useWindowSize()
 
 const detailItems = ref<DetailItem[]>([])
-const { ZmTable, ZmTableColumn } = useTableComponents<DetailItem>()
 const detailDrawerVisible = ref(false)
-const detailFormRef = ref<FormInstance>()
 const detailEditingIndex = ref(-1)
 const detailFormType = ref<'create' | 'edit'>('create')
+const detailForm = ref<DetailItem>(createDetailItem())
 
 const isMobile = computed(() => width.value <= 768)
 const drawerSize = computed(() => (isMobile.value ? '100%' : '100%'))
-const detailDrawerSize = computed(() => (isMobile.value ? '100%' : '50%'))
-
-const companyDisplayName = computed(() => {
-  if (operationMeeting.value.companyName) {
-    return operationMeeting.value.companyName
-  }
 
-  if (operationMeeting.value.deptId) {
-    return (
-      props.deptOptions.find((item) => item.value === operationMeeting.value.deptId)?.label || ''
-    )
+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: ''
   }
-
-  return ''
-})
-
-const detailDrawerTitle = computed(() => {
-  if (props.type === 'view') {
-    return '查看会议明细'
-  }
-
-  return detailFormType.value === 'create' ? '新增会议明细' : '编辑会议明细'
-})
-
-const createDetailItem = (): DetailItem => ({
-  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 || {},
@@ -133,88 +111,6 @@ const normalizeDetailItem = (data?: Record<string, unknown>): DetailItem => ({
   priorityTasks: String(data?.priorityTasks || '')
 })
 
-const detailForm = ref<DetailItem>(createDetailItem())
-const previousWorkPlanQueryKey = ref('')
-
-const detailSummaryFields = [
-  'currentRevenue',
-  'cumulativeRevenue',
-  'currentOnAccount',
-  'cumulativeOnAccount',
-  'currentPayment',
-  'cumulativePayment'
-] as const
-
-type DetailSummaryField = (typeof detailSummaryFields)[number]
-
-interface DetailCardField {
-  label: string
-  prop: keyof DetailItem
-  unit?: string
-  numeric?: boolean
-}
-
-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 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'
-])
-
 const requiredTextRule = (message: string) => [
   {
     required: true,
@@ -224,46 +120,6 @@ const requiredTextRule = (message: string) => [
   }
 ]
 
-const requiredNumberRule = (message: string) => [
-  {
-    required: true,
-    type: 'number' as const,
-    message,
-    trigger: ['blur', 'change']
-  }
-]
-
-const nonNegativeNumberRule = (requiredMessage: string, fieldLabel: string) => [
-  ...requiredNumberRule(requiredMessage),
-  {
-    type: 'number' as const,
-    min: 0,
-    message: `${fieldLabel}不能小于0`,
-    trigger: ['blur', 'change']
-  }
-]
-
-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 operationMeetingRules = reactive<FormRules>({
   meetingDate: [{ required: true, message: '请选择会议日期', trigger: 'change' }],
   meetingSeries: [
@@ -283,109 +139,18 @@ const operationMeetingRules = reactive<FormRules>({
   support: requiredTextRule('请输入需集团协调支持的事项')
 })
 
-const equipmentUtilizationRateRequired = ref(true)
-const getEquipmentUtilizationRateRules = () =>
-  equipmentUtilizationRateRequired.value
-    ? numberRangeRule('请输入设备利用率', '设备利用率', 0, 100)
-    : optionalNumberRangeRule('设备利用率', 0, 100)
-
-const detailRules = reactive<FormRules>({
-  projectName: requiredTextRule('请选择或者输入项目名称'),
-  currentRevenue: nonNegativeNumberRule('请输入收入-本期', '收入-本期'),
-  cumulativeRevenue: nonNegativeNumberRule('请输入收入-累计', '收入-累计'),
-  currentOnAccount: nonNegativeNumberRule('请输入挂帐-本期', '挂帐-本期'),
-  cumulativeOnAccount: nonNegativeNumberRule('请输入挂帐-累计', '挂帐-累计'),
-  currentPayment: nonNegativeNumberRule('请输入回款-本期', '回款-本期'),
-  cumulativePayment: nonNegativeNumberRule('请输入回款-累计', '回款-累计'),
-  plannedWorkload: requiredTextRule('请输入计划工作量'),
-  actualCompletion: requiredTextRule('请输入实际完成'),
-  equipmentUtilizationRate: getEquipmentUtilizationRateRules(),
-  keyWorkCompletion: requiredTextRule('请输入重点工作及完成情况'),
-  problemsAnalysis: requiredTextRule('请输入存在问题及分析'),
-  nextPlannedWorkload: requiredTextRule('请输入下期计划工作量'),
-  priorityTasks: requiredTextRule('请输入重点工作事项')
-})
-
-const updateEquipmentUtilizationRateRule = (required: boolean) => {
-  equipmentUtilizationRateRequired.value = required
-  detailRules.equipmentUtilizationRate = getEquipmentUtilizationRateRules()
-  nextTick(() => detailFormRef.value?.clearValidate('equipmentUtilizationRate'))
-}
-
-const loadEquipmentUtilizationRateRule = async () => {
-  updateEquipmentUtilizationRateRule(true)
-
-  try {
-    const data = await OperationMeetingApi.getMandatoryOrNot()
-    updateEquipmentUtilizationRateRule(data?.mandatory !== false)
-  } catch {
-    updateEquipmentUtilizationRateRule(true)
-  }
-}
-
-const formatSummaryNumber = (value: number) =>
-  value.toLocaleString('zh-CN', {
-    maximumFractionDigits: 2,
-    minimumFractionDigits: Number.isInteger(value) ? 0 : 2
-  })
-
-const getDetailSummaryTotal = (field: DetailSummaryField) =>
-  detailItems.value.reduce((sum, item) => sum + Number(item[field] || 0), 0)
-
-const detailSummaryCards = computed(() =>
-  detailSummaryFields.map((field) => ({
-    label: detailSummaryLabelMap[field],
-    value: formatSummaryNumber(getDetailSummaryTotal(field)),
-    tone: detailSummaryToneMap[field]
-  }))
-)
-
-const formatDetailCardValue = (item: DetailItem, 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 getMeetingTableCellStyle: any = ({ column }): CSSProperties | undefined => {
-  const property = column.property as keyof DetailItem | undefined
-
-  if (property && meetingTableBlueColumns.has(property)) {
-    return { color: '#1b71f6' }
-  }
-
-  return undefined
-}
-
 const handleAddDetailItem = () => {
   detailFormType.value = 'create'
   detailEditingIndex.value = -1
   detailForm.value = createDetailItem()
-  previousWorkPlanQueryKey.value = ''
   detailDrawerVisible.value = true
-  nextTick(() => detailFormRef.value?.clearValidate())
 }
 
 const handleEditDetailItem = (row: DetailItem, index: number) => {
   detailFormType.value = 'edit'
   detailEditingIndex.value = index
   detailForm.value = cloneDetailItem(row)
-  previousWorkPlanQueryKey.value = ''
   detailDrawerVisible.value = true
-  nextTick(() => detailFormRef.value?.clearValidate())
 }
 
 const handleDeleteDetailItem = (index: number) => {
@@ -399,11 +164,25 @@ const handleDetailDrawerChange = (visible: boolean) => {
     detailForm.value = createDetailItem()
     detailFormType.value = 'create'
     detailEditingIndex.value = -1
-    previousWorkPlanQueryKey.value = ''
-    nextTick(() => detailFormRef.value?.clearValidate())
   }
 }
 
+const updateOperationMeeting = (nextOperationMeeting: OperationMeetingForm) => {
+  operationMeeting.value = nextOperationMeeting
+}
+
+const saveDetailItem = (item: DetailItem) => {
+  const nextItem = cloneDetailItem(item)
+
+  if (detailEditingIndex.value > -1) {
+    detailItems.value.splice(detailEditingIndex.value, 1, nextItem)
+  } else {
+    detailItems.value.push(nextItem)
+  }
+
+  handleDetailDrawerChange(false)
+}
+
 const resetForm = () => {
   operationMeeting.value = {}
   detailItems.value = []
@@ -519,110 +298,6 @@ const submitForm = async () => {
     loading.value = false
   }
 }
-
-const saveDetailItem = async () => {
-  if (props.type === 'view' || !detailFormRef.value) return
-
-  const valid = await detailFormRef.value.validate().catch(() => false)
-
-  if (!valid) return
-
-  const nextItem = cloneDetailItem(detailForm.value)
-
-  if (detailEditingIndex.value > -1) {
-    detailItems.value.splice(detailEditingIndex.value, 1, nextItem)
-  } else {
-    detailItems.value.push(nextItem)
-  }
-
-  handleDetailDrawerChange(false)
-}
-
-interface ProjectNameSuggestion {
-  value: string
-}
-
-const projectNameOptions = ref<string[]>([])
-const projectNameSuggestions = computed<ProjectNameSuggestion[]>(() =>
-  projectNameOptions.value.map((item) => ({ value: item }))
-)
-
-const queryProjectNameSearch = (
-  queryString: string,
-  cb: (results: ProjectNameSuggestion[]) => void
-) => {
-  const keyword = queryString.trim().toLowerCase()
-
-  if (!keyword) {
-    cb(projectNameSuggestions.value)
-    return
-  }
-
-  cb(projectNameSuggestions.value.filter((item) => item.value.toLowerCase().includes(keyword)))
-}
-
-const getDetailRawQueryValue = (key: 'id' | 'meetingId') => {
-  const value = detailForm.value.raw?.[key]
-
-  if (value === undefined || value === null) {
-    return ''
-  }
-
-  return typeof value === 'string' || typeof value === 'number' ? value : String(value)
-}
-
-const updatePlannedWorkloadFromPreviousPlan = (data?: Record<string, unknown>) => {
-  detailForm.value.plannedWorkload = String(data?.nextPlannedWorkload || '')
-}
-
-const loadPreviousWorkPlan = async () => {
-  const projectName = detailForm.value.projectName.trim()
-
-  if (!projectName) {
-    previousWorkPlanQueryKey.value = ''
-    return
-  }
-
-  const params = {
-    projectName,
-    id: getDetailRawQueryValue('id'),
-    meetingId: getDetailRawQueryValue('meetingId')
-  }
-  const queryKey = JSON.stringify(params)
-
-  if (previousWorkPlanQueryKey.value === queryKey) return
-
-  previousWorkPlanQueryKey.value = queryKey
-
-  try {
-    const data = await OperationMeetingApi.getPreviousWorkPlan(params)
-    if (detailForm.value.projectName.trim() !== projectName) return
-    updatePlannedWorkloadFromPreviousPlan(data)
-  } catch {
-    previousWorkPlanQueryKey.value = ''
-  }
-}
-
-const handleProjectNameComplete = useDebounceFn(loadPreviousWorkPlan, 300)
-
-const handleProjectNameSelect = (item: ProjectNameSuggestion) => {
-  detailForm.value.projectName = item.value
-  void handleProjectNameComplete()
-}
-
-async function loadProjectNameOptions() {
-  try {
-    const data = await OperationMeetingApi.getProjectNameOptions()
-    projectNameOptions.value = Array.isArray(data) ? data.filter(Boolean).map(String) : []
-  } catch (error) {
-    projectNameOptions.value = []
-  }
-}
-
-onMounted(() => {
-  loadProjectNameOptions()
-  loadEquipmentUtilizationRateRule()
-})
 </script>
 
 <template>
@@ -644,204 +319,18 @@ onMounted(() => {
       v-loading="loading"
       scroll-to-error
       require-asterisk-position="right"
-      :disabled="type === 'view'"
     >
-      <section
-        class="py-2.5 px-4 bg-white border-solid border-1 border-gray-200/90 rounded-xl mb-4"
-      >
-        <div class="meeting-section__grid">
-          <el-form-item
-            label="会议期次"
-            label-position="left"
-            prop="meetingSeries"
-            class="mb-0! min-w-0"
-          >
-            <el-input-number
-              v-model="operationMeeting.meetingSeries"
-              class="w-full!"
-              placeholder="请输入会议期次"
-              :controls="false"
-              :min="1"
-              :step="1"
-              :precision="0"
-            />
-          </el-form-item>
-
-          <el-form-item label="所属公司" label-position="left" class="mb-0! min-w-0">
-            <el-input
-              :model-value="companyDisplayName"
-              class="w-full!"
-              placeholder="新建保存后系统自动填充"
-              disabled
-            />
-            <div v-if="type === 'create'" class="absolute text-xs text-gray top-8">
-              新建保存后由系统自动填充。
-            </div>
-          </el-form-item>
-
-          <el-form-item
-            label="会议日期"
-            label-position="left"
-            prop="meetingDate"
-            class="mb-0! min-w-0"
-          >
-            <el-date-picker
-              v-model="operationMeeting.meetingDate"
-              type="date"
-              placeholder="请选择会议日期"
-              class="w-full!"
-            />
-          </el-form-item>
-          <section class="meeting-summary-strip meeting-section__grid-full">
-            <div class="meeting-summary-strip__title">
-              <span>公司整体</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}`
-                ]"
-              >
-                <span>{{ item.label }}</span>
-                <strong>{{ item.value }}<em>万元</em></strong>
-              </div>
-            </div>
-          </section>
-        </div>
-      </section>
-      <section class="p-6 bg-white border-solid border-1 border-gray-200/90 rounded-xl">
-        <div 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="handleAddDetailItem">
-            <Icon icon="ep:plus" class="mr-5px" />
-            新增一行
-          </el-button>
-        </div>
-        <div class="meeting-detail-table-view">
-          <ZmTable
-            class="meeting-table"
-            :data="detailItems"
-            :loading="loading"
-            :max-height="660"
-            align="left"
-            :show-overflow-tooltip="false"
-            :cell-style="getMeetingTableCellStyle"
-          >
-            <zm-table-column width="156" align="center" label="项目名称" prop="projectName" />
-            <zm-table-column label="本期生产运行情况">
-              <zm-table-column width="220" label="计划工作量" prop="plannedWorkload" />
-              <zm-table-column width="220" label="实际完成" prop="actualCompletion" />
-              <zm-table-column
-                width="120"
-                label="设备利用率"
-                prop="equipmentUtilizationRate"
-                :formatter="(row) => `${row.equipmentUtilizationRate}%`"
-              />
-            </zm-table-column>
-            <zm-table-column label="生产管理情况及重点工作	">
-              <zm-table-column label="重点工作及完成情况" prop="keyWorkCompletion" />
-              <zm-table-column width="300" label="存在问题及分析" prop="problemsAnalysis" />
-            </zm-table-column>
-            <zm-table-column label="下期工作计划		">
-              <zm-table-column width="240" label="计划工作量" prop="nextPlannedWorkload" />
-              <zm-table-column label="重点工作事项" prop="priorityTasks" />
-            </zm-table-column>
-            <zm-table-column label="操作" width="140" fixed="right">
-              <template #default="{ row, $index }">
-                <el-button
-                  link
-                  size="default"
-                  type="primary"
-                  @click="handleEditDetailItem(row, $index)"
-                >
-                  {{ type === 'view' ? '查看' : '编辑' }}
-                </el-button>
-                <el-button
-                  v-if="type !== 'view'"
-                  link
-                  size="default"
-                  type="danger"
-                  @click="handleDeleteDetailItem($index)"
-                >
-                  删除
-                </el-button>
-              </template>
-            </zm-table-column>
-          </ZmTable>
-        </div>
-
-        <div v-loading="loading" class="meeting-detail-card-view">
-          <template v-if="detailItems.length">
-            <article v-for="(item, index) in detailItems" :key="index" 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>
-
-              <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="handleEditDetailItem(item, index)"
-                >
-                  {{ type === 'view' ? '查看' : '编辑' }}
-                </el-button>
-                <el-button
-                  v-if="type !== 'view'"
-                  link
-                  size="small"
-                  type="danger"
-                  @click="handleDeleteDetailItem(index)"
-                >
-                  删除
-                </el-button>
-              </div>
-            </article>
-          </template>
-          <el-empty v-else description="暂无会议明细" :image-size="80" />
-        </div>
-
-        <section class="meeting-support-panel">
-          <el-form-item
-            label="需集团协调支持的事项"
-            prop="support"
-            class="meeting-form-item mb-0! min-w-0"
-          >
-            <el-input
-              v-model="operationMeeting.support"
-              type="textarea"
-              :rows="4"
-              placeholder="请输入需集团协调支持的事项"
-            />
-          </el-form-item>
-        </section>
-      </section>
+      <OperationMeetingContent
+        :meeting="operationMeeting"
+        :details="detailItems"
+        :type="type"
+        :dept-options="deptOptions"
+        :loading="loading"
+        @update:meeting="updateOperationMeeting"
+        @add-detail="handleAddDetailItem"
+        @edit-detail="handleEditDetailItem"
+        @delete-detail="handleDeleteDetailItem"
+      />
     </el-form>
 
     <template #footer>
@@ -857,496 +346,13 @@ onMounted(() => {
       </el-button>
     </template>
 
-    <el-drawer
-      :model-value="detailDrawerVisible"
-      @update:model-value="handleDetailDrawerChange"
-      :append-to-body="true"
-      :size="detailDrawerSize"
-      :close-on-click-modal="false"
-      :close-on-press-escape="false"
-      :show-close="false"
-      header-class="mb-0! p-4!"
-      body-class="bg-gray-100"
-      footer-class="p-4!"
-    >
-      <template #header>
-        <div class="flex items-center">
-          <span class="font-bold text-xl">{{ detailDrawerTitle }}</span>
-        </div>
-      </template>
-
-      <el-form
-        ref="detailFormRef"
-        label-position="top"
-        size="default"
-        :model="detailForm"
-        :rules="detailRules"
-        :disabled="type === 'view'"
-        scroll-to-error
-        require-asterisk-position="right"
-      >
-        <section class="detail-section">
-          <div class="detail-section__grid detail-section__grid--single">
-            <el-form-item label="项目名称" prop="projectName">
-              <el-autocomplete
-                v-model="detailForm.projectName"
-                class="w-full!"
-                placeholder="请选择或输入项目名称"
-                clearable
-                :fetch-suggestions="queryProjectNameSearch"
-                :trigger-on-focus="true"
-                @change="handleProjectNameComplete"
-                @select="handleProjectNameSelect"
-              />
-            </el-form-item>
-          </div>
-        </section>
-
-        <section class="detail-section">
-          <h4 class="detail-section__title">经营情况(万元)</h4>
-          <div class="detail-section__grid">
-            <el-form-item label="收入-本期" prop="currentRevenue">
-              <el-input-number
-                v-model="detailForm.currentRevenue"
-                class="w-full!"
-                :controls="false"
-                :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"
-              />
-            </el-form-item>
-            <el-form-item label="挂帐-本期" prop="currentOnAccount">
-              <el-input-number
-                v-model="detailForm.currentOnAccount"
-                class="w-full!"
-                :controls="false"
-                :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"
-              />
-            </el-form-item>
-            <el-form-item label="回款-本期" prop="currentPayment">
-              <el-input-number
-                v-model="detailForm.currentPayment"
-                class="w-full!"
-                :controls="false"
-                :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"
-              />
-            </el-form-item>
-          </div>
-        </section>
-
-        <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>
-          </div>
-        </section>
-
-        <section class="detail-section">
-          <h4 class="detail-section__title">生产管理情况及重点工作</h4>
-          <div class="detail-section__grid">
-            <el-form-item label="重点工作及完成情况" prop="keyWorkCompletion">
-              <el-input
-                v-model="detailForm.keyWorkCompletion"
-                type="textarea"
-                :rows="4"
-                placeholder="请输入重点工作及完成情况"
-              />
-            </el-form-item>
-            <el-form-item label="存在问题及分析" prop="problemsAnalysis">
-              <el-input
-                v-model="detailForm.problemsAnalysis"
-                type="textarea"
-                :rows="4"
-                placeholder="请输入存在问题及分析"
-              />
-            </el-form-item>
-          </div>
-        </section>
-
-        <section class="detail-section">
-          <h4 class="detail-section__title">下期工作计划</h4>
-          <div class="detail-section__grid">
-            <el-form-item label="计划工作量" prop="nextPlannedWorkload">
-              <el-input
-                v-model="detailForm.nextPlannedWorkload"
-                type="textarea"
-                :rows="4"
-                placeholder="请输入下期计划工作量"
-              />
-            </el-form-item>
-            <el-form-item label="重点工作事项" prop="priorityTasks">
-              <el-input
-                v-model="detailForm.priorityTasks"
-                type="textarea"
-                :rows="4"
-                placeholder="请输入重点工作事项"
-              />
-            </el-form-item>
-          </div>
-        </section>
-      </el-form>
-
-      <template #footer>
-        <el-button size="default" @click="handleDetailDrawerChange(false)">取消</el-button>
-        <el-button size="default" v-if="type !== 'view'" type="primary" @click="saveDetailItem">
-          保存
-        </el-button>
-      </template>
-    </el-drawer>
+    <MeetingDetailDrawer
+      :visible="detailDrawerVisible"
+      :detail="detailForm"
+      :type="type"
+      :form-type="detailFormType"
+      @update:visible="handleDetailDrawerChange"
+      @save="saveDetailItem"
+    />
   </el-drawer>
 </template>
-
-<style scoped lang="scss">
-.meeting-section__grid {
-  display: grid;
-  grid-template-columns: repeat(3, minmax(0, 1fr));
-  gap: 18px 20px;
-}
-
-.meeting-section__grid-full {
-  grid-column: 1 / -1;
-}
-
-.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;
-  padding-right: 8px;
-  border-right: 1px solid #dbeafe;
-
-  span {
-    font-size: 14px;
-    font-weight: 800;
-    line-height: 20px;
-    color: #1f2937;
-  }
-
-  small {
-    margin-top: 0;
-    font-size: 12px;
-    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: 8px;
-  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;
-    line-height: 16px;
-    color: #64748b;
-    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: 600;
-    color: #64748b;
-  }
-}
-
-.meeting-summary-strip__item--revenue strong {
-  color: #1b71f6;
-}
-
-.meeting-summary-strip__item--account strong {
-  color: #f59e0b;
-}
-
-.meeting-summary-strip__item--payment strong {
-  color: #10b981;
-}
-
-.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;
-
-    .truncate {
-      height: 20px;
-    }
-  }
-}
-
-.meeting-support-panel {
-  padding-top: 18px;
-  margin-top: 18px;
-  border-top: 1px solid var(--el-border-color-lighter);
-}
-
-.meeting-form-tip {
-  margin-top: 6px;
-  font-size: 12px;
-  line-height: 18px;
-  color: #909399;
-}
-
-.detail-section {
-  padding: 20px;
-  margin-bottom: 16px;
-  background: #fff;
-  border: 1px solid rgb(229 231 235 / 90%);
-  border-radius: 12px;
-}
-
-.detail-section__title {
-  margin: 0 0 16px;
-  font-size: 16px;
-  font-weight: 700;
-  color: #1f2937;
-}
-
-.detail-section__grid {
-  display: grid;
-  grid-template-columns: repeat(2, minmax(0, 1fr));
-  gap: 18px 24px;
-
-  :deep(.el-form-item) {
-    margin-bottom: 0;
-  }
-}
-
-.detail-section__grid--single {
-  grid-template-columns: 1fr;
-}
-
-.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__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__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__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));
-  }
-
-  .detail-section__grid {
-    grid-template-columns: 1fr;
-  }
-}
-
-@media (width <= 768px) {
-  .meeting-section {
-    padding: 16px;
-  }
-
-  .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>

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

@@ -0,0 +1,259 @@
+<script lang="ts" setup>
+import type { DeptOption, DetailItem, OperationMeeting } from './types'
+import MeetingDetailDrawer from './components/meeting-detail-drawer.vue'
+import OperationMeetingContent from './components/operation-meeting-content.vue'
+import { OperationMeetingApi } from '@/api/pms/meeting'
+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[]
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  meetingSeries: '',
+  year: '',
+  deptOptions: () => []
+})
+
+const emits = defineEmits<{
+  'update:visible': [visible: boolean]
+}>()
+
+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())
+
+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 normalizeDetailItem = (data?: Record<string, unknown>): DetailItem => ({
+  raw: data || {},
+  projectName: String(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 || ''),
+  equipmentUtilizationRate: parseNumberValue(data?.equipmentUtilizationRate),
+  keyWorkCompletion: String(data?.keyWorkCompletion || ''),
+  problemsAnalysis: String(data?.problemsAnalysis || ''),
+  nextPlannedWorkload: String(data?.nextPlannedWorkload || ''),
+  priorityTasks: String(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 || ''),
+      meetingSeries: parseMeetingSeries(data.meetingSeries)
+    },
+    details: details.map((item) => normalizeDetailItem(item as Record<string, unknown>))
+  }
+}
+
+const resetForm = () => {
+  summaryMeetings.value = []
+  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)
+    )
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleViewDetail = (row: DetailItem) => {
+  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
+          v-for="item in summaryMeetings"
+          :key="item.key"
+          label-width="auto"
+          label-position="top"
+          size="default"
+          :model="item.meeting"
+          class="summary-form__meeting"
+        >
+          <OperationMeetingContent
+            :meeting="item.meeting"
+            :details="item.details"
+            type="view"
+            :dept-options="deptOptions"
+            :loading="loading"
+            @edit-detail="handleViewDetail"
+          />
+        </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__meeting {
+  margin-bottom: 16px;
+}
+
+.summary-form__meeting:last-child {
+  margin-bottom: 0;
+}
+</style>

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

@@ -0,0 +1,292 @@
+<script lang="ts" setup>
+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 dayjs from 'dayjs'
+
+const loading = ref(false)
+const list = ref<OperationMeetingListItem[]>([])
+const visible = ref(false)
+const currentMeetingSeries = ref('')
+const currentYear = ref('')
+
+const { ZmTable, ZmTableColumn } = useTableComponents<OperationMeetingListItem>()
+
+const deptOptions = ref<DeptOption[]>([])
+
+async function getDeptOptions() {
+  const deptList = await OperationMeetingApi.getDeptList()
+  deptOptions.value = deptList
+    .filter((item) => {
+      return item.type === '1'
+    })
+    .map((item) => ({
+      label: item.name,
+      value: item.id as number
+    }))
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    const res = await OperationMeetingApi.getIntegratedMeetingList()
+    list.value = res || []
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  getList()
+}
+
+function resetQuery() {
+  getList()
+}
+
+function getMeetingYear(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
+  const meetingDate = dayjs(row.meetingDate)
+
+  return meetingDate.isValid() ? meetingDate.format('YYYY') : ''
+}
+
+function handleView(row: OperationMeetingListItem) {
+  currentMeetingSeries.value = row.meetingSeries || ''
+  currentYear.value = getMeetingYear(row)
+  visible.value = true
+}
+
+function formatMeetingDate(row: Pick<OperationMeetingListItem, 'meetingDate'>) {
+  return row.meetingDate ? dayjs(row.meetingDate).format('YYYY-MM-DD') : '-'
+}
+
+onMounted(() => {
+  getDeptOptions()
+  getList()
+})
+</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))]"
+  >
+    <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]"
+    >
+      <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"
+        >
+          <el-button type="primary" class="!ml-0 w-full sm:w-auto" @click="handleQuery">
+            搜索
+          </el-button>
+          <el-button class="!ml-0 w-full sm:w-auto" @click="resetQuery">重置</el-button>
+        </div>
+      </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"
+    >
+      <div class="flex-1 min-h-0 relative">
+        <el-auto-resizer class="operation-meeting-table-view absolute">
+          <template #default="{ width, height }">
+            <zm-table
+              :data="list"
+              :loading="loading"
+              :width="width"
+              :max-height="height"
+              :height="height"
+              show-border
+            >
+              <ZmTableColumn label="会议期次" prop="meetingSeries" width="180" />
+              <ZmTableColumn label="公司名称" prop="companyName" :min-width="220" />
+              <ZmTableColumn
+                label="会议日期"
+                prop="meetingDate"
+                cover-formatter
+                :real-value="formatMeetingDate"
+                :min-width="160"
+              />
+              <ZmTableColumn label="操作" width="100" fixed="right">
+                <template #default="{ row }">
+                  <el-button size="default" link type="success" @click="handleView(row)">
+                    查看
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+
+        <div v-loading="loading" class="operation-meeting-card-view">
+          <template v-if="list.length">
+            <article v-for="item in list" :key="item.id" class="operation-meeting-card">
+              <div class="operation-meeting-card__header">
+                <div class="operation-meeting-card__title">
+                  <span>会议期次</span>
+                  <strong>{{ item.meetingSeries || '-' }}</strong>
+                </div>
+              </div>
+
+              <div class="operation-meeting-card__content">
+                <div class="operation-meeting-card__field">
+                  <span>公司名称</span>
+                  <strong>{{ item.companyName || '-' }}</strong>
+                </div>
+                <div class="operation-meeting-card__field">
+                  <span>会议日期</span>
+                  <strong>{{ formatMeetingDate(item) }}</strong>
+                </div>
+              </div>
+
+              <div class="operation-meeting-card__actions">
+                <el-button size="default" link type="success" @click="handleView(item)">
+                  查看
+                </el-button>
+              </div>
+            </article>
+          </template>
+          <el-empty v-else description="暂无数据" :image-size="80" />
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <summary-form
+    v-model:visible="visible"
+    :meeting-series="currentMeetingSeries"
+    :year="currentYear"
+    :dept-options="deptOptions"
+    @update:visible="visible = $event"
+  />
+</template>
+
+<style scoped lang="scss">
+.operation-meeting-query__actions {
+  width: min(100%, 428px);
+  max-width: 100%;
+}
+
+:deep(.operation-meeting-query .el-button) {
+  max-width: 100%;
+  min-width: 0;
+}
+
+.operation-meeting-table-view {
+  display: block;
+}
+
+.operation-meeting-card-view {
+  display: none;
+}
+
+.operation-meeting-card {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  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%);
+}
+
+.operation-meeting-card__header,
+.operation-meeting-card__actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.operation-meeting-card__title {
+  display: flex;
+  min-width: 0;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.operation-meeting-card__title span,
+.operation-meeting-card__field span {
+  font-size: 12px;
+  line-height: 1.4;
+  color: var(--el-text-color-secondary);
+}
+
+.operation-meeting-card__title strong {
+  overflow: hidden;
+  font-size: 16px;
+  line-height: 1.35;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.operation-meeting-card__content {
+  display: grid;
+  gap: 10px;
+}
+
+.operation-meeting-card__field {
+  display: grid;
+  grid-template-columns: 72px minmax(0, 1fr);
+  gap: 12px;
+  align-items: start;
+}
+
+.operation-meeting-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;
+}
+
+.operation-meeting-card__actions {
+  justify-content: flex-end;
+  padding-top: 2px;
+  border-top: 1px solid var(--el-border-color-lighter);
+}
+
+@media (width >= 640px) {
+  .operation-meeting-query__actions {
+    width: auto;
+    max-width: none;
+  }
+}
+
+@media (width < 768px) {
+  .operation-meeting-page {
+    height: auto;
+    min-height: calc(
+      100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+    );
+    grid-template-rows: auto 1fr;
+    gap: 12px;
+  }
+
+  .operation-meeting-data-panel {
+    min-height: 280px;
+    padding: 12px;
+  }
+
+  .operation-meeting-table-view {
+    display: none;
+  }
+
+  .operation-meeting-card-view {
+    display: flex;
+    min-height: 180px;
+    flex: 1;
+    flex-direction: column;
+    gap: 12px;
+    overflow-y: auto;
+  }
+}
+</style>