1. 完成项目管理全流程优化,补充阶段推进、汇报统计及生命周期记录。
2. 完成业务与项目数据关联,支持查看项目进度、详情及相关附件。
3. 完成采购审批流程接入,支持配置审批人员及采购业务审批。
4. 精简用户、角色、部门、菜单和字典,完善操作日志及附件下载。
已添加2个文件
已修改24个文件
1697 ■■■■■ 文件已修改
sql/20261103_project_workflow_upgrade.sql 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261105_project_phase_history.sql 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261105_system_data_cleanup.sql 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/workflow.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/file/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/AttachmentUpload/image/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Dialog/FileList.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Dialog/FileListDialog.vue 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/FileUpload/index.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/PIMTable/PIMTable.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/customerFile/index.vue 325 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/approvalManagement/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/approvalProcess/fileList.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/approvalProcess/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/sealManagement/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/shipmentReview/fileList.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/voucher/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/procurementManagement/procurementLedger/fileList.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/components/ProjectExecutionPanel.vue 420 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/components/formDia.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/index.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/projectDetail.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safeProduction/safeWorkApproval/fileList.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/salesManagement/salesLedger/fileList.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/salesManagement/salesLedger/index.vue 377 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261103_project_workflow_upgrade.sql
@@ -27,9 +27,6 @@
CALL add_column_if_missing('project_management_info','project_leader_name',"varchar(100) DEFAULT NULL COMMENT '项目负责人'");
CALL add_column_if_missing('project_management_info','project_manager_ids',"json DEFAULT NULL COMMENT '项目经理ID数组'");
CALL add_column_if_missing('project_management_info','project_manager_names',"json DEFAULT NULL COMMENT '项目经理名称数组'");
CALL add_column_if_missing('project_management_info','project_executor_ids',"json DEFAULT NULL COMMENT '项目执行人ID数组'");
CALL add_column_if_missing('project_management_info','project_executor_names',"json DEFAULT NULL COMMENT '项目执行人名称数组'");
-- æ–°ç‰ˆçš„“立项”是开始前的信息准备活动,开始后统一进入“开发”阶段。
UPDATE project_management_info SET current_phase = '开发' WHERE current_phase = '立项' AND is_delete = 0;
CALL add_column_if_missing('project_management_info','follow_cycle',"varchar(20) DEFAULT 'MONTH' COMMENT 'MONTH每月 QUARTER每季度 YEAR每年'");
sql/20261105_project_phase_history.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `project_management_phase_history` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `project_id` bigint NOT NULL,
  `phase_code` varchar(30) NOT NULL,
  `phase_name` varchar(50) NOT NULL,
  `action_type` varchar(30) NOT NULL,
  `action_label` varchar(100) NOT NULL,
  `form_data` json DEFAULT NULL,
  `remark` varchar(2000) DEFAULT NULL,
  `attachments` json DEFAULT NULL,
  `operator_name` varchar(100) DEFAULT NULL,
  `action_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_phase_history_project` (`project_id`,`action_time`),
  KEY `idx_phase_history_phase` (`project_id`,`phase_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目阶段办理历史';
sql/20261105_system_data_cleanup.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,168 @@
-- å½“前项目系统基础数据精简脚本
-- æ‰§è¡Œå‰å¤‡ä»½ï¼š.local-backups/product-inventory-management-xmgl-before-system-cleanup-20261105-145021.sql
-- ä¸‰ä¸ªè´¦å·åˆå§‹å¯†ç ç»Ÿä¸€ä¸ºï¼šadmin123(登录后请及时修改普通用户和客户密码)
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
START TRANSACTION;
-- 1. ç”¨æˆ·ã€è§’色、部门:仅保留管理员、普通用户、客户三类账号。
DELETE FROM sys_user_post;
DELETE FROM sys_user_role;
DELETE FROM sys_user_dept;
DELETE FROM sys_role_menu;
DELETE FROM sys_role_dept;
DELETE FROM sys_user WHERE user_id <> 1;
UPDATE sys_user
SET user_name='admin', nick_name='管理员账号', user_type='00',
    password='$2a$10$E0Rg.GqdeQd1ba2Z5P87HOsOELqoKwYwKFQGRGop.LEPaom7yzji.',
    status='0', del_flag='0', tenant_id=208, update_by='system-cleanup', update_time=NOW(),
    remark='系统管理员'
WHERE user_id=1;
INSERT INTO sys_user
(user_id,user_name,nick_name,user_type,email,phonenumber,sex,password,status,del_flag,
 create_by,create_time,remark,tenant_id,ai_enabled)
VALUES
(2,'user','普通用户','00','','','0','$2a$10$E0Rg.GqdeQd1ba2Z5P87HOsOELqoKwYwKFQGRGop.LEPaom7yzji.','0','0',
 'admin',NOW(),'项目日常使用账号',208,0),
(3,'customer','客户账号','01','','','0','$2a$10$E0Rg.GqdeQd1ba2Z5P87HOsOELqoKwYwKFQGRGop.LEPaom7yzji.','0','0',
 'admin',NOW(),'客户门户账号',208,0);
DELETE FROM sys_role;
INSERT INTO sys_role
(role_id,role_name,role_key,role_sort,data_scope,menu_check_strictly,dept_check_strictly,status,del_flag,create_by,create_time,remark)
VALUES
(1,'系统管理员','admin',1,'1',1,1,'0','0','admin',NOW(),'拥有全部系统权限'),
(2,'普通用户','common',2,'2',1,1,'0','0','admin',NOW(),'业务、采购和项目日常操作'),
(3,'客户角色','customer',3,'5',1,1,'0','0','admin',NOW(),'仅访问客户门户');
DELETE FROM sys_dept;
INSERT INTO sys_dept
(dept_id,parent_id,ancestors,dept_name,order_num,leader,status,del_flag,create_by,create_time,dept_nick)
VALUES
(100,0,'0','工作室',1,'管理员账号','0','0','admin',NOW(),'工作室'),
(101,100,'0,100','综合管理部',1,'管理员账号','0','0','admin',NOW(),'综合'),
(102,100,'0,100','项目部',2,'普通用户','0','0','admin',NOW(),'项目');
INSERT INTO sys_user_role(user_id,role_id) VALUES (1,1),(2,2),(3,3);
INSERT INTO sys_user_dept(user_id,dept_id,create_user,create_time)
VALUES (1,100,1,NOW()),(2,102,1,NOW()),(3,100,1,NOW());
INSERT INTO sys_role_dept(role_id,dept_id) VALUES (1,100),(1,101),(1,102),(2,102),(3,100);
-- å²—位在当前项目流程中没有独立业务作用:清空岗位并从菜单中移除。
DELETE FROM sys_post;
-- 2. èœå•:只保留当前项目实际使用的业务菜单和精简后的系统管理。
INSERT INTO sys_menu
(menu_id,menu_name,parent_id,order_num,path,component,query,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
VALUES
(2390,'采购审批',2053,4,'purchaseApproval','collaborativeApproval/approvalProcess/index5',NULL,'PurchaseApproval',1,0,'C','0','0','approval:purchase:list','clipboard','admin',NOW(),'按审批流程配置逐级审批采购单')
ON DUPLICATE KEY UPDATE menu_name=VALUES(menu_name),parent_id=VALUES(parent_id),order_num=VALUES(order_num),
 path=VALUES(path),component=VALUES(component),route_name=VALUES(route_name),visible='0',status='0',perms=VALUES(perms),icon=VALUES(icon);
DELETE FROM sys_menu
WHERE menu_id NOT IN (
  1,100,101,102,103,105,108,500,501,
  1000,1001,1002,1003,1004,1005,1006,
  1007,1008,1009,1010,1011,
  1012,1013,1014,1015,
  1016,1017,1018,1019,
  1025,1026,1027,1028,1029,
  1039,1040,1041,1042,1043,1044,1045,
  2053,2370,2371,2293,2390,
  2010,2001,2362,2363,2364,2365,2366,2367,2368,
  2372,2011,2360,2361,
  2015,2016,
  2276,2285,2373,2376,2377,2378,2379,2380,2381,2382,2383,2384,2385,2388,
  2374,2375,2386,2387,
  2357,2358,2359
);
UPDATE sys_menu SET visible='0',status='0';
UPDATE sys_menu SET order_num=1 WHERE menu_id=2053;
UPDATE sys_menu SET order_num=2 WHERE menu_id=2010;
UPDATE sys_menu SET order_num=3 WHERE menu_id=2372;
UPDATE sys_menu SET order_num=4 WHERE menu_id=2015;
UPDATE sys_menu SET order_num=5 WHERE menu_id=2276;
UPDATE sys_menu SET order_num=6 WHERE menu_id=2374;
UPDATE sys_menu SET order_num=7 WHERE menu_id=1;
UPDATE sys_menu SET order_num=8 WHERE menu_id=2357;
UPDATE sys_menu SET menu_name='审批流程配置' WHERE menu_id=2293;
UPDATE sys_menu SET menu_name='采购管理' WHERE menu_id=2015;
UPDATE sys_menu SET menu_name='采购台账' WHERE menu_id=2016;
UPDATE sys_menu SET menu_name='项目列表' WHERE menu_id=2285;
-- ç®¡ç†å‘˜æ‹¥æœ‰å…¨éƒ¨ä¿ç•™èœå•。
INSERT INTO sys_role_menu(role_id,menu_id) SELECT 1,menu_id FROM sys_menu;
-- æ™®é€šç”¨æˆ·ï¼šè¡Œæ”¿ç”³è¯·/审批、客户、业务、采购、项目、政策文件。
INSERT INTO sys_role_menu(role_id,menu_id)
SELECT 2,menu_id FROM sys_menu WHERE menu_id IN (
  2053,2370,2371,2390,
  2010,2001,2367,2368,
  2372,2011,2360,2361,
  2015,2016,
  2276,2285,2373,2376,2377,2378,2379,2380,2381,2382,2383,2384,2385,2388,
  2374,2375,2386,2387
);
-- å®¢æˆ·è§’色:只开放客户资料入口。
INSERT INTO sys_role_menu(role_id,menu_id)
SELECT 3,menu_id FROM sys_menu WHERE menu_id IN (2357,2358,2359);
-- 3. å­—典:删除无关制造业历史字典,保留系统字典并增加当前项目选择项。
DELETE FROM sys_dict_data;
DELETE FROM sys_dict_type;
INSERT INTO sys_dict_type(dict_id,dict_name,dict_type,status,create_by,create_time,remark) VALUES
(1,'用户性别','sys_user_sex','0','admin',NOW(),'系统字典'),
(2,'菜单显示状态','sys_show_hide','0','admin',NOW(),'系统字典'),
(3,'系统启用状态','sys_normal_disable','0','admin',NOW(),'系统字典'),
(4,'是否选项','sys_yes_no','0','admin',NOW(),'系统字典'),
(5,'操作类型','sys_oper_type','0','admin',NOW(),'日志字典'),
(6,'通用状态','sys_common_status','0','admin',NOW(),'系统字典'),
(7,'税率','tax_rate','0','admin',NOW(),'业务采购通用'),
(8,'项目来源','project_source','0','admin',NOW(),'项目管理'),
(9,'项目阶段','project_phase','0','admin',NOW(),'项目管理'),
(10,'项目跟进周期','project_follow_cycle','0','admin',NOW(),'项目管理'),
(11,'项目资料类型','project_material_type','0','admin',NOW(),'项目管理'),
(12,'通用审批状态','approval_status','0','admin',NOW(),'采购及协同审批'),
(13,'业务审批状态','business_approval_status','0','admin',NOW(),'业务管理'),
(14,'财务报表审批状态','financial_report_status','0','admin',NOW(),'客户管理'),
(15,'客户等级','customer_level','0','admin',NOW(),'客户管理'),
(16,'政策级别','policy_level','0','admin',NOW(),'政府政策文件');
INSERT INTO sys_dict_data
(dict_sort,dict_label,dict_value,dict_type,css_class,list_class,is_default,status,create_by,create_time,remark)
VALUES
(1,'男','0','sys_user_sex','','','N','0','admin',NOW(),''),(2,'女','1','sys_user_sex','','','N','0','admin',NOW(),''),(3,'未知','2','sys_user_sex','','info','Y','0','admin',NOW(),''),
(1,'显示','0','sys_show_hide','','primary','Y','0','admin',NOW(),''),(2,'隐藏','1','sys_show_hide','','info','N','0','admin',NOW(),''),
(1,'正常','0','sys_normal_disable','','success','Y','0','admin',NOW(),''),(2,'停用','1','sys_normal_disable','','danger','N','0','admin',NOW(),''),
(1,'是','Y','sys_yes_no','','success','N','0','admin',NOW(),''),(2,'否','N','sys_yes_no','','info','Y','0','admin',NOW(),''),
(0,'其他','0','sys_oper_type','','info','N','0','admin',NOW(),''),(1,'新增','1','sys_oper_type','','success','N','0','admin',NOW(),''),(2,'修改','2','sys_oper_type','','primary','N','0','admin',NOW(),''),(3,'删除','3','sys_oper_type','','danger','N','0','admin',NOW(),''),(5,'导出','5','sys_oper_type','','warning','N','0','admin',NOW(),''),(6,'导入','6','sys_oper_type','','warning','N','0','admin',NOW(),''),
(1,'正常','0','sys_common_status','','success','Y','0','admin',NOW(),''),(2,'停用','1','sys_common_status','','danger','N','0','admin',NOW(),''),
(1,'0%','0','tax_rate','','info','N','0','admin',NOW(),''),(2,'1%','1','tax_rate','','','N','0','admin',NOW(),''),(3,'3%','3','tax_rate','','','N','0','admin',NOW(),''),(4,'6%','6','tax_rate','','','N','0','admin',NOW(),''),(5,'9%','9','tax_rate','','','N','0','admin',NOW(),''),(6,'13%','13','tax_rate','','primary','Y','0','admin',NOW(),''),
(1,'手动创建','MANUAL','project_source','','primary','Y','0','admin',NOW(),''),(2,'业务生成','BUSINESS','project_source','','success','N','0','admin',NOW(),''),
(1,'未开始','未开始','project_phase','','info','Y','0','admin',NOW(),''),(2,'立项','立项','project_phase','','warning','N','0','admin',NOW(),''),(3,'开发','开发','project_phase','','primary','N','0','admin',NOW(),''),(4,'申报','申报','project_phase','','warning','N','0','admin',NOW(),''),(5,'申报结果','申报结果','project_phase','','success','N','0','admin',NOW(),''),(6,'验收','验收','project_phase','','success','N','0','admin',NOW(),''),(7,'已完成','已完成','project_phase','','success','N','0','admin',NOW(),''),
(1,'每月','MONTHLY','project_follow_cycle','','','Y','0','admin',NOW(),''),(2,'每季度','QUARTERLY','project_follow_cycle','','','N','0','admin',NOW(),''),(3,'每年','YEARLY','project_follow_cycle','','','N','0','admin',NOW(),''),
(1,'立项资料','INITIATION','project_material_type','','','N','0','admin',NOW(),''),(2,'开发资料','DEVELOPMENT','project_material_type','','','N','0','admin',NOW(),''),(3,'申报资料','APPLICATION','project_material_type','','','N','0','admin',NOW(),''),(4,'验收资料','ACCEPTANCE','project_material_type','','','N','0','admin',NOW(),''),(5,'其他','OTHER','project_material_type','','info','Y','0','admin',NOW(),''),
(0,'草稿','0','approval_status','','info','Y','0','admin',NOW(),''),(1,'待审批','1','approval_status','','warning','N','0','admin',NOW(),''),(2,'审批中','2','approval_status','','primary','N','0','admin',NOW(),''),(3,'已通过','3','approval_status','','success','N','0','admin',NOW(),''),(4,'已驳回','4','approval_status','','danger','N','0','admin',NOW(),''),
(0,'草稿','0','business_approval_status','','info','Y','0','admin',NOW(),''),(1,'待审批','1','business_approval_status','','warning','N','0','admin',NOW(),''),(2,'已通过','2','business_approval_status','','success','N','0','admin',NOW(),''),(3,'已退回','3','business_approval_status','','danger','N','0','admin',NOW(),''),
(0,'待审批','0','financial_report_status','','warning','Y','0','admin',NOW(),''),(1,'已通过','1','financial_report_status','','success','N','0','admin',NOW(),''),(2,'已退回','2','financial_report_status','','danger','N','0','admin',NOW(),''),
(1,'重点客户','A','customer_level','','danger','N','0','admin',NOW(),''),(2,'普通客户','B','customer_level','','primary','Y','0','admin',NOW(),''),(3,'潜在客户','C','customer_level','','info','N','0','admin',NOW(),''),
(1,'国家级','NATIONAL','policy_level','','danger','N','0','admin',NOW(),''),(2,'省级','PROVINCE','policy_level','','warning','N','0','admin',NOW(),''),(3,'市级','CITY','policy_level','','primary','Y','0','admin',NOW(),''),(4,'区县级','DISTRICT','policy_level','','info','N','0','admin',NOW(),'');
-- é‡‡è´­å®¡æ‰¹é»˜è®¤é…ç½®ç®¡ç†å‘˜ä¸ºç¬¬ä¸€çº§å®¡æ‰¹äººï¼ŒåŽç»­å¯åœ¨â€œå®¡æ‰¹æµç¨‹é…ç½®â€ä¸­è°ƒæ•´ã€‚
DELETE FROM approve_process_config_node WHERE approve_type=5;
INSERT INTO approve_process_config_node
(approve_type,node_order,approver_id,approver_name,timeout_hours,tenant_id,create_user,create_time,delete_flag,dept_id)
VALUES (5,1,1,'管理员账号',24,208,1,NOW(),0,100);
-- 4. æ—¥å¿—从当前项目正式版本重新开始记录。
DELETE FROM sys_oper_log;
DELETE FROM sys_logininfor;
COMMIT;
SET FOREIGN_KEY_CHECKS = 1;
src/api/projectManagement/workflow.js
@@ -4,11 +4,12 @@
  url: `/projectManagement/workflow/${projectId}`,
  method: 'get'
})
export const getProjectLifecycle = projectId => request({ url: `/projectManagement/workflow/lifecycle/${projectId}`, method: 'get' })
export const startProjectWorkflow = projectId => request({ url: `/projectManagement/workflow/start/${projectId}`, method: 'post' })
export const updateProjectExecutors = data => request({ url: '/projectManagement/workflow/project-executors', method: 'post', data })
export const transitionProjectStage = data => request({ url: '/projectManagement/workflow/transition', method: 'post', data })
export const saveProjectModule = data => request({ url: '/projectManagement/workflow/module', method: 'post', data })
export const saveProjectModulesBatch = data => request({ url: '/projectManagement/workflow/modules/batch', method: 'post', data })
export const deleteProjectModule = id => request({ url: `/projectManagement/workflow/module/${id}`, method: 'delete' })
export const saveProjectFollowUp = data => request({ url: '/projectManagement/workflow/follow-up', method: 'post', data })
export const deleteProjectFollowUp = id => request({ url: `/projectManagement/workflow/follow-up/${id}`, method: 'delete' })
src/components/AttachmentUpload/file/index.vue
@@ -280,7 +280,6 @@
      :on-change="handleChange"
      :on-remove="handleRemove"
      :on-exceed="handleExceed"
      :on-preview="openFile"
    >
      <el-icon class="upload-drag-icon"><UploadFilled /></el-icon>
      <div class="el-upload__text">
src/components/AttachmentUpload/image/index.vue
@@ -270,7 +270,6 @@
        :limit="limit"
        :on-change="handleChange"
        :on-remove="handleRemove"
        :on-preview="handlePreview"
        :on-exceed="handleExceed"
    >
      <div class="upload-trigger">
src/components/Dialog/FileList.vue
@@ -43,13 +43,6 @@
                       type="primary"
                       size="small"
                       class="download-link"
                       @click="previewFile(scope.row.previewURL)">
              é¢„览
            </el-button>
            <el-button link
                       type="primary"
                       size="small"
                       class="download-link"
                       @click="downloadFile(scope.row.downloadURL)">
              ä¸‹è½½
            </el-button>
@@ -65,11 +58,9 @@
      </el-table>
    </div>
  </el-dialog>
  <filePreview ref="filePreviewRef" />
</template>
<script setup>
import { ElMessage } from 'element-plus'
  import { ref, computed, getCurrentInstance, onMounted, watch } from "vue";
  import AttachmentUpload from "@/components/AttachmentUpload/file/index.vue";
  import {
@@ -77,8 +68,6 @@
    deleteAttachment,
    createAttachment,
  } from "@/api/basicData/storageAttachment.js";
  import filePreview from '@/components/filePreview/index.vue'
  const filePreviewRef = ref()
  const props = defineProps({
    visible: {
@@ -132,15 +121,6 @@
  const handleClose = () => {
    isShow.value = false;
  };
  // é¢„览文件
  const previewFile = (url) => {
    if (url) {
      filePreviewRef.value.open(url)
    } else {
      ElMessage.warning('文件地址无效,无法预览')
    }
  }
  const handleUpload = () => {
    uploadDialogVisible.value = true;
@@ -260,4 +240,4 @@
  :deep(.el-table__empty-text) {
    color: #999;
  }
</style>
</style>
src/components/Dialog/FileListDialog.vue
@@ -49,13 +49,6 @@
                     @click="handleDownload(scope.row)">
            ä¸‹è½½
          </el-button>
          <el-button v-if="showPreview"
                     link
                     type="primary"
                     size="small"
                     @click="handlePreview(scope.row)">
            é¢„览
          </el-button>
          <el-button v-if="showDeleteButton"
                     link
                     type="danger"
@@ -77,16 +70,12 @@
                @pagination="paginationSearch"
                @change="handleChange" />
  </el-dialog>
<!-- // todo é™„件预览相关 -->
  <filePreview v-if="showPreview"
               ref="filePreviewRef" />
</template>
<script setup>
  import { ref, computed, getCurrentInstance } from "vue";
  import pagination from "@/components/Pagination/index.vue";
  import { ElMessage } from "element-plus";
  import filePreview from "@/components/filePreview/index.vue";
  import { getToken } from "@/utils/auth";
  const props = defineProps({
@@ -132,7 +121,7 @@
    },
    showPreview: {
      type: Boolean,
      default: true,
      default: false,
    },
    showUploadButton: {
      type: Boolean,
@@ -194,7 +183,6 @@
  ]);
  const { proxy } = getCurrentInstance();
  const filePreviewRef = ref(null);
  const uploadFileList = ref([]);
  const dialogVisible = computed({
@@ -225,17 +213,6 @@
    emit("download", row);
  };
  const handlePreview = row => {
    if (props.previewMethod) {
      props.previewMethod(row);
    } else {
      // é»˜è®¤é¢„览方法
      if (filePreviewRef.value) {
        filePreviewRef.value.open(row[props.urlField]);
      }
    }
    emit("preview", row);
  };
  const paginationSearch = page => {
    props.page.current = page.page;
    props.page.size = page.limit;
src/components/FileUpload/index.vue
@@ -20,11 +20,10 @@
    <!-- æ–‡ä»¶åˆ—表 -->
    <transition-group ref="uploadListRef" class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
      <li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
        <el-link :href="getFileUrl(file.url)" :underline="false" target="_blank">
          <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
        </el-link>
        <div class="ele-upload-list__item-content-action">
          <el-link :underline="false" @click="handleDelete(index)" type="danger" v-if="!disabled">&nbsp;删除</el-link>
        <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
        <div class="ele-upload-list__item-content-action">
          <el-link :underline="false" @click="downloadFile(file)" type="primary">&nbsp;下载</el-link>
          <el-link :underline="false" @click="handleDelete(index)" type="danger" v-if="!disabled">&nbsp;删除</el-link>
        </div>
      </li>
    </transition-group>
@@ -87,9 +86,18 @@
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action); // ä¸Šä¼ æ–‡ä»¶æœåŠ¡å™¨åœ°å€
const headers = ref({ Authorization: "Bearer " + getToken() });
const fileList = ref([]);
const showTip = computed(
const showTip = computed(
  () => props.isShowTip && (props.fileType || props.fileSize)
);
);
const downloadFile = file => {
  const url = file?.url || "";
  if (!url) {
    proxy.$modal.msgWarning("文件地址无效");
    return;
  }
  proxy.$download.resource(url);
};
watch(
  () => props.modelValue,
src/components/PIMTable/PIMTable.vue
@@ -116,7 +116,7 @@
        <!-- æŒ‰é’® -->
        <div v-else-if="item.dataType == 'action'"
             @click.stop>
          <template v-for="(o, key) in item.operation"
          <template v-for="(o, key) in item.operation.filter(operation => operation.name !== '预览')"
                    :key="key">
            <el-button v-show="o.type != 'upload'"
                       v-if="o.showHide ? o.showHide(scope.row) : true"
src/views/basicData/customerFile/index.vue
@@ -263,92 +263,107 @@
      </template>
    </el-dialog>
    <!-- è¿‘3年财务报表上传 -->
    <el-dialog title="近3年财务报表上传"
    <el-dialog title="近3年财务报表"
               v-model="reportDialogVisible"
               width="900px"
               width="980px"
               @close="closeReportDialog">
      <div class="report-customer">客户:{{ reportCustomer.customerName }}</div>
      <el-form :model="reportForm"
               label-width="90px"
               inline
               class="report-form">
        <el-form-item label="报表年度">
          <el-select v-model="reportForm.reportYear"
                     placeholder="请选择年度"
                     style="width: 150px">
            <el-option v-for="year in reportYears"
                       :key="year"
                       :label="year + 'å¹´'"
                       :value="year" />
          </el-select>
        </el-form-item>
        <el-form-item label="报表名称">
          <el-input v-model="reportForm.reportName"
                    placeholder="例如:2025年度财务报表"
                    style="width: 260px"
                    clearable />
        </el-form-item>
      </el-form>
      <FileUpload v-model="reportForm.reportFiles"
                  :limit="5"
                  :file-size="20"
                  :file-type="['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'png', 'zip', 'rar']" />
      <div class="report-actions">
        <el-button type="primary"
                   :loading="reportSaving"
                   @click="saveReport">保存上传</el-button>
        <el-button @click="resetReportForm">重置</el-button>
      <div class="report-dialog-head">
        <div>
          <div class="report-dialog-head__label">当前客户</div>
          <div class="report-dialog-head__name">{{ reportCustomer.customerName }}</div>
        </div>
        <el-tag type="primary" effect="light">
          å·²ä¸Šä¼  {{ reportRows.length }} / 3 ä¸ªå¹´åº¦
        </el-tag>
      </div>
      <el-table :data="reportRows"
                border
                size="small"
                style="margin-top: 16px">
        <el-table-column prop="reportYear"
                         label="年度"
                         width="90"
                         align="center" />
        <el-table-column prop="reportName"
                         label="报表名称"
                         min-width="180"
                         show-overflow-tooltip />
        <el-table-column label="报表附件"
                         min-width="260">
          <template #default="scope">
            <FileUpload v-model="scope.row.reportFiles"
                        disabled
                        :is-show-tip="false" />
          </template>
        </el-table-column>
        <el-table-column prop="uploadUserName"
                         label="上传人"
                         width="110" />
        <el-table-column prop="updateTime"
                         label="上传时间"
                         width="170" />
        <el-table-column label="审批状态"
                         width="100"
                         align="center">
          <template #default="scope">
            <el-tag :type="reportStatusTag(scope.row.auditStatus)">
              {{ reportStatusText(scope.row.auditStatus) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作"
                         width="140"
                         align="center">
          <template #default="scope">
            <el-button type="primary"
                       link
                       @click="editReport(scope.row)">修改</el-button>
            <el-button type="danger"
                       link
                       @click="deleteReport(scope.row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
      <div v-if="reportRows.length === 0"
           class="no-record">暂无财务报表,请按年度上传近3年财务报表</div>
      <div class="report-layout">
        <div class="report-upload-card">
          <div class="report-card-title">上传或替换报表</div>
          <div class="report-card-tip">每个年度保存一组报表文件,再次保存将更新该年度记录。</div>
          <el-form :model="reportForm" label-position="top" class="report-form">
            <el-form-item label="报表年度">
              <el-select
                v-model="reportForm.reportYear"
                placeholder="请选择年度"
                style="width: 100%"
                @change="selectReportYear"
              >
                <el-option
                  v-for="year in reportYears"
                  :key="year"
                  :label="year + 'å¹´'"
                  :value="year"
                />
              </el-select>
            </el-form-item>
            <el-form-item label="报表名称">
              <el-input
                v-model="reportForm.reportName"
                placeholder="例如:2026年度财务报表"
                clearable
              />
            </el-form-item>
            <el-form-item label="报表附件">
              <FileUpload
                v-model="reportForm.reportFiles"
                :limit="5"
                :file-size="20"
                :file-type="['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'png', 'zip', 'rar']"
              />
            </el-form-item>
          </el-form>
          <div class="report-actions">
            <el-button type="primary" :loading="reportSaving" @click="saveReport">
              {{ reportYearRecord(reportForm.reportYear) ? "保存修改" : "保存上传" }}
            </el-button>
            <el-button @click="resetReportForm">清空</el-button>
          </div>
        </div>
        <div class="report-year-list">
          <div class="report-card-title">近3年上传情况</div>
          <div
            v-for="year in reportYears"
            :key="year"
            class="report-year-card"
            :class="{ 'is-empty': !reportYearRecord(year) }"
          >
            <div class="report-year-card__year">{{ year }}å¹´</div>
            <template v-if="reportYearRecord(year)">
              <div class="report-year-card__body">
                <div class="report-year-card__name">
                  {{ reportYearRecord(year).reportName || year + "年度财务报表" }}
                </div>
                <div class="report-year-card__meta">
                  ä¸Šä¼ äººï¼š{{ reportYearRecord(year).uploadUserName || "-" }} Â·
                  {{ reportYearRecord(year).updateTime || "-" }}
                </div>
                <FileUpload
                  v-model="reportYearRecord(year).reportFiles"
                  disabled
                  :is-show-tip="false"
                />
              </div>
              <div class="report-year-card__actions">
                <el-tag :type="reportStatusTag(reportYearRecord(year).auditStatus)">
                  {{ reportStatusText(reportYearRecord(year).auditStatus) }}
                </el-tag>
                <el-button type="primary" link @click="editReport(reportYearRecord(year))">
                  ä¿®æ”¹
                </el-button>
                <el-button type="danger" link @click="deleteReport(reportYearRecord(year))">
                  åˆ é™¤
                </el-button>
              </div>
            </template>
            <template v-else>
              <div class="report-year-card__empty">尚未上传</div>
              <el-button type="primary" link @click="selectReportYear(year)">立即上传</el-button>
            </template>
          </div>
        </div>
      </div>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeReportDialog">关闭</el-button>
@@ -921,8 +936,12 @@
  const loadReports = customerId => {
    listCustomerFinancialReport({ customerId }).then(res => {
      reportRows.value = res.data || [];
      selectReportYear(reportForm.reportYear);
    });
  };
  const reportYearRecord = year =>
    reportRows.value.find(item => Number(item.reportYear) === Number(year));
  const reportStatusText = status => {
    if (status === 1) return "已通过";
@@ -937,17 +956,26 @@
  const resetReportForm = () => {
    reportForm.reportYear = nowYear;
    reportForm.reportName = "";
    reportForm.reportName = `${nowYear}年度财务报表`;
    reportForm.reportFiles = "";
  };
  const selectReportYear = year => {
    reportForm.reportYear = Number(year);
    const current = reportYearRecord(year);
    reportForm.reportName = current?.reportName || `${year}年度财务报表`;
    reportForm.reportFiles = current?.reportFiles || "";
  };
  const editReport = row => {
    reportForm.reportYear = row.reportYear;
    reportForm.reportName = row.reportName;
    reportForm.reportFiles = row.reportFiles;
    selectReportYear(row.reportYear);
  };
  const saveReport = () => {
    if (!reportForm.reportName?.trim()) {
      proxy.$modal.msgWarning("请输入报表名称");
      return;
    }
    if (!reportForm.reportFiles) {
      proxy.$modal.msgWarning("请上传财务报表附件");
      return;
@@ -1073,17 +1101,130 @@
    font-size: 14px;
  }
  .report-customer {
    font-size: 14px;
    color: #606266;
    margin-bottom: 12px;
  .report-dialog-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    margin-bottom: 16px;
    background: linear-gradient(135deg, #eef5ff, #f8fbff);
    border: 1px solid #dbe9fb;
    border-radius: 10px;
    &__label {
      margin-bottom: 3px;
      font-size: 12px;
      color: #8491a5;
    }
    &__name {
      font-size: 17px;
      font-weight: 700;
      color: #23334d;
    }
  }
  .report-layout {
    display: grid;
    grid-template-columns: 360px minmax(0, 1fr);
    gap: 18px;
    align-items: start;
  }
  .report-upload-card,
  .report-year-list {
    padding: 18px;
    background: #fff;
    border: 1px solid #e5eaf1;
    border-radius: 10px;
  }
  .report-card-title {
    margin-bottom: 6px;
    font-size: 16px;
    font-weight: 700;
    color: #26364d;
  }
  .report-card-tip {
    margin-bottom: 14px;
    font-size: 12px;
    line-height: 1.6;
    color: #8a95a6;
  }
  .report-form {
    margin-bottom: 4px;
    :deep(.el-form-item) {
      margin-bottom: 14px;
    }
  }
  .report-actions {
    display: flex;
    justify-content: flex-end;
    margin-top: 10px;
  }
  .report-year-card {
    display: flex;
    align-items: flex-start;
    gap: 14px;
    padding: 14px;
    margin-top: 12px;
    background: #f9fbfe;
    border: 1px solid #dfe7f2;
    border-radius: 9px;
    &.is-empty {
      align-items: center;
      color: #98a2b3;
      background: #fafafa;
      border-style: dashed;
    }
    &__year {
      flex: 0 0 58px;
      padding: 6px 8px;
      font-weight: 700;
      color: #1254b5;
      text-align: center;
      background: #eaf2ff;
      border-radius: 6px;
    }
    &__body {
      flex: 1;
      min-width: 0;
    }
    &__name {
      margin-bottom: 5px;
      font-weight: 600;
      color: #303b4c;
    }
    &__meta {
      margin-bottom: 5px;
      font-size: 12px;
      color: #8a95a6;
    }
    &__actions {
      display: flex;
      flex: 0 0 auto;
      align-items: center;
      gap: 4px;
    }
    &__empty {
      flex: 1;
      font-size: 13px;
    }
  }
  @media (max-width: 900px) {
    .report-layout {
      grid-template-columns: 1fr;
    }
  }
</style>
src/views/collaborativeApproval/approvalManagement/index.vue
@@ -241,7 +241,7 @@
    { value: "2", label: "请假管理", icon: "Calendar", color: "#67C23A" },
    { value: "3", label: "出差管理", icon: "Location", color: "#E6A23C" },
    // { value: '4', label: '报销管理', icon: 'Money', color: '#F56C6C' },
    // { value: '5', label: '采购审批', icon: 'ShoppingCart', color: '#909399' },
    { value: "5", label: "采购审批", icon: "ShoppingCart", color: "#4F7EF7" },
    // { value: '6', label: '报价审批', icon: 'DocumentChecked', color: '#9B59B6' },
    // { value: '7', label: '发货审批', icon: 'Van', color: '#1ABC9C' },
  ];
@@ -252,7 +252,7 @@
    2: "请假管理",
    3: "出差管理",
    // 4: '报销管理',
    // 5: '采购审批',
    5: "采购审批",
    // 6: '报价审批',
    // 7: '发货审批',
  };
src/views/collaborativeApproval/approvalProcess/fileList.vue
@@ -5,7 +5,6 @@
      <el-table-column fixed="right" label="操作" width="150" align="center">
        <template #default="scope">
          <el-button link type="primary" @click="downLoadFile(scope.row)">下载</el-button>
          <el-button link type="primary" @click="lookFile(scope.row)">预览</el-button>
          <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
        </template>
      </el-table-column>
@@ -63,4 +62,4 @@
})
</script>
<style></style>
<style></style>
src/views/collaborativeApproval/approvalProcess/index.vue
@@ -210,7 +210,7 @@
    { value: "2", label: "请假管理", icon: "Calendar", color: "#67C23A" },
    { value: "3", label: "出差管理", icon: "Location", color: "#E6A23C" },
    // { value: '4', label: '报销管理', icon: 'Money', color: '#F56C6C' },
    // { value: '5', label: '采购审批', icon: 'ShoppingCart', color: '#909399' },
    { value: "5", label: "采购审批", icon: "ShoppingCart", color: "#4F7EF7" },
    // { value: '6', label: '报价审批', icon: 'DocumentChecked', color: '#9B59B6' },
    // { value: '7', label: '发货审批', icon: 'Van', color: '#1ABC9C' },
  ];
src/views/collaborativeApproval/sealManagement/index.vue
@@ -137,7 +137,6 @@
            </el-table-column>
            <el-table-column fixed="right" label="操作" width="150" align="center">
              <template #default="scope">
                <el-button link type="primary" size="small" @click="previewFile(scope.row)">预览</el-button>
                <el-button link type="primary" size="small" @click="downloadFile(scope.row)">下载</el-button>
              </template>
            </el-table-column>
@@ -517,4 +516,4 @@
.attachment-table {
  border-radius: 4px;
}
</style>
</style>
src/views/collaborativeApproval/shipmentReview/fileList.vue
@@ -5,7 +5,6 @@
      <el-table-column fixed="right" label="操作" width="100" align="center">
        <template #default="scope">
          <el-button link type="primary" size="small" @click="downLoadFile(scope.row)">下载</el-button>
          <el-button link type="primary" size="small" @click="lookFile(scope.row)">预览</el-button>
        </template>
      </el-table-column>
    </el-table>
@@ -39,4 +38,4 @@
})
</script>
<style></style>
<style></style>
src/views/financialManagement/voucher/index.vue
@@ -245,7 +245,6 @@
            </el-table-column>
            <el-table-column fixed="right" label="操作" width="150" align="center">
              <template #default="scope">
                <el-button link type="primary" size="small" @click="previewFile(scope.row)">预览</el-button>
                <el-button link type="primary" size="small" @click="downloadFile(scope.row)">下载</el-button>
              </template>
            </el-table-column>
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
@@ -161,12 +161,6 @@
            {{ file.name || file.originalFilename }}
          </span>
          <div class="file-actions">
            <el-link v-if="file.previewURL || file.url"
                     type="primary"
                     :underline="false"
                     @click="openFile(file.previewURL || file.url)">预览</el-link>
            <el-divider v-if="(file.previewURL || file.url) && file.downloadURL"
                        direction="vertical" />
            <el-link v-if="file.downloadURL"
                     type="primary"
                     :underline="false"
@@ -283,4 +277,4 @@
    align-items: center;
    flex-shrink: 0;
  }
</style>
</style>
src/views/procurementManagement/procurementLedger/fileList.vue
@@ -5,7 +5,6 @@
      <el-table-column fixed="right" label="操作" width="150" align="center">
        <template #default="scope">
          <el-button link type="primary" size="small" @click="downLoadFile(scope.row)">下载</el-button>
          <el-button link type="primary" size="small" @click="lookFile(scope.row)">预览</el-button>
          <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
        </template>
      </el-table-column>
@@ -63,4 +62,4 @@
})
</script>
<style></style>
<style></style>
src/views/projectManagement/Management/components/ProjectExecutionPanel.vue
@@ -1,7 +1,7 @@
<template>
  <div v-loading="loading" class="execution-panel">
    <el-tabs v-model="activeTab" @tab-change="handleTabChange">
      <el-tab-pane v-if="data.sourceBusiness?.id" label="来源业务" name="business">
      <el-tab-pane v-if="!singleTab && data.sourceBusiness?.id" label="来源业务" name="business">
        <el-descriptions :column="3" border>
          <el-descriptions-item label="业务编号">{{ data.sourceBusiness.id }}</el-descriptions-item>
          <el-descriptions-item label="项目名称">{{ data.sourceBusiness.projectName || '-' }}</el-descriptions-item>
@@ -19,10 +19,10 @@
        </el-descriptions>
      </el-tab-pane>
      <el-tab-pane label="模块设置" name="modules">
      <el-tab-pane v-if="!singleTab || singleTab === 'modules'" :label="readonly ? '模块信息' : '模块管理'" name="modules">
        <div class="tab-actions">
          <span class="hint">模块占项目工作量合计:{{ totalModuleWeight }}%</span>
          <el-button v-hasPermi="['project:module:edit']" type="primary" @click="openModule()">新增模块</el-button>
          <el-button v-if="!readonly" v-hasPermi="['project:module:edit']" type="primary" @click="openModuleBatch">{{ isStarted ? '管理执行人' : '编辑模块' }}</el-button>
        </div>
        <el-table :data="data.modules" border>
          <el-table-column prop="moduleName" label="模块名称" min-width="160" />
@@ -41,56 +41,75 @@
              </el-tag>
            </template>
          </el-table-column>
          <el-table-column label="操作" width="140" align="center" fixed="right">
            <template #default="{ row }">
              <el-button v-hasPermi="['project:module:edit']" link type="primary" @click="openModule(row)">修改</el-button>
              <el-button v-hasPermi="['project:module:remove']" link type="danger" @click="removeModule(row)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
      </el-tab-pane>
      <el-tab-pane label="项目跟进" name="follow">
      <el-tab-pane v-if="isStarted && (!singleTab || singleTab === 'follow')" label="项目跟进" name="follow">
        <div class="tab-actions">
          <div class="hint">
            è·Ÿè¿›å‘¨æœŸï¼š{{ followCycleLabel }};
            <span v-if="data.followWindow?.dueDate">本次应跟进日 {{ data.followWindow.dueDate }},可填写 {{ data.followWindow.windowStart }} è‡³ {{ data.followWindow.windowEnd }}</span>
            <span v-else>首次跟进可直接填写</span>
          </div>
          <el-button v-hasPermi="['project:follow:add']" type="primary" @click="openFollow()">填写跟进</el-button>
          <el-button v-if="!readonly" v-hasPermi="['project:follow:add']" type="primary" @click="openFollow()">填写跟进</el-button>
        </div>
        <el-table :data="data.followUps" border>
          <el-table-column prop="followDate" label="跟进日期" width="130" />
          <el-table-column prop="createUserName" label="项目负责人" width="140" />
          <el-table-column prop="detail" label="跟进详情" min-width="300" show-overflow-tooltip />
          <el-table-column label="附件" width="100" align="center"><template #default="{ row }">{{ row.attachmentList?.length || 0 }}</template></el-table-column>
          <el-table-column label="操作" width="130" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:follow:remove']" link type="danger" @click="removeRecord('follow', row)">删除</el-button></template></el-table-column>
          <el-table-column label="附件" min-width="190">
            <template #default="{ row }">
              <div v-if="row.attachmentList?.length" class="file-cell"><el-button v-for="(file,index) in row.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
              <span v-else class="empty-file">无附件</span>
            </template>
          </el-table-column>
          <el-table-column v-if="!readonly" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:follow:add']" link type="primary" @click="openFollow(row)">编辑</el-button><el-button v-hasPermi="['project:follow:remove']" link type="danger" @click="removeRecord('follow', row)">删除</el-button></template></el-table-column>
        </el-table>
      </el-tab-pane>
      <el-tab-pane label="进度汇报与统计" name="reports">
        <div class="tab-actions">
          <span class="hint">成员完成比重根据模块工作量、每次新增进度及该次汇报人员比重计算</span>
          <el-button v-hasPermi="['project:report:add']" type="primary" :disabled="!eligibleReportModules.length" @click="openReport()">新增汇报</el-button>
        </div>
        <div class="report-layout">
          <div ref="chartRef" class="chart" />
          <el-table :data="data.reports" border class="report-table">
            <el-table-column prop="moduleName" label="项目模块" min-width="130" />
            <el-table-column label="汇报人员比重" min-width="210"><template #default="{ row }"><el-tag v-for="member in row.reporterShareList || []" :key="member.userId" class="member-tag">{{ member.userName }} {{ member.moduleShare }}%</el-tag></template></el-table-column>
            <el-table-column prop="progress" label="模块进度" width="100"><template #default="{ row }">{{ row.progress }}%</template></el-table-column>
            <el-table-column prop="reportDate" label="时间" width="120" />
            <el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
            <el-table-column label="文件" width="80" align="center"><template #default="{ row }">{{ row.attachmentList?.length || 0 }}</template></el-table-column>
            <el-table-column label="操作" width="90" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:report:remove']" link type="danger" @click="removeRecord('report', row)">删除</el-button></template></el-table-column>
          </el-table>
      <el-tab-pane v-if="isStarted && (!singleTab || singleTab === 'reports')" label="项目汇报" name="reports">
        <div class="report-workbench">
          <aside class="report-module-nav">
            <div class="report-nav-title">汇报范围</div>
            <button type="button" class="report-nav-item" :class="{ active: reportModuleFilter === 'all' }" @click="selectReportModule('all')">
              <span class="report-nav-name">全部(整个项目)</span>
              <span class="report-nav-meta">共 {{ data.reports.length }} æ¡æ±‡æŠ¥</span>
            </button>
            <button v-for="module in data.modules" :key="module.id" type="button" class="report-nav-item" :class="{ active: reportModuleFilter === String(module.id) }" @click="selectReportModule(String(module.id))">
              <span class="report-nav-name">{{ module.moduleName }}</span>
              <span class="report-nav-meta">进度 {{ Number(module.currentProgress || 0) }}% Â· {{ moduleReportCount(module.id) }} æ¡æ±‡æŠ¥</span>
            </button>
          </aside>
          <section class="report-content">
            <div class="tab-actions">
              <span class="report-stat-summary">{{ reportStatisticsSummary }}</span>
              <el-button v-if="!readonly" v-hasPermi="['project:report:add']" type="primary" :disabled="!eligibleReportModules.length" @click="openReport()">新增汇报</el-button>
            </div>
            <div class="report-layout" :class="{ 'report-only': !readonly }">
              <div v-if="readonly" ref="chartRef" class="chart" />
              <el-table :data="filteredReports" border class="report-table">
                <el-table-column prop="moduleName" label="项目模块" min-width="130" />
                <el-table-column label="汇报人员比重" min-width="210"><template #default="{ row }"><el-tag v-for="member in row.reporterShareList || []" :key="member.userId" class="member-tag">{{ member.userName }} {{ member.moduleShare }}%</el-tag></template></el-table-column>
                <el-table-column prop="incrementProgress" label="本次进度" width="100"><template #default="{ row }">{{ row.incrementProgress ?? row.progress }}%</template></el-table-column>
                <el-table-column prop="reportDate" label="时间" width="120" />
                <el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
                <el-table-column label="文件" min-width="190">
                  <template #default="{ row }">
                    <div v-if="row.attachmentList?.length" class="file-cell"><el-button v-for="(file,index) in row.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
                    <span v-else class="empty-file">无附件</span>
                  </template>
                </el-table-column>
                <el-table-column v-if="!readonly" label="操作" width="140" align="center"><template #default="{ row }"><el-button v-if="canEditReport(row)" v-hasPermi="['project:report:add']" link type="primary" @click="openReport(row)">编辑</el-button><el-button v-if="canEditReport(row)" v-hasPermi="['project:report:remove']" link type="danger" @click="removeRecord('report', row)">删除</el-button></template></el-table-column>
              </el-table>
            </div>
          </section>
        </div>
      </el-tab-pane>
      <el-tab-pane label="项目资料" name="materials">
      <el-tab-pane v-if="!singleTab || singleTab === 'materials'" label="项目资料" name="materials">
        <div class="tab-actions">
          <span class="hint">项目整体资料库,与业务附件和单次汇报附件分开保存</span>
          <el-button v-hasPermi="['project:material:add']" type="primary" @click="openMaterial()">上传资料</el-button>
          <el-button v-if="!readonly" v-hasPermi="['project:material:add']" type="primary" @click="openMaterial()">上传资料</el-button>
        </div>
        <el-table :data="data.materials" border>
          <el-table-column prop="materialName" label="资料名称" min-width="220" />
@@ -105,34 +124,62 @@
              </el-button>
            </template>
          </el-table-column>
          <el-table-column label="操作" width="90" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:material:remove']" link type="danger" @click="removeRecord('material', row)">删除</el-button></template></el-table-column>
          <el-table-column v-if="!readonly" label="操作" width="90" align="center"><template #default="{ row }"><el-button v-hasPermi="['project:material:remove']" link type="danger" @click="removeRecord('material', row)">删除</el-button></template></el-table-column>
        </el-table>
      </el-tab-pane>
      <template v-if="!singleTab">
        <el-tab-pane v-for="stage in visibleProcessStages" :key="stage.code" :label="stage.label" :name="stage.code">
          <el-empty v-if="!stageRecords(stage.code).length" description="暂无阶段办理记录" />
          <el-timeline v-else class="phase-record-timeline">
            <el-timeline-item v-for="record in stageRecords(stage.code)" :key="`${stage.code}-${record.id || record.actionTime}`" :timestamp="formatActionTime(record.actionTime)" placement="top" :type="phaseRecordType(record.actionType)">
              <el-card shadow="never" class="phase-record-card">
                <template #header>
                  <div class="phase-record-head"><div><strong>{{ record.actionLabel || stage.phaseName }}</strong><el-tag size="small" :type="phaseRecordType(record.actionType)">{{ recordStatusLabel(record) }}</el-tag></div><span>操作人:{{ record.operatorName || '-' }}</span></div>
                </template>
                <el-descriptions v-if="phaseFieldRows(record).length" :column="2" border>
                  <el-descriptions-item v-for="field in phaseFieldRows(record)" :key="field.label" :label="field.label">{{ field.value }}</el-descriptions-item>
                </el-descriptions>
                <div v-if="record.remark" class="phase-record-remark"><b>办理说明:</b>{{ record.remark }}</div>
                <div v-if="record.attachmentList?.length" class="phase-record-files"><b>相关附件:</b><el-button v-for="(file,index) in record.attachmentList" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button></div>
              </el-card>
            </el-timeline-item>
          </el-timeline>
        </el-tab-pane>
      </template>
    </el-tabs>
    <el-dialog v-model="moduleVisible" :title="moduleForm.id ? '修改模块' : '新增模块'" width="760px" destroy-on-close>
      <el-form ref="moduleFormRef" :model="moduleForm" :rules="moduleRules" label-width="130px">
        <el-alert v-if="moduleForm.locked" title="该模块已有进度汇报,基础信息和模块占比已锁定,仅可调整执行人。" type="warning" :closable="false" class="dialog-tip" />
        <el-form-item label="模块名称" prop="moduleName"><el-input v-model="moduleForm.moduleName" :disabled="moduleForm.locked" /></el-form-item>
        <el-form-item label="预计开始时间" prop="plannedStartDate"><el-date-picker v-model="moduleForm.plannedStartDate" :disabled="moduleForm.locked" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
        <el-form-item label="模块概要" prop="summary"><el-input v-model="moduleForm.summary" :disabled="moduleForm.locked" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
        <el-form-item label="项目工作量占比" prop="projectWeight"><el-input-number v-model="moduleForm.projectWeight" :disabled="moduleForm.locked" :min="0" :max="moduleWeightMax" :precision="2" /><span class="unit">%</span><div class="percent-hint">其他模块合计 {{ otherModuleWeight }}%,当前模块可调范围 0~{{ moduleWeightMax }}%,保存后总计 {{ moduleWeightAfterChange }}%</div></el-form-item>
        <el-form-item label="模块执行人" required>
          <div class="member-editor">
            <div v-for="(member, index) in moduleForm.members" :key="index" class="member-row">
              <el-select v-model="member.userId" filterable placeholder="请选择执行人" @change="id => syncMemberName(member, id)">
                <el-option v-for="user in projectExecutorOptions" :key="user.value" :label="user.label" :value="user.value" />
              </el-select>
              <el-button link type="danger" @click="moduleForm.members.splice(index, 1)">删除</el-button>
    <el-dialog v-model="moduleVisible" :title="isStarted ? '管理模块执行人' : '编辑项目模块'" width="1100px" top="4vh" destroy-on-close>
      <el-alert :title="isStarted ? '项目开始后模块信息不可修改,仅可新增执行人;已有本模块工作占比的执行人不能删除。' : '所有模块一次性保存,工作量占比合计必须为100%后项目才能开始。'" type="info" :closable="false" class="dialog-tip" />
      <div class="module-total" :class="{ invalid: Number(moduleDraftTotal) !== 100 }">模块工作量合计:{{ moduleDraftTotal }}%,剩余可分配:{{ moduleDraftRemaining }}%</div>
      <div v-for="(module, moduleIndex) in moduleDrafts" :key="module.localKey" class="module-batch-card">
        <div class="module-batch-title">
          <strong>模块 {{ moduleIndex + 1 }}</strong>
          <span v-if="module.locked" class="locked-tip">项目已开始,模块信息不可修改</span>
          <el-button v-if="!isStarted && !module.locked" link type="danger" @click="removeModuleDraft(moduleIndex)">移除模块</el-button>
        </div>
        <div class="module-grid">
          <el-form-item label="模块名称" required><el-input v-model="module.moduleName" :disabled="module.locked" /></el-form-item>
          <el-form-item label="预计开始" required><el-date-picker v-model="module.plannedStartDate" :disabled="module.locked" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="工作量占比" required><el-input-number v-model="module.projectWeight" :disabled="module.locked" :min="0.01" :max="draftWeightMax(moduleIndex)" :precision="2" /><span class="unit">%</span><div class="percent-hint">其他模块 {{ otherDraftWeight(moduleIndex) }}%,本模块最多 {{ draftWeightMax(moduleIndex) }}%</div></el-form-item>
          <el-form-item label="模块概要" required class="summary-item"><el-input v-model="module.summary" :disabled="module.locked" type="textarea" :rows="2" maxlength="1000" show-word-limit /></el-form-item>
          <el-form-item label="模块执行人" required class="members-item">
            <div class="member-editor">
              <div v-for="(member, memberIndex) in module.members" :key="memberIndex" class="member-row">
                <el-select v-model="member.userId" :disabled="member.hasReportedShare" filterable placeholder="请选择执行人" @change="id => syncMemberName(member, id)"><el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" /></el-select>
                <el-tag v-if="member.hasReportedShare" type="warning">已完成模块占比:{{ member.completedModuleShare }}%</el-tag>
                <el-button v-else link type="danger" @click="module.members.splice(memberIndex, 1)">删除</el-button>
              </div>
              <el-button link type="primary" @click="module.members.push({ userId:null, userName:'', hasReportedShare:false, completedModuleShare:0 })">+ æ·»åŠ æ‰§è¡Œäºº</el-button>
            </div>
            <el-button link type="primary" @click="moduleForm.members.push({ userId: null, userName: '' })">+ æ·»åŠ æ‰§è¡Œäºº</el-button>
          </div>
        </el-form-item>
      </el-form>
      <template #footer><el-button @click="moduleVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitModule">保存</el-button></template>
          </el-form-item>
        </div>
      </div>
      <el-button v-if="!isStarted" type="primary" plain @click="addModuleDraft">+ æ·»åŠ æ¨¡å—</el-button>
      <template #footer><el-button @click="moduleVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitModuleBatch">保存</el-button></template>
    </el-dialog>
    <el-dialog v-model="followVisible" title="填写项目跟进" width="650px" destroy-on-close>
    <el-dialog v-model="followVisible" :title="followForm.id ? '编辑项目跟进' : '填写项目跟进'" width="650px" destroy-on-close>
      <el-form ref="followFormRef" :model="followForm" :rules="followRules" label-width="90px">
        <el-form-item label="跟进日期" prop="followDate"><el-date-picker v-model="followForm.followDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
        <el-form-item label="跟进详情" prop="detail"><el-input v-model="followForm.detail" type="textarea" :rows="5" maxlength="2000" show-word-limit /></el-form-item>
@@ -144,7 +191,7 @@
    <el-dialog v-model="reportVisible" title="进度汇报" width="680px" destroy-on-close>
      <el-form ref="reportFormRef" :model="reportForm" :rules="reportRules" label-width="100px">
        <el-form-item label="所属项目"><el-input :model-value="projectInfo?.title" disabled /></el-form-item>
        <el-form-item label="项目模块" prop="moduleId"><el-select v-model="reportForm.moduleId" style="width: 100%" @change="changeReportModule"><el-option v-for="item in eligibleReportModules" :key="item.id" :label="item.moduleName" :value="item.id" /></el-select><div class="percent-hint">仅展示当前登录人参与执行的模块。</div></el-form-item>
        <el-form-item label="项目模块" prop="moduleId"><el-select v-model="reportForm.moduleId" :disabled="!!reportForm.id" style="width: 100%" @change="changeReportModule"><el-option v-for="item in reportModuleOptions" :key="item.id" :label="item.moduleName" :value="item.id" /></el-select><div class="percent-hint">仅展示当前登录人参与执行且尚未完成的模块;已有汇报不能更换模块。</div></el-form-item>
        <el-form-item label="汇报人员比重" required>
          <div class="member-editor">
            <div v-for="(member,index) in reportForm.reporterShares" :key="index" class="reporter-row">
@@ -157,12 +204,12 @@
            <div class="share-total">本次人员比重合计:{{ reportShareTotal }}%,剩余可分配:{{ reportShareRemaining }}%。每人的上限会随其他人员占比自动调整。</div>
          </div>
        </el-form-item>
        <el-form-item label="模块进度" prop="progress"><el-input-number v-model="reportForm.progress" :min="0" :max="100" :precision="2" /><span class="unit">%</span></el-form-item>
        <el-form-item label="汇报时间" prop="reportDate"><el-date-picker v-model="reportForm.reportDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
        <el-form-item label="本次进度" prop="incrementProgress"><el-input-number v-model="reportForm.incrementProgress" :min="0.01" :max="reportIncrementMax" :precision="2" /><span class="unit">%</span><span class="current-progress-hint">{{ reportProgressHint }}</span></el-form-item>
        <el-form-item label="汇报时间" prop="reportDate"><el-date-picker v-model="reportForm.reportDate" :disabled="!!reportForm.id" type="date" value-format="YYYY-MM-DD" style="width: 100%" /><div v-if="reportForm.id" class="percent-hint">编辑已有汇报时不允许调整时间,以保证历史进度顺序正确。</div></el-form-item>
        <el-form-item label="备注"><el-input v-model="reportForm.remark" type="textarea" :rows="3" maxlength="2000" show-word-limit /></el-form-item>
        <el-form-item label="文件"><AttachmentUploadFile v-model:file-list="reportFiles" :limit="10" /></el-form-item>
      </el-form>
      <template #footer><el-button @click="reportVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitReport">提交汇报</el-button></template>
      <template #footer><el-button @click="reportVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitReport">{{ reportForm.id ? '保存修改' : '提交汇报' }}</el-button></template>
    </el-dialog>
    <el-dialog v-model="materialVisible" title="上传项目资料" width="650px" destroy-on-close>
@@ -181,38 +228,87 @@
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import { userListAll } from '@/api/publicApi'
import {
  deleteProjectFollowUp, deleteProjectMaterial, deleteProjectModule, deleteProjectProgressReport,
  getProjectWorkflow, saveProjectFollowUp, saveProjectMaterial, saveProjectModule, saveProjectProgressReport
  deleteProjectFollowUp, deleteProjectMaterial, deleteProjectProgressReport,
  getProjectWorkflow, saveProjectFollowUp, saveProjectMaterial, saveProjectModulesBatch, saveProjectProgressReport
} from '@/api/projectManagement/workflow'
import useUserStore from '@/store/modules/user'
const props = defineProps({ projectId: [String, Number], projectInfo: { type: Object, default: () => ({}) } })
const props = defineProps({
  projectId: [String, Number],
  projectInfo: { type: Object, default: () => ({}) },
  readonly: { type: Boolean, default: false },
  singleTab: { type: String, default: '' }
})
const emit = defineEmits(['project-updated'])
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const loading = ref(false)
const saving = ref(false)
const activeTab = ref('modules')
const activeTab = ref(props.singleTab || 'modules')
const reportModuleFilter = ref('all')
const userOptions = ref([])
const data = reactive({ modules: [], followUps: [], reports: [], materials: [], sourceBusiness: {}, memberStatistics: [], followWindow: {} })
const data = reactive({ modules: [], followUps: [], reports: [], materials: [], phases: [], phaseHistories: [], sourceBusiness: {}, memberStatistics: [], followWindow: {} })
const chartRef = ref()
let chart
const today = () => new Date().toISOString().slice(0, 10)
const today = () => dayjs().format('YYYY-MM-DD')
const totalModuleWeight = computed(() => data.modules.reduce((sum, row) => sum + Number(row.projectWeight || 0), 0).toFixed(2))
const reportShareTotal = computed(() => reportForm.reporterShares.reduce((sum, row) => sum + Number(row.moduleShare || 0), 0).toFixed(2))
const reportShareRemaining = computed(() => Math.max(0, 100 - Number(reportShareTotal.value)).toFixed(2))
const followCycleLabel = computed(() => ({ MONTH: '每月', QUARTER: '每季度', YEAR: '每年' }[props.projectInfo?.followCycle] || props.projectInfo?.followCycle || '每月'))
const projectExecutorOptions = computed(() => {
  const ids = new Set((props.projectInfo?.projectExecutorIds || []).map(String))
  return userOptions.value.filter(user => ids.has(String(user.value)))
})
const eligibleReportModules = computed(() => data.modules.filter(module =>
const isStarted = computed(() => !!props.projectInfo?.currentPhase && props.projectInfo.currentPhase !== '未开始')
const memberReportModules = computed(() => data.modules.filter(module =>
  (module.members || []).some(member => String(member.userId) === String(userStore.id))
))
const eligibleReportModules = computed(() => memberReportModules.value.filter(module => Number(module.currentProgress || 0) < 100))
const reportModuleOptions = computed(() => reportForm.id ? memberReportModules.value : eligibleReportModules.value)
const selectedReportModule = computed(() => data.modules.find(module => String(module.id) === String(reportForm.moduleId)))
const reportBaseProgress = computed(() => Number(reportForm.id ? reportForm.previousProgress : selectedReportModule.value?.currentProgress || 0))
const reportIncrementMax = computed(() => {
  if (!reportForm.id) return Math.max(0.01, Number((100 - reportBaseProgress.value).toFixed(2)))
  const current = Number(selectedReportModule.value?.currentProgress || 0)
  return Math.max(0.01, Number((Number(reportForm.originalIncrementProgress || 0) + 100 - current).toFixed(2)))
})
const reportProgressHint = computed(() => {
  const current = Number(selectedReportModule.value?.currentProgress || 0).toFixed(2)
  if (reportForm.id) return `该次汇报前为 ${reportBaseProgress.value.toFixed(2)}%,模块当前为 ${current}%`
  return `模块当前进度 ${current}%,本次最多可汇报 ${reportIncrementMax.value}%`
})
const filteredReports = computed(() => reportModuleFilter.value === 'all'
  ? data.reports
  : data.reports.filter(report => String(report.moduleId) === reportModuleFilter.value))
const filteredMemberStatistics = computed(() => {
  if (reportModuleFilter.value === 'all') return data.memberStatistics || []
  const totals = new Map()
  for (const report of filteredReports.value) {
    const incrementProgress = Number(report.incrementProgress ?? report.progress ?? 0)
    for (const member of report.reporterShareList || []) {
      const userId = String(member.userId)
      const current = totals.get(userId) || { userId: member.userId, userName: member.userName || '未命名', value: 0 }
      current.value += incrementProgress * Number(member.moduleShare || 0) / 100
      totals.set(userId, current)
    }
  }
  return [...totals.values()]
    .map(item => ({ ...item, value: Number(item.value.toFixed(2)) }))
    .sort((a, b) => b.value - a.value)
})
const reportStatisticsSummary = computed(() => {
  const prefix = reportModuleFilter.value === 'all' ? '项目占比统计' : '模块占比统计'
  if (!filteredMemberStatistics.value.length) return `${prefix}:暂无数据`
  return `${prefix}:${filteredMemberStatistics.value.map(item => `${item.userName} ${item.value}%`).join(';')}`
})
const processStages = [
  { code: 'APPLICATION', label: '申报记录', phaseName: '申报', rank: 3 },
  { code: 'RESULT', label: '申报结果', phaseName: '申报结果', rank: 4 },
  { code: 'ACCEPTANCE', label: '验收记录', phaseName: '验收', rank: 5 }
]
const currentPhaseRank = computed(() => ({ æœªå¼€å§‹: 0, ç«‹é¡¹: 1, å¼€å‘: 2, ç”³æŠ¥: 3, ç”³æŠ¥ç»“æžœ: 4, éªŒæ”¶: 5, å·²å®Œæˆ: 6 })[props.projectInfo?.currentPhase] || 0)
const visibleProcessStages = computed(() => processStages.filter(stage => currentPhaseRank.value >= stage.rank))
const moduleReporterOptions = computed(() => {
  const members = selectedReportModule.value?.members || []
  return members.map(member => ({
@@ -222,21 +318,18 @@
})
const moduleVisible = ref(false)
const moduleFormRef = ref()
const moduleForm = reactive({ id: null, moduleName: '', plannedStartDate: '', summary: '', projectWeight: 0, members: [], locked: false })
const otherModuleWeight = computed(() => Number(data.modules.reduce((sum, row) =>
  String(row.id) === String(moduleForm.id) ? sum : sum + Number(row.projectWeight || 0), 0).toFixed(2)))
const moduleWeightMax = computed(() => Math.max(0, Number((100 - otherModuleWeight.value).toFixed(2))))
const moduleWeightAfterChange = computed(() => Number((otherModuleWeight.value + Number(moduleForm.projectWeight || 0)).toFixed(2)))
const moduleRules = { moduleName: [{ required: true, message: '请输入模块名称', trigger: 'blur' }], plannedStartDate:[{required:true,message:'请选择预计开始时间',trigger:'change'}],summary:[{required:true,message:'请输入模块概要',trigger:'blur'}], projectWeight: [{ required: true, message: '请输入工作量占比', trigger: 'change' }] }
const moduleDrafts = ref([])
let draftSequence = 0
const moduleDraftTotal = computed(() => moduleDrafts.value.reduce((sum, module) => sum + Number(module.projectWeight || 0), 0).toFixed(2))
const moduleDraftRemaining = computed(() => (100 - Number(moduleDraftTotal.value)).toFixed(2))
const followVisible = ref(false)
const followFormRef = ref()
const followForm = reactive({ followDate: today(), detail: '' })
const followForm = reactive({ id: null, followDate: today(), detail: '' })
const followFiles = ref([])
const followRules = { followDate: [{ required: true, message: '请选择跟进日期', trigger: 'change' }], detail: [{ required: true, message: '请输入跟进详情', trigger: 'blur' }] }
const reportVisible = ref(false)
const reportFormRef = ref()
const reportForm = reactive({ moduleId: null, reporterShares: [], progress: 0, reportDate: today(), remark: '' })
const reportForm = reactive({ id: null, moduleId: null, reporterShares: [], incrementProgress: 0, originalIncrementProgress: 0, previousProgress: 0, reportDate: today(), remark: '' })
const reportFiles = ref([])
const reportRules = { moduleId: [{ required: true, message: '请选择项目模块', trigger: 'change' }], reportDate: [{ required: true, message: '请选择汇报时间', trigger: 'change' }] }
const materialVisible = ref(false)
@@ -261,36 +354,69 @@
  } finally { loading.value = false }
}
function openModule(row = null) {
  Object.assign(moduleForm, {
    id: row?.id || null, moduleName: row?.moduleName || '', plannedStartDate: row?.plannedStartDate || '',
    summary: row?.summary || '', projectWeight: Number(row?.projectWeight || 0),
    members: (row?.members || []).map(member => ({ userId: member.userId, userName: member.userName })),
    locked: Number(row?.reportCount || 0) > 0
  })
  if (!moduleForm.members.length) moduleForm.members.push({ userId: null, userName: '' })
function newModuleDraft(row = {}) {
  return {
    id: row.id || null, localKey: row.id ? `module-${row.id}` : `new-${++draftSequence}`,
    moduleName: row.moduleName || '', plannedStartDate: row.plannedStartDate || '', summary: row.summary || '',
    projectWeight: Number(row.projectWeight || 0),
    members: (row.members || []).map(member => {
      const reportStats = memberReportedContribution(row, member.userId)
      return { userId: member.userId, userName: member.userName, ...reportStats }
    }),
    locked: isStarted.value
  }
}
function memberReportedContribution(module, userId) {
  let hasReportedShare = false
  const completedModuleShare = data.reports
    .filter(report => String(report.moduleId) === String(module.id))
    .reduce((total, report) => {
      const memberShare = (report.reporterShareList || [])
        .filter(member => String(member.userId) === String(userId))
        .reduce((sum, member) => sum + Number(member.moduleShare || 0), 0)
      if (memberShare > 0) hasReportedShare = true
      const incrementProgress = Number(report.incrementProgress ?? report.progress ?? 0)
      return total + incrementProgress * memberShare / 100
    }, 0)
  return { hasReportedShare, completedModuleShare: Number(completedModuleShare.toFixed(2)) }
}
function openModuleBatch() {
  moduleDrafts.value = data.modules.map(newModuleDraft)
  if (!moduleDrafts.value.length) addModuleDraft()
  moduleVisible.value = true
}
function addModuleDraft() { moduleDrafts.value.push(newModuleDraft({ members: [{ userId: null, userName: '', hasReportedShare: false, completedModuleShare: 0 }] })) }
function removeModuleDraft(index) { moduleDrafts.value.splice(index, 1) }
function otherDraftWeight(index) { return moduleDrafts.value.reduce((sum, module, i) => i === index ? sum : sum + Number(module.projectWeight || 0), 0).toFixed(2) }
function draftWeightMax(index) { return Math.max(0.01, Number((100 - Number(otherDraftWeight(index))).toFixed(2))) }
function syncMemberName(member, id) { member.userName = userOptions.value.find(user => String(user.value) === String(id))?.label || '' }
async function submitModule() {
  await moduleFormRef.value?.validate()
  if (!moduleForm.members.length || moduleForm.members.some(member => !member.userId)) return ElMessage.warning('请至少选择一名模块执行人')
  if (new Set(moduleForm.members.map(member => String(member.userId))).size !== moduleForm.members.length) return ElMessage.warning('模块执行人不能重复')
async function submitModuleBatch() {
  if (!moduleDrafts.value.length) return ElMessage.warning('请至少添加一个项目模块')
  if (moduleDrafts.value.some(module => !module.moduleName?.trim() || !module.plannedStartDate || !module.summary?.trim())) return ElMessage.warning('请完整填写所有模块的名称、预计开始时间和模块概要')
  if (Number(moduleDraftTotal.value) !== 100) return ElMessage.warning('所有模块的项目工作量占比合计必须为100%')
  for (const module of moduleDrafts.value) {
    if (!module.members.length || module.members.some(member => !member.userId)) return ElMessage.warning(`请为模块“${module.moduleName}”至少选择一名执行人`)
    if (new Set(module.members.map(member => String(member.userId))).size !== module.members.length) return ElMessage.warning(`模块“${module.moduleName}”的执行人不能重复`)
  }
  saving.value = true
  try {
    await saveProjectModule({ ...moduleForm, projectId: props.projectId })
    ElMessage.success('模块保存成功'); moduleVisible.value = false; await loadData()
    await saveProjectModulesBatch({ projectId: props.projectId, modules: moduleDrafts.value })
    ElMessage.success('全部模块保存成功'); moduleVisible.value = false; await loadData()
  } finally { saving.value = false }
}
async function removeModule(row) {
  await ElMessageBox.confirm(`确认删除模块“${row.moduleName}”吗?`, '提示', { type: 'warning' })
  await deleteProjectModule(row.id); ElMessage.success('删除成功'); await loadData()
function openFollow(row) {
  Object.assign(followForm, row
    ? { id: row.id, followDate: row.followDate, detail: row.detail || '' }
    : { id: null, followDate: today(), detail: '' })
  followFiles.value = row ? [...(row.attachmentList || [])] : []
  followVisible.value = true
}
function openFollow() { Object.assign(followForm, { followDate: data.followWindow?.dueDate || today(), detail: '' }); followFiles.value = []; followVisible.value = true }
function normalizeFiles(list) { return list.map(file => ({ id: file.id ?? file.fileId ?? file.rawData?.id, name: file.name ?? file.fileName ?? file.rawData?.originalFilename, url: file.url ?? file.fileUrl ?? file.rawData?.url })).filter(file => file.id || file.url) }
async function submitFollow() {
@@ -299,12 +425,36 @@
  finally { saving.value = false }
}
function openReport() {
  if (!eligibleReportModules.value.length) return ElMessage.warning('只有模块执行人才能填写该模块的进度汇报')
function openReport(row) {
  if (row) {
    if (!memberReportModules.value.some(module => String(module.id) === String(row.moduleId))) return ElMessage.warning('只有该模块执行人才能编辑进度汇报')
    const incrementProgress = Number(row.incrementProgress ?? row.progress ?? 0)
    Object.assign(reportForm, {
      id: row.id, moduleId: row.moduleId,
      reporterShares: (row.reporterShareList || []).map(member => ({ ...member })),
      incrementProgress, originalIncrementProgress: incrementProgress,
      previousProgress: Number(row.previousProgress || 0), reportDate: row.reportDate, remark: row.remark || ''
    })
    reportFiles.value = [...(row.attachmentList || [])]
    reportVisible.value = true
    return
  }
  if (!eligibleReportModules.value.length) return ElMessage.warning('当前没有可汇报的未完成模块,或您不是模块执行人')
  const userId = userStore.id
  const userName = userOptions.value.find(user => String(user.value) === String(userId))?.label || userStore.name || ''
  const module = eligibleReportModules.value[0]
  Object.assign(reportForm, { moduleId: module.id, reporterShares: userId ? [{ userId, userName, moduleShare: 100 }] : [], progress: Number(module.currentProgress || 0), reportDate: today(), remark: '' })
  const module = reportModuleFilter.value === 'all'
    ? null
    : eligibleReportModules.value.find(item => String(item.id) === reportModuleFilter.value)
  Object.assign(reportForm, {
    id: null,
    moduleId: module?.id ?? null,
    reporterShares: module && userId ? [{ userId, userName, moduleShare: 100 }] : [],
    incrementProgress: 0,
    originalIncrementProgress: 0,
    previousProgress: Number(module?.currentProgress || 0),
    reportDate: today(),
    remark: ''
  })
  reportFiles.value = []; reportVisible.value = true
}
@@ -312,8 +462,13 @@
  const module = data.modules.find(item => String(item.id) === String(moduleId))
  const userId = userStore.id
  const userName = module?.members?.find(member => String(member.userId) === String(userId))?.userName || userStore.name || ''
  reportForm.progress = Number(module?.currentProgress || 0)
  reportForm.incrementProgress = 0
  reportForm.previousProgress = Number(module?.currentProgress || 0)
  reportForm.reporterShares = userId ? [{ userId, userName, moduleShare: 100 }] : []
}
function canEditReport(row) {
  return memberReportModules.value.some(module => String(module.id) === String(row.moduleId))
}
function reporterShareLimit(index) {
@@ -327,8 +482,15 @@
  if (!reportForm.reporterShares.length || reportForm.reporterShares.some(member => !member.userId)) return ElMessage.warning('请至少选择一名汇报人员')
  if (new Set(reportForm.reporterShares.map(member => String(member.userId))).size !== reportForm.reporterShares.length) return ElMessage.warning('汇报人员不能重复')
  if (Number(reportShareTotal.value) !== 100) return ElMessage.warning('本次汇报人员比重合计必须为100%')
  if (Number(reportForm.incrementProgress || 0) <= 0) return ElMessage.warning('本次进度必须大于0')
  saving.value = true
  try { await saveProjectProgressReport({ ...reportForm, projectId: props.projectId, attachments: normalizeFiles(reportFiles.value) }); ElMessage.success('汇报已提交'); reportVisible.value = false; await loadData() }
  try {
    await saveProjectProgressReport({ ...reportForm, projectId: props.projectId, attachments: normalizeFiles(reportFiles.value) })
    ElMessage.success(reportForm.id ? '汇报修改已保存' : '汇报已提交')
    reportVisible.value = false
    await loadData()
    emit('project-updated')
  }
  finally { saving.value = false }
}
@@ -345,6 +507,7 @@
  await ElMessageBox.confirm('确认删除这条记录吗?', '提示', { type: 'warning' })
  const api = { follow: deleteProjectFollowUp, report: deleteProjectProgressReport, material: deleteProjectMaterial }[type]
  await api(row.id); ElMessage.success('删除成功'); await loadData()
  if (type === 'report') emit('project-updated')
}
function download(file) { if (!file?.url) return ElMessage.warning('附件暂无下载地址'); proxy.$download.byUrl(file.url, file.name || file.fileName) }
@@ -352,9 +515,10 @@
function renderChart() {
  if (!chartRef.value) return
  chart ||= echarts.init(chartRef.value)
  const source = data.memberStatistics || []
  const source = filteredMemberStatistics.value
  const isAll = reportModuleFilter.value === 'all'
  chart.setOption({
    title: { text: '成员已完成工作量比重', left: 'center', textStyle: { fontSize: 15 } },
    title: { text: isAll ? '成员项目占比' : '成员模块占比', left: 'center', textStyle: { fontSize: 15 } },
    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
    legend: { bottom: 0 },
    series: [{ type: 'pie', radius: ['38%', '68%'], center: ['50%', '48%'], data: source.map(item => ({ name: item.userName || '未命名', value: Number(item.value || 0) })), label: { formatter: '{b}\n{c}' } }]
@@ -362,6 +526,28 @@
}
function handleTabChange(name) { if (name === 'reports') nextTick(renderChart) }
function selectReportModule(moduleId) { reportModuleFilter.value = moduleId; nextTick(renderChart) }
function moduleReportCount(moduleId) { return data.reports.filter(report => String(report.moduleId) === String(moduleId)).length }
function stageRecords(phaseCode) {
  const histories = (data.phaseHistories || []).filter(record => record.phaseCode === phaseCode)
  if (histories.length) return histories
  const snapshot = (data.phases || []).find(record => record.phaseCode === phaseCode)
  if (!snapshot || snapshot.status === 'NOT_STARTED') return []
  return [{ ...snapshot, actionLabel: snapshot.phaseName, actionType: snapshot.status === 'COMPLETED' ? 'PASS' : 'ENTER' }]
}
function formatActionTime(time) { return time ? String(time).replace('T', ' ') : '时间未记录' }
function phaseRecordType(type) { return ['RECTIFY', 'SUPPLEMENT'].includes(type) ? 'warning' : type === 'REJECTED' ? 'danger' : type === 'PASS' ? 'success' : 'primary' }
function recordStatusLabel(record) { return record.actionType === 'RECTIFY' ? '需要整改' : record.actionType === 'SUPPLEMENT' ? '需要补充' : record.actionType === 'REJECTED' ? '未通过' : record.actionType === 'PASS' ? '已通过' : '已办理' }
function phaseFieldRows(record) {
  const form = record.formData || {}
  const labels = {
    applicationDate: '申报日期', applicationType: '申报类型', applicationName: '申报名称', expectedFeedbackDate: '预计反馈日期', description: '申报说明',
    resultDate: '结果日期', resultStatus: '申报结果', resultDescription: '结果说明', acceptancePlanDate: '计划验收日期',
    acceptor: '验收人/客户', acceptanceStandard: '验收标准', acceptanceDate: '验收日期', acceptanceResult: '验收结果', acceptanceDescription: '验收说明'
  }
  const enumLabels = { PASS: '通过', SUPPLEMENT: '需要补充', REJECTED: '未通过', RECTIFY: '需要整改' }
  return Object.entries(labels).filter(([key]) => form[key] !== null && form[key] !== undefined && form[key] !== '').map(([key, label]) => ({ label, value: enumLabels[form[key]] || form[key] }))
}
function resizeChart() { chart?.resize() }
onMounted(async () => { await Promise.all([loadUsers(), loadData()]); window.addEventListener('resize', resizeChart) })
onBeforeUnmount(() => { window.removeEventListener('resize', resizeChart); chart?.dispose() })
@@ -372,16 +558,42 @@
.execution-panel { min-height: 260px; }
.tab-actions { display: flex; align-items: center; justify-content: space-between; margin: 4px 0 14px; }
.hint { color: #606266; font-size: 13px; }
.report-stat-summary { color: #606266; font-size: 13px; line-height: 1.7; }
.report-workbench { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 16px; align-items: start; }
.report-module-nav { padding: 12px; border: 1px solid #e4e7ed; border-radius: 10px; background: #f8fafc; }
.report-nav-title { padding: 4px 10px 10px; color: #303133; font-size: 14px; font-weight: 600; }
.report-nav-item { width: 100%; margin: 0 0 8px; padding: 11px 12px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: #303133; text-align: left; cursor: pointer; transition: all .2s; }
.report-nav-item:last-child { margin-bottom: 0; }
.report-nav-item:hover { border-color: #b8cdf8; background: #eef4ff; }
.report-nav-item.active { border-color: #8fb2f5; background: #e8f1ff; color: #0b46b5; box-shadow: 0 2px 7px rgba(11, 70, 181, .08); }
.report-nav-name, .report-nav-meta { display: block; }
.report-nav-name { overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.report-nav-meta { margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.35; }
.report-nav-item.active .report-nav-meta { color: #5e7fbf; }
.report-content { min-width: 0; }
.member-tag { margin: 2px 6px 2px 0; }
.report-layout { display: grid; grid-template-columns: minmax(300px, 34%) 1fr; gap: 14px; }
.report-layout.report-only { grid-template-columns: 1fr; }
.chart { height: 390px; border: 1px solid #ebeef5; border-radius: 6px; }
.report-table { min-width: 0; }
.member-editor { width: 100%; }
.member-row { display: grid; grid-template-columns: 1fr 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
.member-row { display: grid; grid-template-columns: minmax(260px, 1fr) auto 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
.reporter-row { display: grid; grid-template-columns: 1fr 150px 24px 52px; align-items: center; gap: 10px; margin-bottom: 10px; }
.share-total { margin-top: 8px; color: #606266; }
.percent-hint { width: 100%; margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.5; }
.dialog-tip { margin-bottom: 16px; }
.unit { margin-left: 8px; color: #606266; }
.current-progress-hint { margin-left: 14px; color: #909399; font-size: 12px; }
.phase-record-timeline { padding: 14px 12px 0 8px; }.phase-record-card { border-color: #dce6f5; }.phase-record-head { display:flex;align-items:center;justify-content:space-between;gap:16px; }.phase-record-head>div { display:flex;align-items:center;gap:10px; }.phase-record-head>span { color:#909399;font-size:12px; }.phase-record-remark,.phase-record-files { margin-top:14px;color:#606266;font-size:13px;line-height:1.7; }.phase-record-files { display:flex;align-items:center;flex-wrap:wrap;gap:4px; }
.file-cell { display:flex;flex-direction:column;align-items:flex-start;gap:2px; }.file-cell .el-button { max-width:100%;margin-left:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }.empty-file { color:#c0c4cc;font-size:12px; }
.module-total { margin: 0 0 14px; font-weight: 600; color: #067647; }
.module-total.invalid { color: #d97706; }
.module-batch-card { margin-bottom: 14px; padding: 16px 16px 4px; border: 1px solid #dcdfe6; border-radius: 8px; background: #fafcff; }
.module-batch-title { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.module-batch-title .el-button { margin-left: auto; }
.locked-tip { color: #e6a23c; font-size: 12px; }
.module-grid { display: grid; grid-template-columns: 1.2fr 1fr 1.25fr; column-gap: 18px; }
.summary-item, .members-item { grid-column: 1 / -1; }
@media (max-width: 1200px) { .report-layout { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .report-workbench { grid-template-columns: 1fr; } .report-module-nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; } .report-nav-title { grid-column: 1 / -1; } .report-nav-item { margin: 0; } }
</style>
src/views/projectManagement/Management/components/formDia.vue
@@ -1,51 +1,32 @@
<template>
  <el-dialog v-model="visible" :title="dialogTitle" :width="startProject ? '1100px' : '900px'" destroy-on-close>
  <el-dialog v-model="visible" :title="dialogTitle" width="900px" destroy-on-close>
    <el-alert v-if="isBusinessProject" title="该项目由业务审批生成,保存后可在项目详情查看来源业务及其附件。" type="info" :closable="false" class="tip" />
    <el-alert v-if="startProject" title="开始项目之前,请先通过列表中的“模块管理”把所有模块的工作量占比分配到100%。" type="info" :closable="false" class="tip" />
    <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
      <el-row v-if="!personnelOnly" :gutter="20">
      <el-row :gutter="20">
        <el-col :span="12"><el-form-item label="项目名称" prop="title"><el-input v-model="form.title" maxlength="200" /></el-form-item></el-col>
        <el-col :span="12"><el-form-item label="客户名称" prop="clientName"><el-select v-model="form.clientName" filterable clearable style="width:100%" @change="syncCustomer"><el-option v-for="item in customers" :key="item.id" :label="item.customerName" :value="item.customerName" /></el-select></el-form-item></el-col>
      </el-row>
      <el-row v-if="!personnelOnly" :gutter="20">
      <el-row :gutter="20">
        <el-col :span="12"><el-form-item label="项目分类" prop="projectClassification"><el-tree-select v-model="form.projectClassification" :data="categoryTree" :props="{ label:'categoryName',value:'categoryName',children:'children' }" check-strictly clearable :filterable="false" placeholder="请选择项目分类" style="width:100%" /></el-form-item></el-col>
        <el-col :span="12"><el-form-item label="项目来源"><el-input v-model="form.source" disabled /></el-form-item></el-col>
      </el-row>
      <el-row v-if="!personnelOnly" :gutter="20">
      <el-row :gutter="20">
        <el-col :span="12"><el-form-item label="预计开始时间" prop="planStartTime"><el-date-picker v-model="form.planStartTime" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item></el-col>
        <el-col :span="12"><el-form-item label="预计结束时间" prop="planEndTime"><el-date-picker v-model="form.planEndTime" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item></el-col>
      </el-row>
      <el-row v-if="!personnelOnly" :gutter="20">
      <el-row :gutter="20">
        <el-col :span="12"><el-form-item label="项目负责人" prop="projectLeaderId"><el-select v-model="form.projectLeaderId" filterable style="width:100%" @change="syncLeader"><el-option v-for="u in users" :key="u.value" :label="u.label" :value="u.value" /></el-select></el-form-item></el-col>
        <el-col :span="12"><el-form-item label="项目经理" prop="projectManagerIds"><el-select v-model="form.projectManagerIds" multiple filterable style="width:100%"><el-option v-for="u in users" :key="u.value" :label="u.label" :value="u.value" /></el-select></el-form-item></el-col>
      </el-row>
      <el-row :gutter="20">
        <el-col :span="24"><el-form-item label="项目执行人" prop="projectExecutorIds"><el-select v-model="form.projectExecutorIds" multiple filterable style="width:100%"><el-option v-for="u in users" :key="u.value" :label="u.label" :value="u.value" /></el-select><div class="field-hint">项目开始后仍可新增;已有项目汇报工作占比的执行人不能删除。</div></el-form-item></el-col>
      </el-row>
      <el-row v-if="!personnelOnly" :gutter="20">
        <el-col :span="8"><el-form-item label="跟进周期" prop="followCycle"><el-select v-model="form.followCycle" style="width:100%"><el-option label="每月" value="MONTH" /><el-option label="每季度" value="QUARTER" /><el-option label="每年" value="YEAR" /></el-select></el-form-item></el-col>
        <el-col :span="16"><el-form-item label="跟进要求" prop="followDetail"><el-input v-model="form.followDetail" maxlength="1000" /></el-form-item></el-col>
      </el-row>
      <el-form-item v-if="!personnelOnly" label="项目备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="2000" show-word-limit /></el-form-item>
      <el-form-item v-if="!personnelOnly" label="项目资料"><AttachmentUploadFile v-model:file-list="files" :limit="20" /></el-form-item>
      <template v-if="startProject && !personnelOnly">
        <el-divider content-position="left">项目模块组成(开始项目前必填)</el-divider>
        <el-alert title="模块工作量占比合计必须为100%;这里只选择执行人,人员比重在每次进度汇报时填写。" type="info" :closable="false" class="tip" />
        <div v-for="(module,index) in startModules" :key="module.localKey" class="module-card">
          <div class="module-card__title"><span>模块 {{ index + 1 }}</span><el-button link type="danger" @click="removeStartModule(index)">删除</el-button></div>
          <el-row :gutter="16">
            <el-col :span="8"><el-form-item label="模块名称" required><el-input v-model="module.moduleName" maxlength="200" /></el-form-item></el-col>
            <el-col :span="8"><el-form-item label="预计开始" required><el-date-picker v-model="module.plannedStartDate" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item></el-col>
            <el-col :span="8"><el-form-item label="项目工作量" required><el-input-number v-model="module.projectWeight" :min="0" :max="moduleWeightLimit(index)" :precision="2" /><span class="unit">%</span><div class="field-hint">其他模块 {{ otherStartModuleWeight(index) }}%,当前最多 {{ moduleWeightLimit(index) }}%</div></el-form-item></el-col>
          </el-row>
          <el-row :gutter="16">
            <el-col :span="12"><el-form-item label="模块执行人" required><el-select v-model="module.memberIds" multiple filterable style="width:100%"><el-option v-for="u in projectExecutorOptions" :key="u.value" :label="u.label" :value="u.value" /></el-select></el-form-item></el-col>
            <el-col :span="12"><el-form-item label="模块概要" required><el-input v-model="module.summary" type="textarea" :rows="2" maxlength="1000" /></el-form-item></el-col>
          </el-row>
        </div>
        <div class="module-actions"><span>当前模块工作量合计:{{ startModuleWeight }}%</span><el-button type="primary" plain @click="addStartModule">+ æ·»åŠ æ¨¡å—</el-button></div>
      </template>
      <el-form-item label="项目备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="2000" show-word-limit /></el-form-item>
      <el-form-item label="项目资料"><AttachmentUploadFile v-model:file-list="files" :limit="20" /></el-form-item>
    </el-form>
    <template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">{{ personnelOnly ? '保存执行人' : startProject ? '保存并开始项目' : '保存' }}</el-button></template>
    <template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">{{ startProject ? '保存并开始项目' : '保存' }}</el-button></template>
  </el-dialog>
</template>
@@ -55,35 +36,29 @@
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import { addProject, getProject, updateProject } from '@/api/projectManagement/project'
import { listCustomer } from '@/api/basicData/customer'
import { deleteProjectModule, getProjectWorkflow, listProjectCategories, saveProjectModule, startProjectWorkflow, updateProjectExecutors } from '@/api/projectManagement/workflow'
import { listProjectCategories, startProjectWorkflow } from '@/api/projectManagement/workflow'
import { userListAll } from '@/api/publicApi'
const emit = defineEmits(['completed'])
const visible = ref(false)
const saving = ref(false)
const startProject = ref(false)
const personnelOnly = ref(false)
const operationType = ref('add')
const formRef = ref()
const customers = ref([])
const users = ref([])
const categoryTree = ref([])
const files = ref([])
const startModules = ref([])
const originalModuleIds = ref([])
const form = reactive({ id:null,no:'',title:'',clientId:null,clientName:'',source:'手动创建',sourceBusinessId:null,projectClassification:'',planStartTime:'',planEndTime:'',projectLeaderId:null,projectLeaderName:'',projectManagerIds:[],projectManagerNames:[],projectExecutorIds:[],projectExecutorNames:[],followCycle:'MONTH',followDetail:'',currentPhase:'未开始',remark:'' })
const form = reactive({ id:null,no:'',title:'',clientId:null,clientName:'',source:'手动创建',sourceBusinessId:null,projectClassification:'',planStartTime:'',planEndTime:'',projectLeaderId:null,projectLeaderName:'',projectManagerIds:[],projectManagerNames:[],followCycle:'MONTH',followDetail:'',currentPhase:'未开始',remark:'' })
const rules = {
  title:[{ required:true,message:'请输入项目名称',trigger:'blur' }], clientName:[{ required:true,message:'请选择客户',trigger:'change' }],
  projectClassification:[{ required:true,message:'请选择项目分类',trigger:'change' }], planStartTime:[{ required:true,message:'请选择预计开始时间',trigger:'change' }],
  planEndTime:[{ required:true,message:'请选择预计结束时间',trigger:'change' }], projectLeaderId:[{ required:true,message:'请选择项目负责人',trigger:'change' }],
  projectManagerIds:[{ required:true,type:'array',min:1,message:'请选择至少一名项目经理',trigger:'change' }], followCycle:[{ required:true,message:'请选择跟进周期',trigger:'change' }],
  projectExecutorIds:[{ required:true,type:'array',min:1,message:'请选择至少一名项目执行人',trigger:'change' }],
  followDetail:[{ required:true,message:'请输入跟进要求',trigger:'blur' }]
}
const isBusinessProject = computed(() => !!form.sourceBusinessId || form.source === '业务生成' || form.source === '业务审批')
const startModuleWeight = computed(() => startModules.value.reduce((sum,module)=>sum+Number(module.projectWeight||0),0).toFixed(2))
const projectExecutorOptions = computed(() => users.value.filter(user=>form.projectExecutorIds.some(id=>String(id)===String(user.value))))
const dialogTitle = computed(() => personnelOnly.value ? '调整项目执行人' : startProject.value ? '填写立项信息并开始项目' : operationType.value === 'add' ? '手动创建项目' : '编辑项目基本情况')
const dialogTitle = computed(() => startProject.value ? '填写立项信息并开始项目' : operationType.value === 'add' ? '手动创建项目' : '编辑项目基本情况')
function buildTree(list) { const map=new Map((list||[]).map(x=>[String(x.id),{...x,children:[]}])) ; const roots=[]; map.forEach(node=>{ const parent=map.get(String(node.parentId)); if(parent&&node.parentId) parent.children.push(node); else roots.push(node) }); return roots }
async function loadOptions() {
@@ -92,54 +67,27 @@
  const userData=userRes?.data||userRes?.rows||userRes||[]; users.value=(Array.isArray(userData)?userData:[]).map(u=>({ label:u.nickName||u.userName||u.name,value:u.userId||u.id }))
  categoryTree.value=buildTree(categoryRes?.data||[])
}
function reset() { Object.assign(form,{ id:null,no:'',title:'',clientId:null,clientName:'',source:'手动创建',sourceBusinessId:null,projectClassification:'',planStartTime:'',planEndTime:'',projectLeaderId:null,projectLeaderName:'',projectManagerIds:[],projectManagerNames:[],projectExecutorIds:[],projectExecutorNames:[],followCycle:'MONTH',followDetail:'',currentPhase:'未开始',remark:'' }); files.value=[];startModules.value=[];originalModuleIds.value=[] }
function reset() { Object.assign(form,{ id:null,no:'',title:'',clientId:null,clientName:'',source:'手动创建',sourceBusinessId:null,projectClassification:'',planStartTime:'',planEndTime:'',projectLeaderId:null,projectLeaderName:'',projectManagerIds:[],projectManagerNames:[],followCycle:'MONTH',followDetail:'',currentPhase:'未开始',remark:'' }); files.value=[] }
function syncCustomer(name) { const item=customers.value.find(x=>x.customerName===name); form.clientId=item?.id||item?.customerId||null }
function syncLeader(id) { form.projectLeaderName=users.value.find(u=>String(u.value)===String(id))?.label||'' }
function normalizeFiles(list) { return (list||[]).map(f=>({ id:f.id??f.fileId??f.rawData?.id,name:f.name??f.fileName??f.rawData?.originalFilename,url:f.url??f.fileUrl??f.rawData?.url })).filter(f=>f.id||f.url) }
function newModule(module={}) { return { localKey:module.id||`${Date.now()}-${Math.random()}`,id:module.id||null,moduleName:module.moduleName||'',plannedStartDate:module.plannedStartDate||'',summary:module.summary||'',projectWeight:Number(module.projectWeight||0),memberIds:(module.members||[]).map(member=>member.userId) } }
function addStartModule(){startModules.value.push(newModule())}
function removeStartModule(index){startModules.value.splice(index,1)}
function otherStartModuleWeight(index){return startModules.value.reduce((sum,module,i)=>sum+(i===index?0:Number(module.projectWeight||0)),0).toFixed(2)}
function moduleWeightLimit(index){return Math.max(0,100-Number(otherStartModuleWeight(index)))}
function validateStartModules(){
  if(!startModules.value.length){ElMessage.warning('项目开始前请至少添加一个项目模块');return false}
  if(startModules.value.some(module=>!module.moduleName||!module.plannedStartDate||!module.summary||!module.memberIds.length)){ElMessage.warning('请完整填写每个模块的名称、预计开始时间、模块概要和执行人');return false}
  if(Number(startModuleWeight.value)!==100){ElMessage.warning('所有模块的项目工作量占比合计必须为100%');return false}
  return true
}
async function openDialog(options={}) {
  reset(); operationType.value=options.operationType||'add'; startProject.value=!!options.startProject; personnelOnly.value=!!options.personnelOnly; await loadOptions()
  reset(); operationType.value=options.operationType||'add'; startProject.value=!!options.startProject; await loadOptions()
  const id=options.row?.id
  if(id) { const res=await getProject(id); const detail=res?.data?.data??res?.data??res; const info=detail?.info||{}; Object.assign(form,info,{ projectManagerIds:info.projectManagerIds||[],projectManagerNames:info.projectManagerNames||[],projectExecutorIds:info.projectExecutorIds||[],projectExecutorNames:info.projectExecutorNames||[] }); files.value=(info.attachmentList||[]).map(f=>({...f})) }
  if(startProject.value&&id){const workflow=await getProjectWorkflow(id);const modules=workflow?.data?.modules||[];startModules.value=modules.map(newModule);originalModuleIds.value=modules.map(module=>module.id);if(!startModules.value.length)addStartModule()}
  if(id) { const res=await getProject(id); const detail=res?.data?.data??res?.data??res; const info=detail?.info||{}; Object.assign(form,info,{ projectManagerIds:info.projectManagerIds||[],projectManagerNames:info.projectManagerNames||[] }); files.value=(info.attachmentList||[]).map(f=>({...f})) }
  visible.value=true
}
async function submit() {
  form.projectExecutorNames=form.projectExecutorIds.map(id=>users.value.find(u=>String(u.value)===String(id))?.label||'').filter(Boolean)
  if(personnelOnly.value){
    if(!form.projectExecutorIds.length)return ElMessage.warning('请至少保留一名项目执行人')
    saving.value=true
    try{await updateProjectExecutors({projectId:form.id,executors:form.projectExecutorIds.map(id=>({userId:id,userName:users.value.find(user=>String(user.value)===String(id))?.label||''}))});ElMessage.success('项目执行人已更新');visible.value=false;emit('completed',{id:form.id})}finally{saving.value=false}
    return
  }
  await formRef.value?.validate()
  if(form.planEndTime < form.planStartTime) return ElMessage.warning('预计结束时间不能早于预计开始时间')
  if(startProject.value&&!validateStartModules()) return
  form.projectManagerNames=form.projectManagerIds.map(id=>users.value.find(u=>String(u.value)===String(id))?.label||'').filter(Boolean)
  const info={ ...form, currentPhase:startProject.value?'未开始':form.currentPhase, establishTime:form.establishTime||new Date().toISOString().slice(0,10), managerId:form.projectLeaderId,managerName:form.projectLeaderName,attachmentIds:normalizeFiles(files.value).map(f=>f.id).filter(Boolean),projectManagementPlanId:null }
  saving.value=true
  try {
    await (form.id?updateProject:addProject)({ info,shippingAddress:null,contractInfo:null,salesLedgerProductList:null })
    if(startProject.value){
      for(const module of startModules.value){
        const members=module.memberIds.map(id=>({userId:id,userName:users.value.find(user=>String(user.value)===String(id))?.label||''}))
        const res=await saveProjectModule({...module,projectId:form.id,members})
        module.id=res?.data||module.id
      }
      const retainedIds=new Set(startModules.value.map(module=>String(module.id)))
      for(const id of originalModuleIds.value.filter(id=>!retainedIds.has(String(id)))) await deleteProjectModule(id)
      await startProjectWorkflow(form.id)
    }
    ElMessage.success(startProject.value?'项目已开始':'保存成功')
@@ -150,4 +98,4 @@
defineExpose({ openDialog })
</script>
<style scoped>.tip{margin-bottom:18px}.module-card{padding:14px 14px 2px;margin-bottom:12px;border:1px solid #dcdfe6;border-radius:6px}.module-card__title,.module-actions{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.module-card__title{font-weight:600}.module-actions{color:#606266}.unit{margin-left:6px;color:#606266}.field-hint{width:100%;margin-top:4px;color:#909399;font-size:12px;line-height:18px}</style>
<style scoped>.tip{margin-bottom:18px}</style>
src/views/projectManagement/Management/index.vue
@@ -17,42 +17,191 @@
        <el-table-column prop="source" label="项目来源" width="110" />
        <el-table-column prop="projectClassification" label="项目分类" width="140" />
        <el-table-column prop="projectLeaderName" label="项目负责人" width="120" />
        <el-table-column prop="currentPhase" label="当前阶段" width="110"><template #default="{row}"><el-tag :type="row.currentPhase==='已完成'?'success':row.currentPhase==='未开始'?'info':'warning'">{{ row.currentPhase || '未开始' }}</el-tag></template></el-table-column>
        <el-table-column prop="currentPhase" label="当前阶段" width="110"><template #default="{row}"><el-tag class="phase-tag" :class="phaseTagClass(row.currentPhase)">{{ row.currentPhase || '未开始' }}</el-tag></template></el-table-column>
        <el-table-column prop="projectProgress" label="项目进度" width="150" align="center"><template #default="{row}"><el-progress :percentage="Number(row.projectProgress || 0)" /></template></el-table-column>
        <el-table-column prop="planStartTime" label="预计开始" width="120" />
        <el-table-column prop="planEndTime" label="预计结束" width="120" />
        <el-table-column label="操作" width="270" fixed="right" align="center">
        <el-table-column prop="actualStartTime" label="实际开始" width="120"><template #default="{row}">{{ row.actualStartTime || '-' }}</template></el-table-column>
        <el-table-column prop="actualEndTime" label="实际结束" width="120"><template #default="{row}">{{ row.actualEndTime || '-' }}</template></el-table-column>
        <el-table-column label="操作" width="520" fixed="right" align="center">
          <template #default="{row}">
            <el-button v-if="row.currentPhase==='未开始'" link type="success" @click="start(row)">开始项目</el-button>
            <el-button v-if="row.currentPhase==='未开始'" link type="primary" @click="edit(row)">编辑</el-button>
            <el-button v-else link type="primary" @click="adjustExecutors(row)">调整执行人</el-button>
            <el-button v-if="row.currentPhase==='未开始'" link type="primary" @click="maintain(row, 'modules')">模块管理</el-button>
            <el-dropdown v-if="row.currentPhase!=='未开始'" class="operation-dropdown" @command="tab => maintain(row, tab)">
              <el-button link type="primary">项目维护<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
              <template #dropdown>
                <el-dropdown-menu>
                  <el-dropdown-item command="modules">模块管理</el-dropdown-item>
                  <el-dropdown-item command="follow">项目跟进</el-dropdown-item>
                  <el-dropdown-item command="reports">项目汇报</el-dropdown-item>
                  <el-dropdown-item command="materials">项目资料</el-dropdown-item>
                </el-dropdown-menu>
              </template>
            </el-dropdown>
            <el-button link type="primary" @click="detail(row)">详情</el-button>
            <el-button link type="danger" @click="remove(row)">删除</el-button>
            <el-button v-if="canAdvanceStage(row)" link type="success" @click="advanceStage(row)">{{ nextStageAction(row) }}</el-button>
            <el-button link type="primary" @click="openLifecycle(row)">项目生命周期</el-button>
            <el-button v-if="row.currentPhase==='未开始'" link type="danger" @click="remove(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
      <pagination v-show="total>0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
    </el-card>
    <FormDia ref="formRef" @completed="completed" />
    <el-dialog v-model="maintenanceVisible" :title="`${selectedProject.title || ''} - ${maintenanceTitle}`" width="92%" top="5vh" destroy-on-close>
      <ProjectExecutionPanel v-if="maintenanceVisible" :project-id="selectedProject.id" :project-info="selectedProject" :single-tab="maintenanceTab" :readonly="maintenanceReadonly" @project-updated="refreshProjectList" />
    </el-dialog>
    <el-drawer v-model="lifecycleVisible" :title="`${lifecycleProject.title || ''} - é¡¹ç›®ç”Ÿå‘½å‘¨æœŸ`" size="520px" destroy-on-close>
      <div v-loading="lifecycleLoading" class="lifecycle-drawer">
        <div class="lifecycle-summary">
          <div><span>当前阶段</span><strong>{{ lifecycleProject.currentPhase || '未开始' }}</strong></div>
          <div><span>计划周期</span><strong>{{ lifecycleProject.planStartTime || '-' }} è‡³ {{ lifecycleProject.planEndTime || '-' }}</strong></div>
          <div><span>实际周期</span><strong>{{ lifecycleProject.actualStartTime || '-' }} è‡³ {{ lifecycleProject.actualEndTime || '-' }}</strong></div>
        </div>
        <el-empty v-if="!lifecycleLoading && !lifecycleNodes.length" description="暂无生命周期记录" />
        <el-timeline v-else class="project-timeline">
          <el-timeline-item v-for="node in lifecycleNodes" :key="node.code" :timestamp="formatLifecycleTime(node.time)" placement="top" :type="lifecycleNodeType(node.status,node.eventType)" :hollow="node.status === 'PENDING'">
            <div class="lifecycle-node" :class="node.status?.toLowerCase()">
              <div class="lifecycle-node-head"><strong>{{ node.name }}</strong><el-tag size="small" :type="lifecycleTagType(node.status,node.eventType)">{{ node.statusLabel || lifecycleStatusLabel(node.status) }}</el-tag></div>
              <p>{{ node.description || '暂无说明' }}</p>
            </div>
          </el-timeline-item>
        </el-timeline>
      </div>
    </el-drawer>
    <el-dialog v-model="stageVisible" :title="stageDialogTitle" width="680px" destroy-on-close>
      <el-alert :title="`当前项目:${stageProject.title || '-'},当前阶段:${stageProject.currentPhase || '-'}`" type="info" :closable="false" class="stage-alert" />
      <el-form label-width="110px">
        <template v-if="stageProject.currentPhase==='开发'">
          <el-form-item label="申报日期" required><el-date-picker v-model="stageForm.applicationDate" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="申报类型" required><el-select v-model="stageForm.applicationType" style="width:100%"><el-option v-for="item in ['客户交付','平台发布','软件著作权','内部发布','其他']" :key="item" :label="item" :value="item" /></el-select></el-form-item>
          <el-form-item label="申报名称" required><el-input v-model="stageForm.applicationName" maxlength="200" /></el-form-item>
          <el-form-item label="预计反馈日期"><el-date-picker v-model="stageForm.expectedFeedbackDate" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="申报说明" required><el-input v-model="stageForm.description" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
        </template>
        <template v-else-if="stageProject.currentPhase==='申报'">
          <el-form-item label="结果日期" required><el-date-picker v-model="stageForm.resultDate" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="申报结果" required><el-radio-group v-model="stageForm.resultStatus"><el-radio value="PASS">通过</el-radio><el-radio value="SUPPLEMENT">需要补充</el-radio><el-radio value="REJECTED">未通过</el-radio></el-radio-group></el-form-item>
          <el-form-item label="结果说明" required><el-input v-model="stageForm.resultDescription" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
          <el-alert v-if="stageForm.resultStatus!=='PASS'" title="保存后项目仍停留在申报阶段,可继续补充或重新登记结果。" type="warning" :closable="false" />
        </template>
        <template v-else-if="stageProject.currentPhase==='申报结果'">
          <el-form-item label="计划验收日期" required><el-date-picker v-model="stageForm.acceptancePlanDate" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="验收人/客户" required><el-input v-model="stageForm.acceptor" maxlength="100" /></el-form-item>
          <el-form-item label="验收标准" required><el-input v-model="stageForm.acceptanceStandard" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
        </template>
        <template v-else-if="stageProject.currentPhase==='验收'">
          <el-form-item label="验收日期" required><el-date-picker v-model="stageForm.acceptanceDate" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
          <el-form-item label="验收人/客户" required><el-input v-model="stageForm.acceptor" maxlength="100" /></el-form-item>
          <el-form-item label="验收结果" required><el-radio-group v-model="stageForm.acceptanceResult"><el-radio value="PASS">通过</el-radio><el-radio value="RECTIFY">需要整改</el-radio></el-radio-group></el-form-item>
          <el-form-item label="验收说明" required><el-input v-model="stageForm.acceptanceDescription" type="textarea" :rows="4" maxlength="2000" show-word-limit /></el-form-item>
          <el-alert v-if="stageForm.acceptanceResult==='RECTIFY'" title="保存后项目仍停留在验收阶段,整改完成后可再次填写验收结果。" type="warning" :closable="false" />
        </template>
        <el-form-item label="相关附件"><AttachmentUploadFile v-model:file-list="stageFiles" :limit="20" /></el-form-item>
      </el-form>
      <template #footer><el-button @click="stageVisible=false">取消</el-button><el-button type="primary" :loading="stageSaving" @click="submitStage">{{ stageSubmitText }}</el-button></template>
    </el-dialog>
  </div>
</template>
<script setup name="ProjectManagement">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listProject, delProject } from '@/api/projectManagement/project'
import { getProjectLifecycle, saveProjectPhase, transitionProjectStage } from '@/api/projectManagement/workflow'
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import FormDia from './components/formDia.vue'
import ProjectExecutionPanel from './components/ProjectExecutionPanel.vue'
const router=useRouter(); const loading=ref(false); const rows=ref([]); const total=ref(0); const formRef=ref()
const maintenanceVisible=ref(false); const maintenanceTab=ref('modules'); const selectedProject=ref({})
const lifecycleVisible=ref(false); const lifecycleLoading=ref(false); const lifecycleProject=ref({}); const lifecycleNodes=ref([])
const stageVisible=ref(false); const stageSaving=ref(false); const stageProject=ref({}); const stageFiles=ref([])
const stageForm=reactive({applicationDate:'',applicationType:'客户交付',applicationName:'',expectedFeedbackDate:'',description:'',resultDate:'',resultStatus:'PASS',resultDescription:'',acceptancePlanDate:'',acceptor:'',acceptanceStandard:'',acceptanceDate:'',acceptanceResult:'PASS',acceptanceDescription:''})
const query=reactive({ keyword:'',customerName:'',source:'',pageNum:1,pageSize:10 })
async function load(){ loading.value=true; try{ const res=await listProject({ noOrName:query.keyword,clientName:query.customerName,current:query.pageNum,size:query.pageSize }); const data=res?.data?.records||res?.rows||[]; rows.value=(data||[]).filter(r=>!query.source||r.source===query.source); total.value=res?.data?.total||res?.total||0 }finally{loading.value=false} }
function search(){query.pageNum=1;load()} function reset(){Object.assign(query,{keyword:'',customerName:'',source:'',pageNum:1,pageSize:10});load()}
function add(){formRef.value?.openDialog({operationType:'add'})} function edit(row){formRef.value?.openDialog({operationType:'edit',row})} function start(row){formRef.value?.openDialog({operationType:'edit',row,startProject:true})}
function adjustExecutors(row){formRef.value?.openDialog({operationType:'edit',row,personnelOnly:true})}
const maintenanceNames={modules:'模块管理',follow:'项目跟进',reports:'项目汇报',materials:'项目资料'}
const nextStageMap={开发:'申报',申报:'申报结果',申报结果:'验收',验收:'已完成'}
const maintenanceReadonly=computed(()=>['申报','申报结果','验收','已完成'].includes(selectedProject.value.currentPhase))
const maintenanceTitle=computed(()=>`${maintenanceNames[maintenanceTab.value]||'项目维护'}${maintenanceReadonly.value?'(只读)':''}`)
function maintain(row,tab){selectedProject.value={...row};maintenanceTab.value=tab;maintenanceVisible.value=true}
async function refreshProjectList(){
  await load()
  const latest=rows.value.find(row=>String(row.id)===String(selectedProject.value.id))
  if(latest) selectedProject.value={...latest}
}
function detail(row){router.push(`/projectManagement/Management/detail/${row.id}`)}
function canAdvanceStage(row){
  if(!nextStageMap[row.currentPhase]) return false
  return row.currentPhase!=='开发'||Number(row.projectProgress||0)>=100
}
function phaseTagClass(phase){
  return ({未开始:'phase-not-started',立项:'phase-initiation',开发:'phase-development',申报:'phase-application',申报结果:'phase-result',验收:'phase-acceptance',已完成:'phase-completed'})[phase]||'phase-not-started'
}
function nextStageAction(row){return ({开发:'提交申报',申报:'登记申报结果',申报结果:'进入验收',验收:'填写验收结果'})[row.currentPhase]||'阶段办理'}
const stageDialogTitle=computed(()=>nextStageAction(stageProject.value))
const stageSubmitText=computed(()=>stageProject.value.currentPhase==='验收'&&stageForm.acceptanceResult==='PASS'?'验收通过并完成项目':'保存')
function advanceStage(row){
  const today=new Date().toISOString().slice(0,10)
  stageProject.value={...row}
  Object.assign(stageForm,{applicationDate:today,applicationType:'客户交付',applicationName:row.title||'',expectedFeedbackDate:'',description:'',resultDate:today,resultStatus:'PASS',resultDescription:'',acceptancePlanDate:today,acceptor:row.clientName||'',acceptanceStandard:'',acceptanceDate:today,acceptanceResult:'PASS',acceptanceDescription:''})
  stageFiles.value=[]
  stageVisible.value=true
}
function normalizedStageFiles(){return stageFiles.value.map(file=>({id:file.id??file.fileId??file.rawData?.id,name:file.name??file.fileName??file.rawData?.originalFilename,url:file.url??file.fileUrl??file.rawData?.url})).filter(file=>file.id||file.url)}
function stageRemark(phase){
  if(phase==='开发') return `${stageForm.applicationType}:${stageForm.applicationName}`
  if(phase==='申报') return `申报结果:${({PASS:'通过',SUPPLEMENT:'需要补充',REJECTED:'未通过'})[stageForm.resultStatus]};${stageForm.resultDescription}`
  if(phase==='申报结果') return `计划验收:${stageForm.acceptancePlanDate};验收人:${stageForm.acceptor}`
  return `验收结果:${stageForm.acceptanceResult==='PASS'?'通过':'需要整改'};${stageForm.acceptanceDescription}`
}
function validateStageForm(phase){
  if(phase==='开发'&&(!stageForm.applicationDate||!stageForm.applicationType||!stageForm.applicationName.trim()||!stageForm.description.trim())) return '请完整填写申报日期、类型、名称和说明'
  if(phase==='申报'&&(!stageForm.resultDate||!stageForm.resultStatus||!stageForm.resultDescription.trim())) return '请完整填写申报结果日期、结果和说明'
  if(phase==='申报结果'&&(!stageForm.acceptancePlanDate||!stageForm.acceptor.trim()||!stageForm.acceptanceStandard.trim())) return '请完整填写验收日期、验收人和验收标准'
  if(phase==='验收'&&(!stageForm.acceptanceDate||!stageForm.acceptor.trim()||!stageForm.acceptanceResult||!stageForm.acceptanceDescription.trim())) return '请完整填写验收日期、验收人、结果和说明'
}
async function submitStage(){
  const phase=stageProject.value.currentPhase
  const validationMessage=validateStageForm(phase)
  if(validationMessage) return ElMessage.warning(validationMessage)
  const common={projectId:stageProject.value.id,formData:{...stageForm},remark:stageRemark(phase),attachments:normalizedStageFiles()}
  stageSaving.value=true
  try{
    if(phase==='开发') await transitionProjectStage({...common,targetStage:'申报'})
    else if(phase==='申报'&&stageForm.resultStatus==='PASS') await transitionProjectStage({...common,targetStage:'申报结果'})
    else if(phase==='申报') await saveProjectPhase({...common,phaseCode:'APPLICATION',status:'IN_PROGRESS'})
    else if(phase==='申报结果') await transitionProjectStage({...common,targetStage:'验收'})
    else if(phase==='验收'&&stageForm.acceptanceResult==='PASS') await transitionProjectStage({...common,targetStage:'已完成'})
    else if(phase==='验收') await saveProjectPhase({...common,phaseCode:'ACCEPTANCE',status:'IN_PROGRESS'})
    ElMessage.success(phase==='验收'&&stageForm.acceptanceResult==='PASS'?'项目已验收完成':'阶段信息已保存')
    stageVisible.value=false
    await load()
  }finally{stageSaving.value=false}
}
async function openLifecycle(row){
  lifecycleProject.value={...row};lifecycleNodes.value=[];lifecycleVisible.value=true;lifecycleLoading.value=true
  try{const res=await getProjectLifecycle(row.id);const data=res?.data||{};lifecycleProject.value={...row,...(data.project||{})};lifecycleNodes.value=data.nodes||[]}
  catch{ElMessage.error('获取项目生命周期失败')}finally{lifecycleLoading.value=false}
}
function formatLifecycleTime(time){return time?String(time).replace('T',' '):'时间待记录'}
function lifecycleNodeType(status,eventType){return ['RECTIFY','SUPPLEMENT'].includes(eventType)?'warning':eventType==='REJECTED'?'danger':status==='COMPLETED'?'success':status==='CURRENT'?'primary':'info'}
function lifecycleTagType(status,eventType){return lifecycleNodeType(status,eventType)}
function lifecycleStatusLabel(status){return status==='COMPLETED'?'已完成':status==='CURRENT'?'进行中':'待进行'}
function completed(result){ load(); if(result?.startProject&&result?.id) router.push(`/projectManagement/Management/detail/${result.id}`) }
async function remove(row){ await ElMessageBox.confirm(`确认删除项目“${row.title}”吗?`,'提示',{type:'warning'}); await delProject(row.id); ElMessage.success('删除成功');load() }
onMounted(load)
</script>
<style scoped>.app-container{padding:20px}.search-card{margin-bottom:16px}.actions{margin-bottom:14px;text-align:right}</style>
<style scoped>
.app-container{padding:20px}.search-card{margin-bottom:16px}.actions{margin-bottom:14px;text-align:right}.operation-dropdown{margin:0 10px}
.stage-alert{margin-bottom:18px}
.lifecycle-drawer{min-height:300px;padding:0 6px 24px}.lifecycle-summary{display:grid;gap:10px;margin-bottom:26px;padding:16px;border:1px solid #dbe7fb;border-radius:10px;background:linear-gradient(135deg,#f4f8ff,#fff)}
.lifecycle-summary div{display:flex;align-items:center;justify-content:space-between;gap:14px}.lifecycle-summary span{color:#909399;font-size:13px}.lifecycle-summary strong{color:#303133;font-size:13px;text-align:right}
.project-timeline{padding:4px 4px 0 8px}.lifecycle-node{padding:13px 14px;border:1px solid #e4e7ed;border-radius:9px;background:#fff}.lifecycle-node.current{border-color:#9bbaf2;background:#f3f7ff}.lifecycle-node.completed{border-color:#b9dfca;background:#f4fbf7}.lifecycle-node-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.lifecycle-node-head strong{font-size:15px}.lifecycle-node p{margin:9px 0 0;color:#606266;font-size:13px;line-height:1.6}
.phase-tag.phase-not-started{color:#667085;background:#f2f4f7;border-color:#d0d5dd}.phase-tag.phase-initiation{color:#087f8c;background:#e8f8fa;border-color:#9bd8de}.phase-tag.phase-development{color:#1769e0;background:#eaf3ff;border-color:#aac9f9}.phase-tag.phase-application{color:#c65d00;background:#fff3e6;border-color:#ffc58f}.phase-tag.phase-result{color:#6941c6;background:#f4ebff;border-color:#d6bbfb}.phase-tag.phase-acceptance{color:#087a65;background:#e8f7f2;border-color:#9ad7c8}.phase-tag.phase-completed{color:#21834a;background:#eaf8ef;border-color:#a9ddb9}
</style>
src/views/projectManagement/Management/projectDetail.vue
@@ -4,11 +4,7 @@
      <div class="header-row">
        <div><div class="title">{{ info.title || '项目详情' }}</div><div class="subtitle">{{ info.no || '-' }} Â· {{ info.source || '手动创建' }}</div></div>
        <div>
          <el-tag :type="phaseTagType" size="large">{{ info.currentPhase || '未开始' }}</el-tag>
          <el-button v-if="info.currentPhase === '未开始'" type="primary" @click="openStart">开始项目</el-button>
          <el-button v-if="info.currentPhase === '未开始'" @click="openEdit">编辑立项信息</el-button>
          <el-button v-else @click="adjustExecutors">调整项目执行人</el-button>
          <el-button v-if="nextStage" type="success" @click="advanceStage">进入{{ nextStage }}</el-button>
          <el-tag class="phase-tag" :class="phaseTagClass(info.currentPhase)" size="large">{{ info.currentPhase || '未开始' }}</el-tag>
          <el-button @click="router.back()">返回</el-button>
        </div>
      </div>
@@ -21,9 +17,10 @@
        <el-descriptions-item label="项目分类">{{ info.projectClassification || '-' }}</el-descriptions-item>
        <el-descriptions-item label="预计开始">{{ info.planStartTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="预计结束">{{ info.planEndTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="实际开始">{{ info.actualStartTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="实际结束">{{ info.actualEndTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="项目负责人">{{ info.projectLeaderName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="项目经理">{{ (info.projectManagerNames || []).join('、') || '-' }}</el-descriptions-item>
        <el-descriptions-item label="项目执行人" :span="2">{{ (info.projectExecutorNames || []).join('、') || '-' }}</el-descriptions-item>
        <el-descriptions-item label="跟进周期">{{ followCycleLabel }}</el-descriptions-item>
        <el-descriptions-item label="跟进要求" :span="3">{{ info.followDetail || '-' }}</el-descriptions-item>
        <el-descriptions-item label="项目备注" :span="4">{{ info.remark || '-' }}</el-descriptions-item>
@@ -33,19 +30,16 @@
        <el-button v-for="(file,index) in attachments" :key="file.id || index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index + 1}` }}</el-button>
      </div>
    </el-card>
    <el-card shadow="never" class="content-card"><ProjectExecutionPanel v-if="info.id" ref="executionRef" :project-id="info.id" :project-info="info" /></el-card>
    <FormDia ref="formDiaRef" @completed="loadDetail" />
    <el-card shadow="never" class="content-card"><ProjectExecutionPanel v-if="info.id" ref="executionRef" :project-id="info.id" :project-info="info" readonly /></el-card>
  </div>
</template>
<script setup name="ProjectManagementDetail">
import { computed, getCurrentInstance, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { getProject } from '@/api/projectManagement/project'
import { transitionProjectStage } from '@/api/projectManagement/workflow'
import ProjectExecutionPanel from './components/ProjectExecutionPanel.vue'
import FormDia from './components/formDia.vue'
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -53,11 +47,11 @@
const loading = ref(false)
const info = ref({})
const attachments = ref([])
const formDiaRef = ref()
const executionRef = ref()
const followCycleLabel = computed(() => ({ MONTH: '每月', QUARTER: '每季度', YEAR: '每年' }[info.value.followCycle] || '-'))
const phaseTagType = computed(() => info.value.currentPhase === '已完成' ? 'success' : info.value.currentPhase === '未开始' ? 'info' : 'warning')
const nextStage = computed(() => ({ '立项': '申报', '开发': '申报', '申报': '申报结果', '申报结果': '验收', '验收': '已完成' }[info.value.currentPhase] || ''))
function phaseTagClass(phase) {
  return ({ æœªå¼€å§‹: 'phase-not-started', ç«‹é¡¹: 'phase-initiation', å¼€å‘: 'phase-development', ç”³æŠ¥: 'phase-application', ç”³æŠ¥ç»“æžœ: 'phase-result', éªŒæ”¶: 'phase-acceptance', å·²å®Œæˆ: 'phase-completed' })[phase] || 'phase-not-started'
}
async function loadDetail() {
  loading.value = true
@@ -69,19 +63,11 @@
    await executionRef.value?.refresh?.()
  } catch { ElMessage.error('获取项目详情失败') } finally { loading.value = false }
}
function openEdit() { formDiaRef.value?.openDialog({ operationType: 'edit', row: { id: info.value.id }, startProject: false }) }
function openStart() { formDiaRef.value?.openDialog({ operationType: 'edit', row: { id: info.value.id }, startProject: true }) }
function adjustExecutors() { formDiaRef.value?.openDialog({ operationType: 'edit', row: { id: info.value.id }, personnelOnly: true }) }
async function advanceStage() {
  await ElMessageBox.confirm(`确认将项目推进到“${nextStage.value}”吗?`, '项目阶段推进', { type: 'warning' })
  await transitionProjectStage({ projectId: info.value.id, targetStage: nextStage.value })
  ElMessage.success(`已进入${nextStage.value}阶段`)
  await loadDetail()
}
function download(file) { if (!file?.url) return ElMessage.warning('附件暂无下载地址'); proxy.$download.byUrl(file.url, file.name || file.fileName) }
onMounted(loadDetail)
</script>
<style scoped>
.app-container { padding: 20px; }.header-card,.content-card { margin-bottom: 16px; }.header-row { display:flex;align-items:center;justify-content:space-between;gap:16px; }.title { font-size:22px;font-weight:600;color:#1f2d3d; }.subtitle { margin-top:6px;color:#909399; }.header-row>div:last-child { display:flex;align-items:center;gap:10px; }.attachment-block { margin-top:18px; }.section-title { margin-bottom:10px;font-weight:600; }
.phase-tag.phase-not-started{color:#667085;background:#f2f4f7;border-color:#d0d5dd}.phase-tag.phase-initiation{color:#087f8c;background:#e8f8fa;border-color:#9bd8de}.phase-tag.phase-development{color:#1769e0;background:#eaf3ff;border-color:#aac9f9}.phase-tag.phase-application{color:#c65d00;background:#fff3e6;border-color:#ffc58f}.phase-tag.phase-result{color:#6941c6;background:#f4ebff;border-color:#d6bbfb}.phase-tag.phase-acceptance{color:#087a65;background:#e8f7f2;border-color:#9ad7c8}.phase-tag.phase-completed{color:#21834a;background:#eaf8ef;border-color:#a9ddb9}
</style>
src/views/safeProduction/safeWorkApproval/fileList.vue
@@ -5,7 +5,6 @@
      <el-table-column fixed="right" label="操作" width="150" align="center">
        <template #default="scope">
          <el-button link type="primary" size="small" @click="downLoadFile(scope.row)">下载</el-button>
          <el-button link type="primary" size="small" @click="lookFile(scope.row)">预览</el-button>
          <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
        </template>
      </el-table-column>
@@ -64,4 +63,4 @@
})
</script>
<style></style>
<style></style>
src/views/salesManagement/salesLedger/fileList.vue
@@ -5,7 +5,6 @@
      <el-table-column fixed="right" label="操作" width="100" align="center">
        <template #default="scope">
          <el-button link type="primary" @click="downLoadFile(scope.row)">下载</el-button>
          <el-button link type="primary" @click="lookFile(scope.row)">预览</el-button>
        </template>
      </el-table-column>
    </el-table>
@@ -40,4 +39,4 @@
})
</script>
<style></style>
<style></style>
src/views/salesManagement/salesLedger/index.vue
@@ -210,6 +210,37 @@
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="关联项目" width="290" align="left">
          <template #default="scope">
            <div
              v-if="scope.row.projectManagementInfoId"
              class="business-project-cell"
              @click="openProjectProgress(scope.row)"
            >
              <div class="business-project-cell__head">
                <span class="business-project-cell__name">
                  {{ scope.row.linkedProjectName || scope.row.projectName || "关联项目" }}
                </span>
                <el-tag
                  size="small"
                  effect="light"
                  :type="projectPhaseTagType(scope.row.projectCurrentPhase)"
                >
                  {{ scope.row.projectCurrentPhase || "未开始" }}
                </el-tag>
              </div>
              <el-progress
                :percentage="Number(scope.row.projectProgress || 0)"
                :stroke-width="7"
              />
              <div class="business-project-cell__meta">
                æ¨¡å— {{ scope.row.projectCompletedModuleCount || 0 }}/{{ scope.row.projectModuleCount || 0 }},
                è´Ÿè´£äººï¼š{{ scope.row.projectLeaderName || "未设置" }}
              </div>
            </div>
            <el-tag v-else type="info" effect="plain">暂未生成项目</el-tag>
          </template>
        </el-table-column>
        <el-table-column fixed="right" label="操作" width="500" align="center">
          <template #default="scope">
            <el-button link type="primary" @click="openForm('view', scope.row)"
@@ -248,7 +279,7 @@
              link
              type="primary"
              @click="openProjectProgress(scope.row)"
              >查看项目进度
              >项目概况
            </el-button>
            <el-button
              v-if="![1, 2].includes(scope.row.businessApprovalStatus)"
@@ -1297,6 +1328,112 @@
        </template>
      </el-table>
    </el-dialog>
    <el-drawer
      v-model="projectOverviewVisible"
      title="关联项目进度与情况"
      size="720px"
      destroy-on-close
    >
      <div v-loading="projectOverviewLoading" class="project-overview">
        <template v-if="projectOverviewRow">
          <div class="project-overview__hero">
            <div>
              <div class="project-overview__title">
                {{ projectOverviewRow.linkedProjectName || projectOverviewRow.projectName }}
              </div>
              <div class="project-overview__no">
                {{ projectOverviewRow.projectNo || "暂无项目编号" }}
              </div>
            </div>
            <el-tag
              size="large"
              :type="projectPhaseTagType(projectOverviewRow.projectCurrentPhase)"
            >
              {{ projectOverviewRow.projectCurrentPhase || "未开始" }}
            </el-tag>
          </div>
          <div class="project-overview__progress-card">
            <div class="project-overview__progress-title">
              <span>项目总进度</span>
              <strong>{{ Number(projectOverviewRow.projectProgress || 0) }}%</strong>
            </div>
            <el-progress
              :percentage="Number(projectOverviewRow.projectProgress || 0)"
              :stroke-width="12"
              :show-text="false"
            />
          </div>
          <el-descriptions :column="2" border class="project-overview__descriptions">
            <el-descriptions-item label="项目负责人">
              {{ projectOverviewRow.projectLeaderName || "未设置" }}
            </el-descriptions-item>
            <el-descriptions-item label="模块完成">
              {{ projectOverviewRow.projectCompletedModuleCount || 0 }} /
              {{ projectOverviewRow.projectModuleCount || 0 }}
            </el-descriptions-item>
            <el-descriptions-item label="计划周期">
              {{ formatProjectDate(projectOverviewRow.projectPlanStartTime) }} è‡³
              {{ formatProjectDate(projectOverviewRow.projectPlanEndTime) }}
            </el-descriptions-item>
            <el-descriptions-item label="实际周期">
              {{ formatProjectDate(projectOverviewRow.projectActualStartTime) }} è‡³
              {{ formatProjectDate(projectOverviewRow.projectActualEndTime) }}
            </el-descriptions-item>
          </el-descriptions>
          <div class="project-overview__section-title">项目节点</div>
          <div class="project-overview__nodes">
            <div
              v-for="node in projectCoreLifecycleNodes"
              :key="node.code"
              class="project-overview__node"
              :class="'is-' + String(node.status || 'PENDING').toLowerCase()"
            >
              <span class="project-overview__node-dot"></span>
              <span class="project-overview__node-name">{{ node.name }}</span>
              <span class="project-overview__node-time">
                {{ node.time ? formatProjectDate(node.time) : "待进行" }}
              </span>
            </div>
          </div>
          <div class="project-overview__section-title">模块进度</div>
          <el-table :data="projectOverview.modules || []" border size="small">
            <el-table-column prop="moduleName" label="模块" min-width="150" />
            <el-table-column prop="projectWeight" label="项目占比" width="100" align="center">
              <template #default="scope">{{ Number(scope.row.projectWeight || 0) }}%</template>
            </el-table-column>
            <el-table-column label="当前进度" min-width="170">
              <template #default="scope">
                <el-progress :percentage="Number(scope.row.currentProgress || 0)" :stroke-width="8" />
              </template>
            </el-table-column>
            <el-table-column prop="reportCount" label="汇报次数" width="90" align="center" />
          </el-table>
          <div class="project-overview__section-title">最近动态</div>
          <el-timeline v-if="projectRecentActivities.length">
            <el-timeline-item
              v-for="item in projectRecentActivities"
              :key="item.key"
              :timestamp="item.time"
              placement="top"
            >
              <strong>{{ item.title }}</strong>
              <div class="project-overview__activity-detail">{{ item.detail }}</div>
            </el-timeline-item>
          </el-timeline>
          <el-empty v-else description="暂无跟进或汇报记录" :image-size="70" />
          <div class="project-overview__footer">
            <el-button type="primary" @click="openProjectDetail">进入项目详情</el-button>
          </div>
        </template>
      </div>
    </el-drawer>
  </div>
</template>
@@ -1340,6 +1477,10 @@
  productionPlanCombine,
  productionPlanListPage,
} from "@/api/productionPlan/productionPlan.js";
import {
  getProjectWorkflow,
  getProjectLifecycle,
} from "@/api/projectManagement/workflow.js";
const FileList = defineAsyncComponent(() =>
  import("@/components/Dialog/FileList.vue")
@@ -1367,6 +1508,36 @@
const businessActionId = ref(null);
const fileList = ref([]);
const deliveryFileList = ref([]);
const projectOverviewVisible = ref(false);
const projectOverviewLoading = ref(false);
const projectOverviewRow = ref(null);
const projectOverview = ref({ modules: [], followUps: [], reports: [] });
const projectLifecycle = ref({ nodes: [] });
const projectRecentActivities = computed(() => {
  const follows = (projectOverview.value.followUps || []).map((item) => ({
    key: "follow-" + item.id,
    time: item.followDate || item.createTime || "",
    title: "项目跟进",
    detail: item.detail || "已填写项目跟进",
  }));
  const reports = (projectOverview.value.reports || []).map((item) => ({
    key: "report-" + item.id,
    time: item.reportDate || item.createTime || "",
    title: (item.moduleName || "项目模块") + "进度汇报",
    detail:
      (item.reporterNames || "") +
      " æ±‡æŠ¥è‡³ " +
      Number(item.progress || 0) +
      "%" +
      (item.remark ? ":" + item.remark : ""),
  }));
  return [...follows, ...reports]
    .sort((a, b) => String(b.time).localeCompare(String(a.time)))
    .slice(0, 6);
});
const projectCoreLifecycleNodes = computed(() =>
  (projectLifecycle.value.nodes || []).filter((item) => !item.eventType)
);
// ç”¨æˆ·ä¿¡æ¯è¡¨å•弹框数据
const operationType = ref("");
@@ -3030,14 +3201,56 @@
  fileDialogVisible.value = true;
};
// æŸ¥çœ‹é¡¹ç›®è¿›åº¦ï¼ˆè·³è½¬é¡¹ç›®è¯¦æƒ…,可查看阶段进展与资料文件)
const openProjectProgress = (row) => {
const projectPhaseTagType = (phase) => {
  const types = {
    æœªå¼€å§‹: "info",
    ç«‹é¡¹: "warning",
    å¼€å‘: "primary",
    ç”³æŠ¥: "warning",
    ç”³æŠ¥ç»“æžœ: "success",
    éªŒæ”¶: "success",
    å·²å®Œæˆ: "success",
  };
  return types[phase] || "info";
};
const formatProjectDate = (value) => {
  if (!value) return "未记录";
  const date = dayjs(value);
  return date.isValid() ? date.format("YYYY-MM-DD") : value;
};
// åœ¨ä¸šåŠ¡ç®¡ç†å†…æŸ¥çœ‹å…³è”é¡¹ç›®çš„é˜¶æ®µã€è¿›åº¦ã€æ¨¡å—å’Œæœ€è¿‘åŠ¨æ€ã€‚
const openProjectProgress = async (row) => {
  const projectId = row.projectManagementInfoId || row.projectId;
  if (!projectId) {
    proxy.$modal.msgWarning("该业务暂未关联项目");
    return;
  }
  router.push(`/projectManagement/Management/detail/${projectId}`);
  projectOverviewRow.value = row;
  projectOverview.value = { modules: [], followUps: [], reports: [] };
  projectLifecycle.value = { nodes: [] };
  projectOverviewVisible.value = true;
  projectOverviewLoading.value = true;
  try {
    const [workflowRes, lifecycleRes] = await Promise.all([
      getProjectWorkflow(projectId),
      getProjectLifecycle(projectId),
    ]);
    projectOverview.value = workflowRes.data || {};
    projectLifecycle.value = lifecycleRes.data || {};
  } finally {
    projectOverviewLoading.value = false;
  }
};
const openProjectDetail = () => {
  const projectId =
    projectOverviewRow.value?.projectManagementInfoId ||
    projectOverviewRow.value?.projectId;
  if (!projectId) return;
  projectOverviewVisible.value = false;
  router.push("/projectManagement/Management/detail/" + projectId);
};
// æ‰“开发货弹框
@@ -3714,6 +3927,162 @@
  }
}
.business-project-cell {
  cursor: pointer;
  padding: 3px 0;
  &__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 4px;
  }
  &__name {
    overflow: hidden;
    font-weight: 600;
    color: #1f3b68;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  &__meta {
    margin-top: 2px;
    font-size: 12px;
    color: #7a8699;
  }
  :deep(.el-progress__text) {
    min-width: 38px;
    font-size: 12px !important;
  }
}
.project-overview {
  padding: 0 4px 24px;
  &__hero,
  &__progress-title,
  &__footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
  }
  &__hero {
    padding: 18px 20px;
    margin-bottom: 14px;
    background: linear-gradient(135deg, #eef5ff, #f7faff);
    border: 1px solid #dce9fb;
    border-radius: 10px;
  }
  &__title {
    margin-bottom: 5px;
    font-size: 20px;
    font-weight: 700;
    color: #1f2d3d;
  }
  &__no,
  &__activity-detail {
    font-size: 13px;
    color: #7a8699;
  }
  &__progress-card {
    padding: 16px 20px;
    margin-bottom: 14px;
    background: #fff;
    border: 1px solid #e5eaf1;
    border-radius: 10px;
  }
  &__progress-title {
    margin-bottom: 10px;
    strong {
      font-size: 22px;
      color: var(--el-color-primary);
    }
  }
  &__descriptions {
    margin-bottom: 20px;
  }
  &__section-title {
    padding-left: 10px;
    margin: 22px 0 12px;
    font-size: 16px;
    font-weight: 700;
    border-left: 4px solid var(--el-color-primary);
  }
  &__nodes {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 8px;
  }
  &__node {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
    padding: 9px 10px;
    color: #8a94a6;
    background: #f7f8fa;
    border: 1px solid #e8ebf0;
    border-radius: 8px;
    &.is-current {
      color: #1d5ec7;
      background: #edf5ff;
      border-color: #a9ccff;
    }
    &.is-completed {
      color: #168464;
      background: #effaf6;
      border-color: #a9dfce;
    }
  }
  &__node-dot {
    flex: 0 0 auto;
    width: 7px;
    height: 7px;
    background: currentColor;
    border-radius: 50%;
  }
  &__node-name {
    overflow: hidden;
    font-size: 13px;
    font-weight: 600;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  &__node-time {
    margin-left: auto;
    font-size: 11px;
    white-space: nowrap;
  }
  &__activity-detail {
    margin-top: 5px;
    line-height: 1.6;
  }
  &__footer {
    justify-content: flex-end;
    padding-top: 18px;
  }
}
@media print {
  .app-container {
    display: none;