Zimo 6 روز پیش
والد
کامیت
bf40366447

+ 1 - 1
.env.local

@@ -4,7 +4,7 @@ NODE_ENV=development
 VITE_DEV=true
 
 # 请求路径  http://192.168.188.79:48080  https://iot.deepoil.cc  http://172.26.0.56:48080
-VITE_BASE_URL='https://iot.deepoil.cc'
+VITE_BASE_URL='http://192.168.188.198:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server

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

@@ -0,0 +1,23 @@
+import request from '@/config/axios'
+
+export const OperationMeetingApi = {
+  // 查询会议分页
+  getOperationMeetingPage: async (params: {
+    pageNo: number
+    pageSize: number
+    deptId?: number
+    meetingDate?: string[]
+  }) => {
+    return await request.get({ url: `/pms/iot-operation-meeting/page`, params })
+  },
+
+  // 查询会议详情
+  getOperationMeeting: async (id: number) => {
+    return await request.get({ url: `/pms/iot-operation-meeting/get?id=${id}` })
+  },
+
+  // 保存运营会议及明细
+  saveBatch: async (data: { operationMeeting: unknown; details: unknown[] }) => {
+    return await request.post({ url: `/pms/iot-operation-meeting/saveBatch`, data })
+  }
+}

+ 41 - 20
src/components/ZmTable/ZmTableColumn.vue

@@ -3,8 +3,12 @@ import { type TableColumnCtx } from 'element-plus'
 import { computed, useAttrs, inject, ref } from 'vue'
 import { Filter } from '@element-plus/icons-vue'
 import { SortOrder, TableContextKey } from './token'
+import { DefaultRow } from 'element-plus/es/components/table/src/table/defaults'
 
-interface Props extends /* @vue-ignore */ Partial<Omit<TableColumnCtx<T>, 'prop'>> {
+interface Props
+  extends /* @vue-ignore */ Partial<
+    Omit<TableColumnCtx<T extends DefaultRow ? T : DefaultRow>, 'prop'>
+  > {
   prop?: (keyof T & string) | (string & {})
   zmSortable?: boolean
   zmFilterable?: boolean
@@ -90,7 +94,7 @@ const handleSearchClick = () => {
   }
 }
 
-const getTextWidth = (text: string, fontSize = 14) => {
+const getTextWidth = (text: string, fontSize = 12) => {
   const span = document.createElement('span')
   span.style.visibility = 'hidden'
   span.style.position = 'absolute'
@@ -111,17 +115,14 @@ const calculativeWidth = () => {
     .map((item) => props.realValue?.(item) || item[props.prop])
     .filter(Boolean)
 
-  let labelWidth = getTextWidth(bindProps.value.label || '') + 34
+  let labelWidth = getTextWidth(bindProps.value.label || '') + 30
 
-  if (props.zmFilterable || props.zmSortable) {
-    labelWidth += 8
-  }
-
-  if (props.zmFilterable) labelWidth += 22
-  if (props.zmSortable) labelWidth += 22
+  if (props.zmFilterable || props.zmSortable) labelWidth += 8
+  if (props.zmFilterable) labelWidth += 20
+  if (props.zmSortable) labelWidth += 20
 
   const maxWidth = Math.min(
-    Math.max(...values.map((value) => getTextWidth(value) + 34), labelWidth),
+    Math.max(...values.map((value) => getTextWidth(value) + 30), labelWidth),
     360
   )
 
@@ -195,7 +196,7 @@ watch(
               </template>
 
               <slot name="filter" v-bind="scope">
-                <div class="flex gap-2 p-1">
+                <div class="filter-panel">
                   <el-input
                     :model-value="bindProps.filterModelValue"
                     @input="(val) => emits('update:filterModelValue', val)"
@@ -223,42 +224,56 @@ watch(
   align-items: center;
   width: 100%;
   height: 100%;
+  min-width: 0;
+  gap: 6px;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 16px;
+  color: #6b7f99;
   user-select: none;
+
+  .truncate {
+    min-width: 0;
+  }
 }
 
 .action-area {
   display: flex;
+  flex: 0 0 auto;
   height: 100%;
-  margin-left: 8px;
+  margin-left: 4px;
   align-items: center;
-  gap: 4px;
+  gap: 3px;
 }
 
 .icon-btn {
   display: flex;
-  width: 20px;
-  height: 20px;
-  color: var(--el-text-color-secondary);
+  width: 18px;
+  height: 18px;
+  color: #8aa0b8;
   cursor: pointer;
   border-radius: 4px;
-  transition: background-color 0.2s;
   align-items: center;
   justify-content: center;
+  transition:
+    color 0.16s ease,
+    background-color 0.16s ease;
 
   &:hover {
     color: var(--el-color-primary);
-    background-color: var(--el-fill-color-darker);
+    background-color: var(--el-color-primary-light-9);
   }
 
   &.is-active {
     color: var(--el-color-primary);
+    background-color: var(--el-color-primary-light-9);
   }
 }
 
 .zm-sort-icon {
   position: relative;
   display: flex;
-  width: 12px;
+  width: 10px;
   height: 12px;
   align-items: center;
   justify-content: center;
@@ -266,7 +281,7 @@ watch(
   &::before,
   &::after {
     position: absolute;
-    width: 8px;
+    width: 7px;
     height: 2px;
     background-color: currentcolor;
     border-radius: 2px;
@@ -292,4 +307,10 @@ watch(
     }
   }
 }
+
+.filter-panel {
+  display: flex;
+  padding: 4px;
+  gap: 8px;
+}
 </style>

+ 163 - 30
src/components/ZmTable/index.vue

@@ -1,8 +1,12 @@
 <script lang="ts" setup generic="T">
 import type { TableInstance, TableProps } from 'element-plus'
 import { FilterPayload, SortField, SortOrder, TableContextKey } from './token'
+import { DefaultRow } from 'element-plus/es/components/table/src/table/defaults'
 
-interface Props extends /* @vue-ignore */ Partial<Omit<TableProps<T>, 'data'>> {
+interface Props
+  extends /* @vue-ignore */ Partial<
+    Omit<TableProps<T extends DefaultRow ? T : DefaultRow>, 'data'>
+  > {
   data: T[]
   loading: boolean
   handleQuery?: (payload?: FilterPayload) => void
@@ -105,9 +109,28 @@ defineExpose({
   </el-table>
 </template>
 
-<style>
+<style lang="scss">
 .zm-table {
-  border-radius: 8px;
+  --zm-table-radius: 10px;
+  --zm-table-border-color: #e7edf4;
+  --zm-table-header-border-color: #e3eaf2;
+  --zm-table-row-border-color: #edf2f7;
+  --zm-table-header-bg: #f7f9fc;
+  --zm-table-header-text-color: #6b7f99;
+  --zm-table-body-text-color: #40546d;
+  --zm-table-strong-text-color: #24364d;
+  --zm-table-stripe-bg: #fcfdff;
+  --zm-table-hover-bg: #f5f9ff;
+  --zm-table-current-bg: #eef6ff;
+
+  width: 100%;
+  overflow: hidden;
+  font-size: 12px;
+  color: var(--zm-table-body-text-color);
+  background: var(--el-bg-color);
+  border: 1px solid var(--zm-table-border-color);
+  border-radius: var(--zm-table-radius);
+  box-shadow: none;
 
   &::before,
   &::after {
@@ -125,20 +148,59 @@ defineExpose({
     display: none;
   }
 
+  .el-table__inner-wrapper,
+  .el-table__header-wrapper,
+  .el-table__body-wrapper,
+  .el-scrollbar__wrap {
+    background: transparent;
+  }
+
+  .el-table__inner-wrapper {
+    border-radius: var(--zm-table-radius);
+  }
+
   .el-table__cell {
-    height: 52px;
+    height: 38px;
+    padding: 0;
+    color: var(--zm-table-body-text-color);
+    background: var(--el-bg-color);
+    border-right: 1px solid var(--zm-table-row-border-color) !important;
+    border-bottom: 1px solid var(--zm-table-row-border-color) !important;
+    transition:
+      background-color 0.16s ease,
+      color 0.16s ease;
 
     &:last-child {
       border-right: none !important;
     }
   }
 
+  .cell {
+    padding-right: 13px;
+    padding-left: 13px;
+    line-height: 18px;
+  }
+
   .el-table__header {
-    border-bottom-right-radius: 8px;
-    border-bottom-left-radius: 8px;
+    color: var(--zm-table-header-text-color);
 
     .el-table__cell {
-      background: var(--el-fill-color-light) !important;
+      height: 36px;
+      font-size: 12px;
+      font-weight: 600;
+      color: var(--zm-table-header-text-color);
+      background: var(--zm-table-header-bg) !important;
+      border-right: 1px solid var(--zm-table-header-border-color) !important;
+      border-bottom: 1px solid var(--zm-table-header-border-color) !important;
+
+      .cell {
+        display: flex;
+        min-height: 100%;
+        align-items: center;
+        justify-content: center;
+        padding-top: 0;
+        padding-bottom: 0;
+      }
 
       &:last-child {
         .cell {
@@ -150,41 +212,120 @@ defineExpose({
     tr:first-child {
       .el-table__cell {
         &:first-child {
-          border-bottom-left-radius: 8px;
+          border-top-left-radius: var(--zm-table-radius);
         }
 
         &:last-child {
-          border-bottom-right-radius: 8px;
+          border-top-right-radius: var(--zm-table-radius);
         }
       }
     }
+
+    tr:not(:last-child) {
+      .el-table__cell {
+        height: 42px;
+        border-bottom-color: var(--zm-table-header-border-color) !important;
+      }
+    }
   }
 
-  .el-table__body-wrapper {
-    .el-table__cell {
-      &:last-child {
-        border-top-right-radius: 8px;
-        border-bottom-right-radius: 8px;
+  .el-table__body {
+    tr.el-table__row--striped {
+      .el-table__cell {
+        background: var(--zm-table-stripe-bg);
       }
+    }
+
+    tr:hover,
+    tr.hover-row {
+      .el-table__cell {
+        background: var(--zm-table-hover-bg) !important;
+      }
+    }
+
+    tr.current-row {
+      .el-table__cell {
+        // color: var(--el-color-primary);
+        background: var(--zm-table-current-bg) !important;
+      }
+    }
+  }
+
+  .el-table__row {
+    .el-table__cell {
+      font-weight: 500;
+      color: var(--zm-table-strong-text-color);
 
       &:first-child {
-        border-bottom-left-radius: 8px;
-        border-top-left-radius: 8px;
+        .cell {
+          padding-left: 16px;
+        }
+      }
+
+      &:last-child {
+        .cell {
+          padding-right: 16px;
+        }
       }
     }
   }
-}
 
-.zm-table:not(.show-border) {
-  .el-table__cell {
-    border: none !important;
+  .el-table__empty-block {
+    min-height: 148px;
+    background: var(--el-bg-color);
+  }
+
+  .el-table__empty-text {
+    font-size: 12px;
+    color: var(--el-text-color-secondary);
+  }
+
+  .el-table__cell.el-table-fixed-column--left,
+  .el-table__cell.el-table-fixed-column--right {
+    background: inherit;
+  }
+
+  .el-table__cell.el-table-fixed-column--left.is-last-column {
+    box-shadow: 6px 0 12px -10px rgb(15 23 42 / 22%);
+  }
+
+  .el-table__cell.el-table-fixed-column--right.is-first-column {
+    box-shadow: -6px 0 12px -10px rgb(15 23 42 / 22%);
+  }
+
+  .el-table__fixed-right-patch {
+    background: var(--zm-table-header-bg);
+    border-bottom: 1px solid var(--zm-table-header-border-color);
+  }
+
+  .el-scrollbar__bar {
+    &.is-horizontal {
+      height: 7px;
+    }
+
+    &.is-vertical {
+      width: 7px;
+    }
   }
 
+  .el-scrollbar__thumb {
+    background: #b8c5d6;
+    border-radius: 999px;
+    opacity: 0.55;
+
+    &:hover {
+      opacity: 0.85;
+    }
+  }
+}
+
+.zm-table:not(.show-border) {
   .el-table__header {
     .el-table__cell {
+      border-right-color: var(--zm-table-header-border-color) !important;
+
       .cell {
-        border-right: var(--el-table-border);
-        border-color: var(--el-table-header-text-color);
+        border-right: none;
       }
 
       &:last-child {
@@ -194,13 +335,5 @@ defineExpose({
       }
     }
   }
-
-  .el-table__row {
-    &:last-child {
-      .el-table__cell {
-        border-bottom: none;
-      }
-    }
-  }
 }
 </style>

+ 1 - 0
src/styles/var.css

@@ -71,4 +71,5 @@ body {
   margin: 0;
   padding: 0;
   box-sizing: border-box;
+  font-family: 'Noto Sans SC';
 }

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

@@ -0,0 +1,269 @@
+<script lang="ts" setup>
+import { OperationMeetingApi } from '@/api/pms/meeting'
+import { getSimpleDeptList } from '@/api/system/dept'
+import type { DeptOption, OperationMeetingListItem } from './types'
+import { rangeShortcuts } from '@/utils/formatTime'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import MeetingForm from './meeting-form.vue'
+import dayjs from 'dayjs'
+import { useUserStore } from '@/store/modules/user'
+
+interface Query {
+  pageNo: number
+  pageSize: number
+  deptId: number | undefined
+  meetingDate: string[]
+}
+
+const userStore = useUserStore()
+const userDeptId = userStore.getUser.deptId
+
+const createInitialQuery = (): Query => ({
+  pageNo: 1,
+  pageSize: 10,
+  deptId: undefined,
+  meetingDate: []
+})
+
+const query = ref<Query>(createInitialQuery())
+const loading = ref(false)
+const total = ref(0)
+const list = ref<OperationMeetingListItem[]>([])
+const visible = ref(false)
+const type = ref('create' as 'create' | 'edit' | 'view')
+const currentId = ref<number>()
+
+const { ZmTable, ZmTableColumn } = useTableComponents<OperationMeetingListItem>()
+
+const deptOptions = ref<DeptOption[]>([])
+
+async function getDeptOptions() {
+  const deptList = await getSimpleDeptList()
+  deptOptions.value = deptList
+    .filter((item) => item.id === userDeptId)
+    .map((item) => ({
+      label: item.name,
+      value: item.id as number
+    }))
+}
+
+async function getList() {
+  loading.value = true
+  try {
+    const res = await OperationMeetingApi.getOperationMeetingPage(query.value)
+    list.value = res.list || []
+    total.value = res.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  query.value.pageNo = 1
+  getList()
+}
+
+function handleSizeChange(val: number) {
+  query.value.pageSize = val
+  handleQuery()
+}
+
+function handleCurrentChange(val: number) {
+  query.value.pageNo = val
+  handleQuery()
+}
+
+function resetQuery() {
+  query.value = createInitialQuery()
+  getList()
+}
+
+onMounted(() => {
+  getDeptOptions()
+  getList()
+})
+
+function handleCreate() {
+  type.value = 'create'
+  currentId.value = undefined
+  visible.value = true
+}
+
+function handleEdit(id: number) {
+  type.value = 'edit'
+  currentId.value = id
+  visible.value = true
+}
+
+function handleView(id: number) {
+  type.value = 'view'
+  currentId.value = id
+  visible.value = true
+}
+</script>
+<template>
+  <div
+    class="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="top"
+      class="operation-meeting-query min-w-0 overflow-hidden rounded-lg bg-white p-4 shadow dark:bg-[#1d1e1f]"
+    >
+      <div class="min-w-0 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
+        <div
+          class="operation-meeting-query__fields min-w-0 flex-1 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-[repeat(2,minmax(0,320px))]"
+        >
+          <el-form-item label="所属公司/项目部" class="operation-meeting-query__item mb-0! min-w-0">
+            <el-select
+              v-model="query.deptId"
+              class="w-full!"
+              placeholder="请选择所属公司/项目部"
+              :options="deptOptions"
+              clearable
+            />
+          </el-form-item>
+
+          <el-form-item label="会议日期" class="operation-meeting-query__item mb-0! min-w-0">
+            <el-date-picker
+              v-model="query.meetingDate"
+              type="daterange"
+              value-format="yyyy-MM-dd"
+              range-separator="至"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+              :shortcuts="rangeShortcuts"
+              class="w-full!"
+            />
+          </el-form-item>
+        </div>
+
+        <div
+          class="operation-meeting-query__actions min-w-0 flex flex-col gap-3 sm:flex-row lg:shrink-0"
+        >
+          <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>
+          <el-button type="primary" plain class="!ml-0 w-full sm:w-auto" @click="handleCreate">
+            新建
+          </el-button>
+        </div>
+      </div>
+    </el-form>
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-4">
+      <div class="flex-1 relative">
+        <el-auto-resizer class="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="120" />
+              <ZmTableColumn label="公司名称" prop="companyName" :min-width="220" />
+              <ZmTableColumn
+                label="会议日期"
+                prop="meetingDate"
+                cover-formatter
+                :real-value="
+                  (row: OperationMeetingListItem) => dayjs(row.meetingDate).format('YYYY-MM-DD')
+                "
+                :min-width="160"
+              />
+              <ZmTableColumn label="操作" width="120" fixed="right">
+                <template #default="{ row }">
+                  <el-button size="default" link type="primary" @click="handleEdit(row.id)">
+                    编辑
+                  </el-button>
+                  <el-button size="default" link type="success" @click="handleView(row.id)">
+                    查看
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <el-pagination
+          size="default"
+          v-show="total > 0"
+          :current-page="query.pageNo"
+          :page-size="query.pageSize"
+          :background="true"
+          :page-sizes="[10, 20, 30, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </div>
+  </div>
+  <meeting-form
+    v-model:visible="visible"
+    :id="currentId"
+    :type="type"
+    :dept-options="deptOptions"
+    @update:visible="visible = $event"
+    @success="getList"
+  />
+</template>
+
+<style scoped lang="scss">
+.operation-meeting-query__fields,
+.operation-meeting-query__actions {
+  width: min(100%, 428px);
+  max-width: 100%;
+}
+
+.operation-meeting-query__item {
+  width: 100%;
+  max-width: 100%;
+}
+
+:deep(.operation-meeting-query .el-form-item__content),
+:deep(.operation-meeting-query .el-select),
+:deep(.operation-meeting-query .el-date-editor),
+:deep(.operation-meeting-query .el-button) {
+  max-width: 100%;
+  min-width: 0;
+}
+
+:deep(.operation-meeting-query .el-select),
+:deep(.operation-meeting-query .el-date-editor) {
+  width: 100% !important;
+  box-sizing: border-box;
+}
+
+:deep(.operation-meeting-query .el-range-editor.el-input__wrapper) {
+  display: flex;
+  width: 100% !important;
+  max-width: 100%;
+  min-width: 0;
+}
+
+:deep(.operation-meeting-query .el-range-input) {
+  min-width: 0;
+}
+
+:deep(.operation-meeting-query .el-range-separator) {
+  flex-shrink: 0;
+}
+
+@media (width >= 640px) {
+  .operation-meeting-query__fields {
+    width: min(100%, 664px);
+  }
+
+  .operation-meeting-query__actions {
+    width: auto;
+    max-width: none;
+  }
+}
+</style>

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

@@ -0,0 +1,805 @@
+<script lang="ts" setup>
+import type { FormInstance, FormRules } from 'element-plus'
+import type { DeptOption, DetailItem, OperationMeeting } from './types'
+import { OperationMeetingApi } from '@/api/pms/meeting'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+
+interface Props {
+  visible: boolean
+  id?: number
+  type: 'create' | 'edit' | 'view'
+  deptOptions?: DeptOption[]
+}
+
+interface SummaryColumn {
+  property?: string
+}
+
+interface DetailSummaryMethodProps {
+  columns: SummaryColumn[]
+  data: DetailItem[]
+}
+
+interface OperationMeetingForm
+  extends Omit<Partial<OperationMeeting>, 'meetingDate' | 'meetingSeries'> {
+  meetingDate?: number | string | Date
+  meetingSeries?: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  type: 'create',
+  deptOptions: () => []
+})
+
+const emits = defineEmits(['update:visible', 'success'])
+const message = useMessage()
+
+const operationMeeting = ref<OperationMeetingForm>({})
+const operationMeetingRef = ref<FormInstance>()
+const loading = ref(false)
+
+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 drawerTitle = computed(() =>
+  props.type === 'create' ? '创建会议' : props.type === 'edit' ? '编辑会议' : '查看会议'
+)
+
+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 || ''
+  }
+
+  return ''
+})
+
+const detailDrawerTitle = computed(() => {
+  if (props.type === 'view') {
+    return '查看会议明细'
+  }
+
+  return detailFormType.value === 'create' ? '新增会议明细' : '编辑会议明细'
+})
+
+const createDetailItem = (): DetailItem => ({
+  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 => ({
+  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 => ({
+  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 detailForm = ref<DetailItem>(createDetailItem())
+
+const detailSummaryFields = [
+  'currentRevenue',
+  'cumulativeRevenue',
+  'currentOnAccount',
+  'cumulativeOnAccount',
+  'currentPayment',
+  'cumulativePayment'
+]
+
+const requiredTextRule = (message: string) => [
+  {
+    required: true,
+    whitespace: true,
+    message,
+    trigger: 'blur'
+  }
+]
+
+const requiredNumberRule = (message: string) => [
+  {
+    required: true,
+    type: 'number' as const,
+    message,
+    trigger: ['blur', 'change']
+  }
+]
+
+const operationMeetingRules = reactive<FormRules>({
+  meetingDate: [{ required: true, message: '请选择会议日期', trigger: 'change' }],
+  meetingSeries: [
+    {
+      required: true,
+      type: 'number',
+      message: '请输入会议期次',
+      trigger: ['blur', 'change']
+    },
+    {
+      type: 'number',
+      min: 1,
+      message: '会议期次最小为1',
+      trigger: ['blur', 'change']
+    }
+  ],
+  support: requiredTextRule('请输入需集团协调支持的事项')
+})
+
+const detailRules = reactive<FormRules>({
+  projectName: requiredTextRule('请输入项目部'),
+  currentRevenue: requiredNumberRule('请输入收入-本期'),
+  cumulativeRevenue: requiredNumberRule('请输入收入-累计'),
+  currentOnAccount: requiredNumberRule('请输入挂帐-本期'),
+  cumulativeOnAccount: requiredNumberRule('请输入挂帐-累计'),
+  currentPayment: requiredNumberRule('请输入回款-本期'),
+  cumulativePayment: requiredNumberRule('请输入回款-累计'),
+  plannedWorkload: requiredTextRule('请输入计划工作量'),
+  actualCompletion: requiredTextRule('请输入实际完成'),
+  equipmentUtilizationRate: requiredNumberRule('请输入设备利用率'),
+  keyWorkCompletion: requiredTextRule('请输入重点工作及完成情况'),
+  problemsAnalysis: requiredTextRule('请输入存在问题及分析'),
+  nextPlannedWorkload: requiredTextRule('请输入下期计划工作量'),
+  priorityTasks: requiredTextRule('请输入重点工作事项')
+})
+
+const formatSummaryNumber = (value: number) =>
+  value.toLocaleString('zh-CN', {
+    maximumFractionDigits: 2,
+    minimumFractionDigits: Number.isInteger(value) ? 0 : 2
+  })
+
+const getDetailSummaries = ({ columns, data }: DetailSummaryMethodProps) => {
+  const sums: string[] = []
+
+  columns.forEach((column, index) => {
+    if (index === 0) {
+      sums[index] = '公司整体'
+      return
+    }
+
+    if (!column.property || !detailSummaryFields.includes(column.property)) {
+      sums[index] = ''
+      return
+    }
+
+    const total = data.reduce(
+      (sum, item) => sum + Number(item[column.property as keyof DetailItem] || 0),
+      0
+    )
+    sums[index] = formatSummaryNumber(total)
+  })
+
+  return sums
+}
+
+const handleAddDetailItem = () => {
+  detailFormType.value = 'create'
+  detailEditingIndex.value = -1
+  detailForm.value = createDetailItem()
+  detailDrawerVisible.value = true
+  nextTick(() => detailFormRef.value?.clearValidate())
+}
+
+const handleEditDetailItem = (row: DetailItem, index: number) => {
+  detailFormType.value = 'edit'
+  detailEditingIndex.value = index
+  detailForm.value = cloneDetailItem(row)
+  detailDrawerVisible.value = true
+  nextTick(() => detailFormRef.value?.clearValidate())
+}
+
+const handleDeleteDetailItem = (index: number) => {
+  detailItems.value.splice(index, 1)
+}
+
+const handleDetailDrawerChange = (visible: boolean) => {
+  detailDrawerVisible.value = visible
+
+  if (!visible) {
+    detailForm.value = createDetailItem()
+    detailFormType.value = 'create'
+    detailEditingIndex.value = -1
+    nextTick(() => detailFormRef.value?.clearValidate())
+  }
+}
+
+const resetForm = () => {
+  operationMeeting.value = {}
+  detailItems.value = []
+  loading.value = false
+  handleDetailDrawerChange(false)
+  nextTick(() => operationMeetingRef.value?.clearValidate())
+}
+
+const loadOperationMeetingDetail = async (id: number) => {
+  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)
+    }
+
+    const details = Array.isArray(data?.details) ? data.details : []
+    detailItems.value = details.map((item) => normalizeDetailItem(item as Record<string, unknown>))
+    nextTick(() => operationMeetingRef.value?.clearValidate())
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleVisibleChange = (visible: boolean) => {
+  emits('update:visible', visible)
+
+  if (!visible) {
+    resetForm()
+  }
+}
+
+watch(
+  () => [props.visible, props.id, props.type] as const,
+  ([visible, id, type]) => {
+    if (!visible) return
+
+    if (type === 'create') {
+      resetForm()
+      return
+    }
+
+    if (id) {
+      loadOperationMeetingDetail(id)
+    }
+  }
+)
+
+const formatMeetingSeries = (value: number) => `第${value}期`
+
+const getMeetingDateTimestamp = (value: OperationMeetingForm['meetingDate']) => {
+  if (value instanceof Date) return value.getTime()
+  if (typeof value === 'number') return value
+  if (typeof value === 'string') {
+    const timestamp = new Date(value).getTime()
+
+    return Number.isNaN(timestamp) ? undefined : timestamp
+  }
+
+  return undefined
+}
+
+const buildOperationMeetingPayload = (): Partial<OperationMeeting> => {
+  const payload: Partial<OperationMeeting> = {
+    meetingDate: getMeetingDateTimestamp(operationMeeting.value.meetingDate),
+    meetingSeries: formatMeetingSeries(operationMeeting.value.meetingSeries as number),
+    support: operationMeeting.value.support || ''
+  }
+
+  if (operationMeeting.value.id) {
+    payload.id = operationMeeting.value.id
+  }
+
+  return payload
+}
+
+const buildDetailPayload = (item: DetailItem): DetailItem => cloneDetailItem(item)
+
+const submitForm = async () => {
+  if (props.type === 'view' || !operationMeetingRef.value) return
+
+  const valid = await operationMeetingRef.value.validate().catch(() => false)
+
+  if (!valid) return
+
+  if (detailItems.value.length === 0) {
+    message.warning('请先新增至少一条会议明细')
+    return
+  }
+
+  loading.value = true
+  try {
+    await OperationMeetingApi.saveBatch({
+      operationMeeting: buildOperationMeetingPayload(),
+      details: detailItems.value.map((item) => buildDetailPayload(item))
+    })
+
+    message.success('保存成功')
+    handleVisibleChange(false)
+    emits('success')
+  } finally {
+    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)
+}
+</script>
+
+<template>
+  <el-drawer
+    :model-value="visible"
+    @update:model-value="handleVisibleChange"
+    header-class="mb-0! p-4!"
+    body-class="bg-gray-100"
+    footer-class="p-4!"
+    size="92.2%"
+  >
+    <template #header>
+      <div class="flex items-center">
+        <span class="font-bold text-xl">{{ drawerTitle }}</span>
+      </div>
+    </template>
+
+    <el-form
+      ref="operationMeetingRef"
+      label-position="top"
+      size="default"
+      :model="operationMeeting"
+      :rules="operationMeetingRules"
+      v-loading="loading"
+      scroll-to-error
+      require-asterisk-position="right"
+      :disabled="type === 'view'"
+    >
+      <section class="p-6 bg-white border-solid border-1 border-gray-200/90 rounded-xl mb-6">
+        <h3 class="text-lg font-bold mb-4">会议信息</h3>
+
+        <div class="meeting-section__grid">
+          <el-form-item
+            label="所属公司/项目部"
+            class="meeting-form-item mb-0! min-w-0"
+          >
+            <el-input
+              :model-value="companyDisplayName"
+              class="w-full!"
+              placeholder="新建保存后系统自动填充"
+              disabled
+            />
+            <div class="meeting-form-tip">新建保存后由系统自动填充。</div>
+          </el-form-item>
+
+          <el-form-item
+            label="会议日期"
+            prop="meetingDate"
+            class="meeting-form-item mb-0! min-w-0"
+          >
+            <el-date-picker
+              v-model="operationMeeting.meetingDate"
+              type="date"
+              placeholder="请选择会议日期"
+              class="w-full!"
+            />
+          </el-form-item>
+
+          <el-form-item
+            label="会议期次"
+            prop="meetingSeries"
+            class="meeting-form-item 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="需集团协调支持的事项"
+            prop="support"
+            class="meeting-form-item meeting-section__grid-full mb-0! min-w-0"
+          >
+            <el-input
+              v-model="operationMeeting.support"
+              type="textarea"
+              :rows="4"
+              placeholder="请输入需集团协调支持的事项"
+            />
+          </el-form-item>
+        </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>
+        <ZmTable
+          :data="detailItems"
+          :loading="loading"
+          show-summary
+          :summary-method="getDetailSummaries"
+        >
+          <zm-table-column label="项目部" prop="projectName" />
+          <zm-table-column label="收入(万元)">
+            <zm-table-column label="本期" prop="currentRevenue" />
+            <zm-table-column label="累计" prop="cumulativeRevenue" />
+          </zm-table-column>
+          <zm-table-column label="挂帐(万元)">
+            <zm-table-column label="本期" prop="currentOnAccount" />
+            <zm-table-column label="累计" prop="cumulativeOnAccount" />
+          </zm-table-column>
+          <zm-table-column label="回款(万元)">
+            <zm-table-column label="本期" prop="currentPayment" />
+            <zm-table-column label="累计" prop="cumulativePayment" />
+          </zm-table-column>
+          <zm-table-column label="本期生产运行情况">
+            <zm-table-column label="计划工作量" prop="plannedWorkload" />
+            <zm-table-column label="实际完成" prop="actualCompletion" />
+            <zm-table-column label="设备利用率" prop="equipmentUtilizationRate" />
+          </zm-table-column>
+          <zm-table-column label="生产管理情况及重点工作	">
+            <zm-table-column label="重点工作及完成情况" prop="keyWorkCompletion" />
+            <zm-table-column label="存在问题及分析" prop="problemsAnalysis" />
+          </zm-table-column>
+          <zm-table-column label="下期工作计划		">
+            <zm-table-column label="计划工作量" prop="nextPlannedWorkload" />
+            <zm-table-column label="重点工作事项" prop="priorityTasks" />
+          </zm-table-column>
+          <zm-table-column label="操作" width="120" fixed="right">
+            <template #default="{ row, $index }">
+              <el-button
+                link
+                size="small"
+                type="primary"
+                @click="handleEditDetailItem(row, $index)"
+              >
+                {{ type === 'view' ? '查看' : '编辑' }}
+              </el-button>
+              <el-button
+                v-if="type !== 'view'"
+                link
+                size="small"
+                type="danger"
+                @click="handleDeleteDetailItem($index)"
+              >
+                删除
+              </el-button>
+            </template>
+          </zm-table-column>
+        </ZmTable>
+      </section>
+    </el-form>
+
+    <template #footer>
+      <el-button size="default" @click="handleVisibleChange(false)">取消</el-button>
+      <el-button
+        v-if="type !== 'view'"
+        size="default"
+        type="primary"
+        :loading="loading"
+        @click="submitForm"
+      >
+        保存
+      </el-button>
+    </template>
+
+    <el-drawer
+      :model-value="detailDrawerVisible"
+      @update:model-value="handleDetailDrawerChange"
+      :append-to-body="true"
+      size="50%"
+      :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">
+          <h4 class="detail-section__title">项目基础</h4>
+          <div class="detail-section__grid detail-section__grid--single">
+            <el-form-item label="项目部" prop="projectName">
+              <el-input v-model="detailForm.projectName" placeholder="请输入项目部" clearable />
+            </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"
+                :min="0"
+                :precision="2"
+              />
+            </el-form-item>
+            <el-form-item label="收入-累计" prop="cumulativeRevenue">
+              <el-input-number
+                v-model="detailForm.cumulativeRevenue"
+                class="w-full!"
+                :controls="false"
+                :min="0"
+                :precision="2"
+              />
+            </el-form-item>
+            <el-form-item label="挂帐-本期" prop="currentOnAccount">
+              <el-input-number
+                v-model="detailForm.currentOnAccount"
+                class="w-full!"
+                :controls="false"
+                :min="0"
+                :precision="2"
+              />
+            </el-form-item>
+            <el-form-item label="挂帐-累计" prop="cumulativeOnAccount">
+              <el-input-number
+                v-model="detailForm.cumulativeOnAccount"
+                class="w-full!"
+                :controls="false"
+                :min="0"
+                :precision="2"
+              />
+            </el-form-item>
+            <el-form-item label="回款-本期" prop="currentPayment">
+              <el-input-number
+                v-model="detailForm.currentPayment"
+                class="w-full!"
+                :controls="false"
+                :min="0"
+                :precision="2"
+              />
+            </el-form-item>
+            <el-form-item label="回款-累计" prop="cumulativePayment">
+              <el-input-number
+                v-model="detailForm.cumulativePayment"
+                class="w-full!"
+                :controls="false"
+                :min="0"
+                :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"
+                :min="0"
+                :max="100"
+                :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>
+  </el-drawer>
+</template>
+
+<style scoped lang="scss">
+.meeting-section__grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 32px;
+}
+
+.meeting-section__grid-full {
+  grid-column: 1 / -1;
+}
+
+.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;
+}
+
+@media (width <= 960px) {
+  .meeting-section__grid {
+    grid-template-columns: 1fr;
+  }
+
+  .detail-section__grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (width <= 768px) {
+  .meeting-section {
+    padding: 16px;
+  }
+}
+</style>

+ 103 - 0
src/views/pms/operation-meeting/meeting-table.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import type { OperationMeetingListItem } from './types'
+import dayjs from 'dayjs'
+
+const props = defineProps({
+  list: {
+    type: Array as PropType<OperationMeetingListItem[]>,
+    default: () => []
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  },
+  total: {
+    type: Number,
+    default: 0
+  },
+  pageNo: {
+    type: Number,
+    default: 1
+  },
+  pageSize: {
+    type: Number,
+    default: 10
+  },
+  showAction: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emits = defineEmits(['sizeChange', 'currentChange'])
+
+const { list, loading, total, pageNo, pageSize, showAction } = toRefs(props)
+const { ZmTable, ZmTableColumn } = useTableComponents<OperationMeetingListItem>()
+
+function handleSizeChange(val: number) {
+  emits('sizeChange', val)
+}
+
+function handleCurrentChange(val: number) {
+  emits('currentChange', val)
+}
+</script>
+
+<template>
+  <div
+    class="min-w-0 min-h-0 flex flex-col overflow-hidden rounded-lg bg-white p-4 shadow dark:bg-[#1d1e1f]"
+  >
+    <div class="min-w-0 min-h-0 flex-1 relative overflow-hidden">
+      <el-auto-resizer class="absolute">
+        <template #default="{ width, height }">
+          <ZmTable
+            :data="list"
+            :loading="loading"
+            :width="width"
+            :max-height="height"
+            :height="height"
+            show-border
+          >
+            <ZmTableColumn label="会议期次" prop="session" :min-width="120" />
+            <ZmTableColumn label="公司名称" prop="companyName" :min-width="220" />
+            <ZmTableColumn label="项目数" :min-width="120">
+              <template #default="{ row }">
+                <el-tag type="info" effect="plain">{{ row.projectItems?.length || 0 }} 项</el-tag>
+              </template>
+            </ZmTableColumn>
+            <ZmTableColumn
+              label="会议日期"
+              prop="meetingDate"
+              cover-formatter
+              :real-value="
+                (row: OperationMeetingListItem) => dayjs(row.meetingDate).format('YYYY-MM-DD')
+              "
+              :min-width="160"
+            />
+            <ZmTableColumn label="操作" :width="140" fixed="right" v-if="showAction">
+              <template #default="scope">
+                <slot name="action" :row="scope.row"></slot>
+              </template>
+            </ZmTableColumn>
+          </ZmTable>
+        </template>
+      </el-auto-resizer>
+    </div>
+
+    <div class="mt-2 flex items-center justify-end overflow-x-auto">
+      <el-pagination
+        size="default"
+        v-show="total > 0"
+        :current-page="pageNo"
+        :page-size="pageSize"
+        :background="true"
+        :page-sizes="[10, 20, 30, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>

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

@@ -0,0 +1,41 @@
+export interface DeptOption {
+  label: string
+  value: number
+}
+
+export interface OperationMeeting {
+  id?: number
+  deptId?: number
+  companyName?: string
+  meetingDate: number
+  meetingSeries: string
+  support: string
+}
+
+export interface DetailItem {
+  projectName: string
+  currentRevenue: number | undefined // 本期收入
+  cumulativeRevenue: number | undefined // 累计收入
+  currentOnAccount: number | undefined // 本期挂帐
+  cumulativeOnAccount: number | undefined // 累计挂帐
+  currentPayment: number | undefined // 本期回款
+  cumulativePayment: number | undefined // 累计回款
+  plannedWorkload: string // 计划工作量
+  actualCompletion: string // 实际完成
+  equipmentUtilizationRate: number | undefined // 设备利用率
+  keyWorkCompletion: string // 重点工作及完成情况
+  problemsAnalysis: string // 存在问题及分析
+  nextPlannedWorkload: string // 下期计划工作量
+  priorityTasks: string // 重点工作事项
+}
+
+export interface OperationMeetingListItem {
+  id: number
+  deptId: number | undefined
+  companyName: string
+  meetingDate: number
+  meetingSeries: string
+  support?: string
+}
+
+export type OperationMeetingOpenType = 'create' | 'edit' | 'readonly'

+ 17 - 2
uno.config.ts

@@ -1,4 +1,10 @@
-import { defineConfig, toEscapedSelector as e, presetUno, presetIcons } from 'unocss'
+import {
+  defineConfig,
+  toEscapedSelector as e,
+  presetUno,
+  presetIcons,
+  presetWebFonts
+} from 'unocss'
 // import transformerVariantGroup from '@unocss/transformer-variant-group'
 
 export default defineConfig({
@@ -115,7 +121,16 @@ ${selector}:after {
       }
     ]
   ],
-  presets: [presetUno({ dark: 'class', attributify: false }), presetIcons()],
+  presets: [
+    presetUno({ dark: 'class', attributify: false }),
+    presetIcons(),
+    presetWebFonts({
+      provider: 'google',
+      fonts: {
+        sans: 'Noto Sans SC'
+      }
+    })
+  ],
   // transformers: [transformerVariantGroup()],
   shortcuts: {
     'wh-full': 'w-full h-full'