zhangwencui
9 天以前 def80005702fd12eb17a6e53554d56624c15b04c
部分改动
已添加5个文件
已修改9个文件
3584 ■■■■■ 文件已修改
public/favicon.ico 补丁 | 查看 | 原始文档 | blame | 历史
sql/20261103_project_workflow_upgrade.sql 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/数据库升级说明.md 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/project.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/projectManagement/workflow.js 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/collaborativeApproval/approvalProcess/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/governmentPolicy/policyFile/index.vue 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/components/ProjectExecutionPanel.vue 387 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/components/formDia.vue 1493 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/index.vue 462 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/Management/projectDetail.vue 448 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/projectManagement/projectCategory/index.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/salesManagement/salesLedger/index.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
public/favicon.ico

sql/20261103_project_workflow_upgrade.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,323 @@
-- é¡¹ç›®ç®¡ç†ç³»ç»Ÿæ–°æµç¨‹å‡çº§è„šæœ¬
-- é€‚用库:product-inventory-management-xmgl
-- è¯´æ˜Žï¼šæ‰§è¡Œå‰å…ˆåšå…¨åº“备份。本脚本不删除原业务数据,可重复执行。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP PROCEDURE IF EXISTS `add_column_if_missing`;
DELIMITER $$
CREATE PROCEDURE `add_column_if_missing`(
  IN p_table varchar(64), IN p_column varchar(64), IN p_definition varchar(1000)
)
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.COLUMNS
    WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=p_table AND COLUMN_NAME=p_column
  ) THEN
    SET @ddl=CONCAT('ALTER TABLE `',p_table,'` ADD COLUMN `',p_column,'` ',p_definition);
    PREPARE stmt FROM @ddl;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
  END IF;
END$$
DELIMITER ;
CALL add_column_if_missing('project_management_info','project_leader_id',"bigint DEFAULT NULL COMMENT '项目负责人ID'");
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每年'");
CALL add_column_if_missing('project_management_info','follow_detail',"varchar(1000) DEFAULT NULL COMMENT '跟进要求说明'");
CALL add_column_if_missing('project_management_info','current_phase',"varchar(30) DEFAULT '未开始' COMMENT '未开始/立项/开发/申报/申报结果/验收'");
CALL add_column_if_missing('project_management_info','source_business_id',"bigint DEFAULT NULL COMMENT '来源业务ID'");
ALTER TABLE `project_management_info` MODIFY COLUMN `project_management_plan_id` bigint DEFAULT NULL COMMENT '旧计划模板ID(新流程不再必填)';
CREATE TABLE IF NOT EXISTS `project_management_category` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `parent_id` bigint NOT NULL DEFAULT 0,
  `category_name` varchar(100) NOT NULL,
  `sort` int NOT NULL DEFAULT 0,
  `remark` varchar(500) DEFAULT NULL,
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_project_category_parent` (`parent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目多级分类';
CREATE TABLE IF NOT EXISTS `project_management_module` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `project_id` bigint NOT NULL,
  `module_name` varchar(200) NOT NULL,
  `planned_start_date` date DEFAULT NULL,
  `summary` varchar(1000) DEFAULT NULL,
  `project_weight` decimal(7,2) NOT NULL DEFAULT 0 COMMENT '模块占项目工作量百分比',
  `current_progress` decimal(7,2) NOT NULL DEFAULT 0 COMMENT '模块最新进度百分比',
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_project_module_project` (`project_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目模块';
CREATE TABLE IF NOT EXISTS `project_management_module_member` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `module_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `user_name` varchar(100) NOT NULL,
  `module_share` decimal(7,2) NOT NULL DEFAULT 0 COMMENT '兼容旧数据,新流程不在模块设置中填写',
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_module_member_module` (`module_id`), KEY `idx_module_member_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目模块执行人';
CREATE TABLE IF NOT EXISTS `project_management_follow_up` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `project_id` bigint NOT NULL,
  `follow_date` date NOT NULL,
  `detail` varchar(2000) NOT NULL,
  `attachments` json DEFAULT NULL,
  `create_user_id` bigint DEFAULT NULL,
  `create_user_name` varchar(100) DEFAULT NULL,
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_project_follow_project` (`project_id`,`follow_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目周期跟进';
CREATE TABLE IF NOT EXISTS `project_management_progress_report` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `project_id` bigint NOT NULL,
  `module_id` bigint NOT NULL,
  `reporter_ids` json NOT NULL,
  `reporter_names` json NOT NULL,
  `reporter_shares` json DEFAULT NULL COMMENT '本次汇报人员及工作量比重',
  `progress` decimal(7,2) NOT NULL,
  `report_date` date NOT NULL,
  `remark` varchar(2000) DEFAULT NULL,
  `attachments` json DEFAULT NULL,
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_progress_report_project` (`project_id`,`report_date`), KEY `idx_progress_report_module` (`module_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目模块进度汇报';
CALL add_column_if_missing('project_management_progress_report','reporter_shares',"json DEFAULT NULL COMMENT '本次汇报人员及工作量比重'");
DROP PROCEDURE IF EXISTS `add_column_if_missing`;
CREATE TABLE IF NOT EXISTS `project_management_material` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `project_id` bigint NOT NULL,
  `material_name` varchar(200) NOT NULL,
  `material_type` varchar(50) DEFAULT NULL,
  `remark` varchar(1000) DEFAULT NULL,
  `attachments` json DEFAULT NULL,
  `create_user_id` bigint DEFAULT NULL,
  `create_user_name` varchar(100) DEFAULT NULL,
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_project_material_project` (`project_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目整体资料库';
CREATE TABLE IF NOT EXISTS `project_management_phase` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `project_id` bigint NOT NULL,
  `phase_code` varchar(30) NOT NULL,
  `phase_name` varchar(50) NOT NULL,
  `phase_order` int NOT NULL,
  `status` varchar(20) NOT NULL DEFAULT 'NOT_STARTED' COMMENT 'NOT_STARTED/IN_PROGRESS/COMPLETED',
  `form_data` json DEFAULT NULL COMMENT '阶段对应业务信息',
  `remark` varchar(2000) DEFAULT NULL,
  `attachments` json DEFAULT NULL,
  `completed_time` datetime DEFAULT NULL,
  `update_user_name` varchar(100) DEFAULT NULL,
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_project_phase` (`project_id`,`phase_code`),
  KEY `idx_project_phase_order` (`project_id`,`phase_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目立项、开发、申报、申报结果、验收流程';
CREATE TABLE IF NOT EXISTS `government_policy_file` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `policy_name` varchar(255) NOT NULL,
  `publish_date` date DEFAULT NULL,
  `policy_url` varchar(1000) DEFAULT NULL,
  `attachments` json DEFAULT NULL,
  `maintainer_name` varchar(100) DEFAULT NULL,
  `remark` varchar(1000) DEFAULT NULL,
  `is_deleted` tinyint NOT NULL DEFAULT 0,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), KEY `idx_policy_publish_date` (`publish_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='政府政策文件';
-- äº”阶段模板:立项 â†’ å¼€å‘ â†’ ç”³æŠ¥ â†’ ç”³æŠ¥ç»“æžœ â†’ éªŒæ”¶ã€‚
INSERT INTO `project_management_plan` (`name`,`description`,`is_delete`,`create_time`,`update_time`,`create_user_name`)
SELECT '项目五阶段','立项、开发、申报、申报结果、验收顺序推进',0,NOW(),NOW(),'升级脚本'
WHERE NOT EXISTS (SELECT 1 FROM `project_management_plan` WHERE `name`='项目五阶段' AND `is_delete`=0);
SET @five_stage_plan_id=(SELECT id FROM project_management_plan WHERE name='项目五阶段' AND is_delete=0 ORDER BY id LIMIT 1);
INSERT INTO project_management_plan_node(project_management_plan_id,sort,name,estimated_duration,work_content,is_delete,create_time,update_time,create_user_name)
SELECT @five_stage_plan_id,1,'立项',1,'完善项目基本情况、分类、负责人、经理、周期和模块',0,NOW(),NOW(),'升级脚本'
WHERE NOT EXISTS (SELECT 1 FROM project_management_plan_node WHERE project_management_plan_id=@five_stage_plan_id AND name='立项' AND is_delete=0);
INSERT INTO project_management_plan_node(project_management_plan_id,sort,name,estimated_duration,work_content,is_delete,create_time,update_time,create_user_name)
SELECT @five_stage_plan_id,2,'开发',1,'开展项目跟进、进度汇报、统计和资料维护',0,NOW(),NOW(),'升级脚本'
WHERE NOT EXISTS (SELECT 1 FROM project_management_plan_node WHERE project_management_plan_id=@five_stage_plan_id AND name='开发' AND is_delete=0);
INSERT INTO project_management_plan_node(project_management_plan_id,sort,name,estimated_duration,work_content,is_delete,create_time,update_time,create_user_name)
SELECT @five_stage_plan_id,3,'申报',1,'填写项目申报信息并上传申报文件',0,NOW(),NOW(),'升级脚本'
WHERE NOT EXISTS (SELECT 1 FROM project_management_plan_node WHERE project_management_plan_id=@five_stage_plan_id AND name='申报' AND is_delete=0);
INSERT INTO project_management_plan_node(project_management_plan_id,sort,name,estimated_duration,work_content,is_delete,create_time,update_time,create_user_name)
SELECT @five_stage_plan_id,4,'申报结果',1,'登记申报结果并上传结果文件',0,NOW(),NOW(),'升级脚本'
WHERE NOT EXISTS (SELECT 1 FROM project_management_plan_node WHERE project_management_plan_id=@five_stage_plan_id AND name='申报结果' AND is_delete=0);
INSERT INTO project_management_plan_node(project_management_plan_id,sort,name,estimated_duration,work_content,is_delete,create_time,update_time,create_user_name)
SELECT @five_stage_plan_id,5,'验收',1,'填写验收信息、上传验收文件并归档',0,NOW(),NOW(),'升级脚本'
WHERE NOT EXISTS (SELECT 1 FROM project_management_plan_node WHERE project_management_plan_id=@five_stage_plan_id AND name='验收' AND is_delete=0);
-- å·²æœ‰æ‰‹å·¥é¡¹ç›®è‹¥å°šæœªæŒ‡å®šå‘¨æœŸå’Œé˜¶æ®µï¼Œè¡¥é»˜è®¤å€¼ï¼›ä¸è¦†ç›–已有内容。
UPDATE project_management_info SET follow_cycle='MONTH' WHERE follow_cycle IS NULL OR follow_cycle='';
UPDATE project_management_info SET current_phase='未开始' WHERE current_phase IS NULL OR current_phase='';
UPDATE project_management_info SET project_leader_id=manager_id,project_leader_name=manager_name
WHERE project_leader_id IS NULL AND manager_id IS NOT NULL;
-- ------------------------------
-- èœå•路由重组
-- ------------------------------
-- è¡Œæ”¿ç®¡ç†ï¼šå¤ç”¨åŽŸâ€œååŒåŠžå…¬â€æ ¹èœå•ã€‚
UPDATE sys_menu SET menu_name='行政管理',path='administrationManagement',route_name='AdministrationManagement',order_num=1,
  icon='time-range',visible='0',status='0',remark='请假、出差和统一审批入口'
WHERE menu_id=2053;
-- ç»§ç»­ä½¿ç”¨é¡¹ç›®åŽŸæœ‰çš„å®¡æ‰¹ç®¡ç†ä¸ŽååŒå®¡æ‰¹é¡µé¢ï¼Œä¸å¯ç”¨å¦ä¸€å¥—å®¡æ‰¹æµç¨‹é…ç½®é¡µé¢ã€‚
UPDATE sys_menu SET menu_name='审批管理',parent_id=2053,order_num=3,path='approvalManagement',
  component='collaborativeApproval/approvalManagement/index',route_name='ApprovalManagement',
  perms='',icon='cascader',visible='0',status='0',app_component=NULL,
  remark='原有审批类型和审批节点管理' WHERE menu_id=2293;
UPDATE sys_menu SET menu_name='协同审批',parent_id=2053,order_num=4,path='approvalProcess',
  component='collaborativeApproval/approvalProcess/index',route_name='ApprovalProcess',
  perms='',icon='skill',visible='1',status='1',app_component='/pages/indexItem?label=协同审批',
  remark='功能与请假、出差入口重复,菜单停用但保留页面和历史数据' WHERE menu_id=2054;
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '请假管理',2053,1,'leave','officeProcessAutomation/AttendManage/leave-apply/index','LeaveManagement',1,0,'C','0','0','approval:leave:list','form','admin',NOW(),'员工请假申请与重新提交'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id=2053 AND path='leave');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '出差管理',2053,2,'businessTrip','officeProcessAutomation/AttendManage/out-apply/index','BusinessTripManagement',1,0,'C','0','0','approval:trip:list','documentation','admin',NOW(),'员工出差申请与重新提交'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id=2053 AND path='businessTrip');
UPDATE sys_menu SET component='collaborativeApproval/approvalProcess/index2',
  remark='使用原有协同审批的请假流程(类型2)' WHERE parent_id=2053 AND path='leave';
UPDATE sys_menu SET component='collaborativeApproval/approvalProcess/index3',
  remark='使用原有协同审批的出差流程(类型3)' WHERE parent_id=2053 AND path='businessTrip';
-- å®¢æˆ·ç®¡ç†ï¼šå¤ç”¨åŽŸâ€œè¥é”€ç®¡ç†â€æ ¹èœå•ã€‚
UPDATE sys_menu SET menu_name='客户管理',path='customerManagement',route_name='CustomerManagement',order_num=2,
  icon='peoples',visible='0',status='0',remark='客户信息与近三年财务报表' WHERE menu_id=2010;
UPDATE sys_menu SET menu_name='客户信息维护',parent_id=2010,order_num=1,path='customerFile',
  component='basicData/customerFile/index',route_name='CustomerFile',visible='0',status='0' WHERE menu_id=2001;
UPDATE sys_menu SET menu_name='近三年财务报表',parent_id=2010,order_num=2,path='financialReport',
  component='salesManagement/reportAudit/index',route_name='CustomerFinancialReport',
  perms='sales:report:audit',visible='1',status='1',remark='已合并到客户信息维护,不再单独显示' WHERE menu_id=2369;
-- ä¸šåŠ¡ç®¡ç†æ ¹èœå•ï¼Œå¹¶è¿ç§»åŽŸé”€å”®å°è´¦é¡µé¢ã€‚
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '业务管理',0,3,'businessManagement',NULL,'BusinessManagement',1,0,'M','0','0','','documentation','admin',NOW(),'关联客户的业务登记、附件、进度与审批'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id=0 AND path='businessManagement');
SET @business_root=(SELECT menu_id FROM sys_menu WHERE parent_id=0 AND path='businessManagement' ORDER BY menu_id LIMIT 1);
UPDATE sys_menu SET menu_name='业务登记',parent_id=@business_root,order_num=1,path='businessLedger',
  component='salesManagement/salesLedger/index',route_name='BusinessLedger',visible='0',status='0',remark='业务登记、附件、进度与审批'
WHERE menu_id=2011;
UPDATE sys_menu SET order_num=4,visible='0',status='0' WHERE menu_id=2276;
UPDATE sys_menu SET menu_name='项目列表',order_num=1,path='projectList',route_name='ProjectList',visible='0',status='0' WHERE menu_id=2285;
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '项目分类',2276,2,'projectCategory','projectManagement/projectCategory/index','ProjectCategory',1,0,'C','0','0','project:category:list','tree','admin',NOW(),'多层树形项目分类维护'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id=2276 AND path='projectCategory');
UPDATE sys_menu SET order_num=5,visible='0',status='0' WHERE menu_id=2015;
UPDATE sys_menu SET menu_name='采购信息',order_num=1,route_name='ProcurementLedger',visible='0',status='0' WHERE menu_id=2016;
-- æ”¿åºœæ”¿ç­–文件。
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '政府政策文件',0,6,'governmentPolicy',NULL,'GovernmentPolicy',1,0,'M','0','0','','documentation','admin',NOW(),'政策依据资料库'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id=0 AND path='governmentPolicy');
SET @policy_root=(SELECT menu_id FROM sys_menu WHERE parent_id=0 AND path='governmentPolicy' ORDER BY menu_id LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '政策文件维护',@policy_root,1,'policyFile','governmentPolicy/policyFile/index','PolicyFile',1,0,'C','0','0','policy:file:list','documentation','admin',NOW(),'政策名称、发布时间、链接、文件和维护人'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE parent_id=@policy_root AND path='policyFile');
-- å®¢æˆ·è´¦å·å…¥å£ä¿ç•™ï¼Œä½†åªåˆ†é…ç»™å®¢æˆ·è§’色;不提供自行注册。
UPDATE sys_menu SET menu_name='客户资料',order_num=90,visible='0',status='0' WHERE menu_id=2357;
UPDATE sys_menu SET menu_name='财务报表上传',parent_id=2357,order_num=1,visible='0',status='0' WHERE menu_id=2358;
UPDATE sys_menu SET status='1',visible='1' WHERE menu_id=2359;
-- é¡¹ç›®æŒ‰é’®æƒé™ã€‚
SET @project_list_menu=2285;
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '模块维护',@project_list_menu,21,'#',NULL,'',1,0,'F','0','0','project:module:edit','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:module:edit');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '模块删除',@project_list_menu,22,'#',NULL,'',1,0,'F','0','0','project:module:remove','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:module:remove');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '新增跟进',@project_list_menu,23,'#',NULL,'',1,0,'F','0','0','project:follow:add','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:follow:add');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '删除跟进',@project_list_menu,24,'#',NULL,'',1,0,'F','0','0','project:follow:remove','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:follow:remove');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '进度汇报',@project_list_menu,25,'#',NULL,'',1,0,'F','0','0','project:report:add','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:report:add');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '项目资料维护',@project_list_menu,26,'#',NULL,'',1,0,'F','0','0','project:material:add','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:material:add');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '删除进度汇报',@project_list_menu,27,'#',NULL,'',1,0,'F','0','0','project:report:remove','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:report:remove');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '删除项目资料',@project_list_menu,28,'#',NULL,'',1,0,'F','0','0','project:material:remove','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:material:remove');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '维护项目阶段',@project_list_menu,29,'#',NULL,'',1,0,'F','0','0','project:phase:edit','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:phase:edit');
SET @project_category_menu=(SELECT menu_id FROM sys_menu WHERE parent_id=2276 AND path='projectCategory' ORDER BY menu_id LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '维护项目分类',@project_category_menu,1,'#',NULL,'',1,0,'F','0','0','project:category:edit','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:category:edit');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '删除项目分类',@project_category_menu,2,'#',NULL,'',1,0,'F','0','0','project:category:remove','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='project:category:remove');
SET @policy_file_menu=(SELECT menu_id FROM sys_menu WHERE parent_id=@policy_root AND path='policyFile' ORDER BY menu_id LIMIT 1);
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '维护政策文件',@policy_file_menu,1,'#',NULL,'',1,0,'F','0','0','policy:file:edit','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='policy:file:edit');
INSERT INTO sys_menu(menu_name,parent_id,order_num,path,component,route_name,is_frame,is_cache,menu_type,visible,status,perms,icon,create_by,create_time,remark)
SELECT '删除政策文件',@policy_file_menu,2,'#',NULL,'',1,0,'F','0','0','policy:file:remove','#','admin',NOW(),''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms='policy:file:remove');
-- è¶…级管理员角色补齐新增菜单;其他角色仍在“角色管理”中按需配置。
INSERT IGNORE INTO sys_role_menu(role_id,menu_id)
SELECT 1,menu_id FROM sys_menu WHERE status='0' AND (
  menu_id IN (2053,2010,2015,2276,2357,@business_root,@policy_root)
  OR parent_id IN (2053,2010,2015,2276,2357,@business_root,@policy_root,2285)
);
SET FOREIGN_KEY_CHECKS = 1;
SELECT 'five_stage_nodes' AS item,COUNT(*) AS count_value FROM project_management_plan_node
 WHERE project_management_plan_id=@five_stage_plan_id AND is_delete=0
UNION ALL SELECT 'project_workflow_tables',COUNT(*) FROM information_schema.TABLES
 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (
  'project_management_category','project_management_module','project_management_module_member',
  'project_management_follow_up','project_management_progress_report','project_management_material',
  'project_management_phase','government_policy_file'
 )
UNION ALL SELECT 'enabled_root_menus',COUNT(*) FROM sys_menu WHERE parent_id=0 AND status='0';
sql/Êý¾Ý¿âÉý¼¶ËµÃ÷.md
@@ -8,3 +8,14 @@
6. ç®¡ç†å‘˜åœ¨çŽ°æœ‰â€œç”¨æˆ·ç®¡ç†â€åˆ›å»ºå®¢æˆ·è´¦å·ï¼Œèµ‹äºˆâ€œå®¢æˆ·â€è§’è‰²ï¼›å†åœ¨çŽ°æœ‰â€œå®¢æˆ·æ¡£æ¡ˆâ€ä¸­æŠŠè¯¥ç”¨æˆ·ç»‘å®šåˆ°å¯¹åº”å®¢æˆ·ã€‚å†…éƒ¨å‘˜å·¥èœå•æƒé™ä»åœ¨çŽ°æœ‰è§’è‰²ç®¡ç†ä¸­é…ç½®ã€‚
回滚方式:停止应用,创建一个空库后导入上述全量备份,确认无误再切换连接。不要在有新业务数据产生后直接覆盖生产库。
## 2026-11-03 é¡¹ç›®ä¸šåŠ¡æµç¨‹å‡çº§
1. æœ¬æ¬¡å‡çº§å‰å…¨é‡å¤‡ä»½ï¼š`D:\newcode\product-inventory-management\.local-backups\product-inventory-management-xmgl-before-workflow-upgrade-20261103.sql`(约 83.6 MB)。
2. åœ¨ `product-inventory-management-xmgl` æ•°æ®åº“执行 `20261103_project_workflow_upgrade.sql`。
3. è„šæœ¬ä¼šè°ƒæ•´åŠ¨æ€èœå•ä¸ºè¡Œæ”¿ç®¡ç†ã€å®¢æˆ·ç®¡ç†ã€ä¸šåŠ¡ç®¡ç†ã€é¡¹ç›®ç®¡ç†ã€é‡‡è´­ç®¡ç†ã€æ”¿åºœæ”¿ç­–æ–‡ä»¶å’Œç³»ç»Ÿç®¡ç†ï¼Œå¹¶ä¿ç•™å®¢æˆ·èµ„æ–™å…¥å£ï¼›è¡Œæ”¿ç®¡ç†ä½¿ç”¨åŽŸæœ‰â€œå®¡æ‰¹ç®¡ç†â€ç»´æŠ¤å®¡æ‰¹äººï¼Œè¯·å‡å’Œå‡ºå·®ä½¿ç”¨åŽŸå®¡æ‰¹ä½“ç³»ï¼Œé‡å¤çš„â€œååŒå®¡æ‰¹â€æ±‡æ€»å…¥å£é»˜è®¤åœç”¨ã€‚
4. è„šæœ¬æ–°å¢žé¡¹ç›®åˆ†ç±»ã€æ¨¡å—/执行人、跟进、进度汇报、项目资料、五阶段流程、政策文件等 8 å¼ ä¸šåŠ¡è¡¨ï¼Œå¹¶ä¸ºé¡¹ç›®è¡¥å……è´Ÿè´£äººã€ç®¡ç†å‘˜ã€è·Ÿè¿›å‘¨æœŸã€å½“å‰é˜¶æ®µå’Œæ¥æºä¸šåŠ¡å­—æ®µã€‚
5. æ–°é¡¹ç›®ä¸å†ä½¿ç”¨æ—§è®¡åˆ’模板阶段,独立按:立项、开发、申报、申报结果、验收顺序维护对应信息和文件;旧阶段数据只保留兼容,不在新页面使用。
6. è„šæœ¬æœ«å°¾æ ¡éªŒç»“æžœåº”ä¸ºï¼š`project_workflow_tables = 8`、`enabled_root_menus = 8`。
如需回滚本次升级,请停止前后端后,把上述 `before-workflow-upgrade` å¤‡ä»½å¯¼å…¥æ–°çš„空数据库,核对数据后再切换连接;不要直接覆盖仍在使用的数据库。
src/api/projectManagement/project.js
@@ -31,11 +31,10 @@
  })
}
export function delProject(ids) {
export function delProject(id) {
  return request({
    url: '/projectManagement/info/remove',
    method: 'delete',
    data: ids
    url: `/projectManagement/info/delete/${id}`,
    method: 'post'
  })
}
src/api/projectManagement/workflow.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
import request from '@/utils/request'
export const getProjectWorkflow = projectId => request({
  url: `/projectManagement/workflow/${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 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' })
export const saveProjectProgressReport = data => request({ url: '/projectManagement/workflow/report', method: 'post', data })
export const deleteProjectProgressReport = id => request({ url: `/projectManagement/workflow/report/${id}`, method: 'delete' })
export const saveProjectMaterial = data => request({ url: '/projectManagement/workflow/material', method: 'post', data })
export const deleteProjectMaterial = id => request({ url: `/projectManagement/workflow/material/${id}`, method: 'delete' })
export const saveProjectPhase = data => request({ url: '/projectManagement/workflow/phase', method: 'post', data })
export const listProjectCategories = () => request({ url: '/projectManagement/workflow/category/list', method: 'get' })
export const saveProjectCategory = data => request({ url: '/projectManagement/workflow/category', method: 'post', data })
export const deleteProjectCategory = id => request({ url: `/projectManagement/workflow/category/${id}`, method: 'delete' })
export const listGovernmentPolicies = params => request({ url: '/projectManagement/workflow/policy/list', method: 'get', params })
export const saveGovernmentPolicy = data => request({ url: '/projectManagement/workflow/policy', method: 'post', data })
export const deleteGovernmentPolicy = id => request({ url: `/projectManagement/workflow/policy/${id}`, method: 'delete' })
src/views/collaborativeApproval/approvalProcess/index.vue
@@ -1,7 +1,7 @@
<template>
  <div class="app-container">
    <!-- å®¡æ‰¹ç±»åž‹åˆ‡æ¢ - ç´§å‡‘标签式 -->
    <div class="type-tabs">
    <div v-if="!props.approveType" class="type-tabs">
      <div v-for="type in approveTypes"
           :key="type.value"
           class="type-tab"
@@ -191,9 +191,15 @@
  const userStore = useUserStore();
  const route = useRoute();
  const props = defineProps({
    approveType: {
      type: Number,
      default: null,
    },
  });
  // å½“前选中的标签页,默认为请假管理
  const activeTab = ref("2");
  const activeTab = ref(String(props.approveType || 2));
  // å„类型数量统计
  const typeCounts = ref({});
@@ -560,6 +566,8 @@
    if (approveType) {
      // è®¾ç½®æ ‡ç­¾é¡µï¼ˆapproveType å¯¹åº” activeTab çš„ name)
      activeTab.value = String(approveType);
    } else if (props.approveType) {
      activeTab.value = String(props.approveType);
    }
    if (approveId) {
src/views/governmentPolicy/policyFile/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
<template>
  <div class="app-container">
    <el-card shadow="never">
      <div class="toolbar">
        <el-input v-model="keyword" clearable placeholder="请输入政策名称" style="width: 280px" @keyup.enter="loadData" />
        <el-button type="primary" @click="loadData">查询</el-button>
        <el-button @click="keyword = ''; loadData()">重置</el-button>
        <div class="toolbar-spacer" />
        <el-button v-hasPermi="['policy:file:edit']" type="primary" @click="openDialog()">新增政策文件</el-button>
      </div>
      <el-table v-loading="loading" :data="rows" border>
        <el-table-column prop="policyName" label="政策名称" min-width="240" show-overflow-tooltip />
        <el-table-column prop="publishDate" label="发布时间" width="130" />
        <el-table-column label="原文链接" min-width="200">
          <template #default="{ row }">
            <el-link v-if="row.policyUrl" :href="row.policyUrl" target="_blank" type="primary">打开链接</el-link>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column label="文件" min-width="220">
          <template #default="{ row }">
            <div v-if="row.attachmentList?.length" class="file-list">
              <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>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="maintainerName" label="维护人" width="130" />
        <el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
        <el-table-column label="操作" width="150" fixed="right" align="center">
          <template #default="{ row }">
            <el-button v-hasPermi="['policy:file:edit']" link type="primary" @click="openDialog(row)">修改</el-button>
            <el-button v-hasPermi="['policy:file:remove']" link type="danger" @click="removeRow(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
    <el-dialog v-model="dialogVisible" :title="form.id ? '修改政策文件' : '新增政策文件'" width="680px" destroy-on-close>
      <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
        <el-form-item label="政策名称" prop="policyName">
          <el-input v-model="form.policyName" maxlength="255" show-word-limit />
        </el-form-item>
        <el-form-item label="发布时间" prop="publishDate">
          <el-date-picker v-model="form.publishDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
        </el-form-item>
        <el-form-item label="原文链接">
          <el-input v-model="form.policyUrl" placeholder="https://" />
        </el-form-item>
        <el-form-item label="文件">
          <AttachmentUploadFile v-model:file-list="fileList" :limit="10" />
        </el-form-item>
        <el-form-item label="维护人">
          <el-input v-model="form.maintainerName" placeholder="不填则取当前登录人" />
        </el-form-item>
        <el-form-item label="备注">
          <el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup name="PolicyFile">
import { getCurrentInstance, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import { deleteGovernmentPolicy, listGovernmentPolicies, saveGovernmentPolicy } from '@/api/projectManagement/workflow'
const { proxy } = getCurrentInstance()
const loading = ref(false)
const saving = ref(false)
const keyword = ref('')
const rows = ref([])
const dialogVisible = ref(false)
const formRef = ref()
const fileList = ref([])
const form = reactive({ id: null, policyName: '', publishDate: '', policyUrl: '', maintainerName: '', remark: '' })
const rules = {
  policyName: [{ required: true, message: '请输入政策名称', trigger: 'blur' }],
  publishDate: [{ required: true, message: '请选择发布时间', trigger: 'change' }]
}
async function loadData() {
  loading.value = true
  try {
    const res = await listGovernmentPolicies({ keyword: keyword.value })
    rows.value = Array.isArray(res?.data) ? res.data : []
  } finally {
    loading.value = false
  }
}
function openDialog(row = null) {
  Object.assign(form, {
    id: row?.id || null,
    policyName: row?.policyName || '',
    publishDate: row?.publishDate || '',
    policyUrl: row?.policyUrl || '',
    maintainerName: row?.maintainerName || '',
    remark: row?.remark || ''
  })
  fileList.value = (row?.attachmentList || []).map(file => ({ ...file }))
  dialogVisible.value = true
}
function normalizeFiles() {
  return fileList.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)
}
async function submit() {
  await formRef.value?.validate()
  saving.value = true
  try {
    await saveGovernmentPolicy({ ...form, attachments: normalizeFiles() })
    ElMessage.success('保存成功')
    dialogVisible.value = false
    await loadData()
  } finally {
    saving.value = false
  }
}
async function removeRow(row) {
  await ElMessageBox.confirm(`确认删除“${row.policyName}”吗?`, '提示', { type: 'warning' })
  await deleteGovernmentPolicy(row.id)
  ElMessage.success('删除成功')
  await loadData()
}
function download(file) {
  if (!file?.url) return ElMessage.warning('附件暂无下载地址')
  proxy.$download.byUrl(file.url, file.name || file.fileName)
}
onMounted(loadData)
</script>
<style scoped>
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.toolbar-spacer { flex: 1; }
.file-list { display: flex; flex-wrap: wrap; gap: 4px 8px; }
</style>
src/views/projectManagement/Management/components/ProjectExecutionPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,387 @@
<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-descriptions :column="3" border>
          <el-descriptions-item label="业务编号">{{ data.sourceBusiness.id }}</el-descriptions-item>
          <el-descriptions-item label="项目名称">{{ data.sourceBusiness.projectName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="客户名称">{{ data.sourceBusiness.customerName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="业务员">{{ data.sourceBusiness.salesman || '-' }}</el-descriptions-item>
          <el-descriptions-item label="录入日期">{{ data.sourceBusiness.entryDate || '-' }}</el-descriptions-item>
          <el-descriptions-item label="审批状态">{{ ({ 0:'草稿',1:'待审批',2:'已通过',3:'已退回' })[data.sourceBusiness.approvalStatus] || '-' }}</el-descriptions-item>
          <el-descriptions-item label="业务备注" :span="3">{{ data.sourceBusiness.remarks || '-' }}</el-descriptions-item>
          <el-descriptions-item label="业务附件" :span="3">
            <template v-if="data.sourceBusiness.attachmentList?.length">
              <el-button v-for="(file,index) in data.sourceBusiness.attachmentList" :key="index" link type="primary" @click="download(file)">{{ file.name || file.fileName || `附件${index+1}` }}</el-button>
            </template>
            <span v-else>{{ data.sourceBusiness.attachmentMaterials || '无' }}</span>
          </el-descriptions-item>
        </el-descriptions>
      </el-tab-pane>
      <el-tab-pane label="模块设置" name="modules">
        <div class="tab-actions">
          <span class="hint">模块占项目工作量合计:{{ totalModuleWeight }}%</span>
          <el-button v-hasPermi="['project:module:edit']" type="primary" @click="openModule()">新增模块</el-button>
        </div>
        <el-table :data="data.modules" border>
          <el-table-column prop="moduleName" label="模块名称" min-width="160" />
          <el-table-column prop="plannedStartDate" label="预计开始" width="120" />
          <el-table-column prop="summary" label="模块概要" min-width="220" show-overflow-tooltip />
          <el-table-column prop="projectWeight" label="项目工作量占比" width="150" align="center">
            <template #default="{ row }">{{ row.projectWeight }}%</template>
          </el-table-column>
          <el-table-column prop="currentProgress" label="当前进度" width="120" align="center">
            <template #default="{ row }"><el-progress :percentage="Number(row.currentProgress || 0)" /></template>
          </el-table-column>
          <el-table-column label="模块执行人" min-width="230">
            <template #default="{ row }">
              <el-tag v-for="member in row.members || []" :key="member.id" class="member-tag">
                {{ member.userName }}
              </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">
        <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>
        </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>
      </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>
        </div>
      </el-tab-pane>
      <el-tab-pane label="项目资料" name="materials">
        <div class="tab-actions">
          <span class="hint">项目整体资料库,与业务附件和单次汇报附件分开保存</span>
          <el-button 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" />
          <el-table-column prop="materialType" label="资料类型" width="130" />
          <el-table-column prop="createUserName" label="维护人" width="130" />
          <el-table-column prop="createTime" label="上传时间" width="180" />
          <el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
          <el-table-column label="文件" min-width="200">
            <template #default="{ row }">
              <el-button v-for="(file, index) in row.attachmentList || []" :key="file.id || index" link type="primary" @click="download(file)">
                {{ file.name || `附件${index + 1}` }}
              </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>
      </el-tab-pane>
    </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>
            </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-dialog>
    <el-dialog v-model="followVisible" title="填写项目跟进" 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>
        <el-form-item label="附件"><AttachmentUploadFile v-model:file-list="followFiles" :limit="10" /></el-form-item>
      </el-form>
      <template #footer><el-button @click="followVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitFollow">提交</el-button></template>
    </el-dialog>
    <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="汇报人员比重" required>
          <div class="member-editor">
            <div v-for="(member,index) in reportForm.reporterShares" :key="index" class="reporter-row">
              <el-select v-model="member.userId" filterable placeholder="请选择人员" @change="id => syncMemberName(member,id)"><el-option v-for="user in moduleReporterOptions" :key="user.value" :label="user.label" :value="user.value" /></el-select>
              <el-input-number v-model="member.moduleShare" :min="0" :max="reporterShareLimit(index)" :precision="2" />
              <span>%</span>
              <el-button link type="danger" @click="reportForm.reporterShares.splice(index,1)">删除</el-button>
            </div>
            <el-button link type="primary" @click="reportForm.reporterShares.push({userId:null,userName:'',moduleShare:0})">+ æ·»åŠ äººå‘˜</el-button>
            <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="备注"><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>
    </el-dialog>
    <el-dialog v-model="materialVisible" title="上传项目资料" width="650px" destroy-on-close>
      <el-form ref="materialFormRef" :model="materialForm" :rules="materialRules" label-width="90px">
        <el-form-item label="资料名称" prop="materialName"><el-input v-model="materialForm.materialName" /></el-form-item>
        <el-form-item label="资料类型"><el-select v-model="materialForm.materialType" allow-create filterable clearable style="width: 100%"><el-option v-for="type in ['立项资料','开发资料','申报资料','结果资料','验收资料','其他']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
        <el-form-item label="文件" required><AttachmentUploadFile v-model:file-list="materialFiles" :limit="20" /></el-form-item>
        <el-form-item label="备注"><el-input v-model="materialForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
      </el-form>
      <template #footer><el-button @click="materialVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitMaterial">保存</el-button></template>
    </el-dialog>
  </div>
</template>
<script setup>
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import * as echarts from 'echarts'
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import { userListAll } from '@/api/publicApi'
import {
  deleteProjectFollowUp, deleteProjectMaterial, deleteProjectModule, deleteProjectProgressReport,
  getProjectWorkflow, saveProjectFollowUp, saveProjectMaterial, saveProjectModule, saveProjectProgressReport
} from '@/api/projectManagement/workflow'
import useUserStore from '@/store/modules/user'
const props = defineProps({ projectId: [String, Number], projectInfo: { type: Object, default: () => ({}) } })
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const loading = ref(false)
const saving = ref(false)
const activeTab = ref('modules')
const userOptions = ref([])
const data = reactive({ modules: [], followUps: [], reports: [], materials: [], sourceBusiness: {}, memberStatistics: [], followWindow: {} })
const chartRef = ref()
let chart
const today = () => new Date().toISOString().slice(0, 10)
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 =>
  (module.members || []).some(member => String(member.userId) === String(userStore.id))
))
const selectedReportModule = computed(() => data.modules.find(module => String(module.id) === String(reportForm.moduleId)))
const moduleReporterOptions = computed(() => {
  const members = selectedReportModule.value?.members || []
  return members.map(member => ({
    value: member.userId,
    label: member.userName || userOptions.value.find(user => String(user.value) === String(member.userId))?.label || String(member.userId)
  }))
})
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 followVisible = ref(false)
const followFormRef = ref()
const followForm = reactive({ 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 reportFiles = ref([])
const reportRules = { moduleId: [{ required: true, message: '请选择项目模块', trigger: 'change' }], reportDate: [{ required: true, message: '请选择汇报时间', trigger: 'change' }] }
const materialVisible = ref(false)
const materialFormRef = ref()
const materialForm = reactive({ materialName: '', materialType: '', remark: '' })
const materialFiles = ref([])
const materialRules = { materialName: [{ required: true, message: '请输入资料名称', trigger: 'blur' }] }
async function loadUsers() {
  const res = await userListAll()
  const list = res?.data || res?.rows || res || []
  userOptions.value = (Array.isArray(list) ? list : []).map(user => ({ label: user.nickName || user.userName || user.name, value: user.userId || user.id }))
}
async function loadData() {
  if (!props.projectId) return
  loading.value = true
  try {
    const res = await getProjectWorkflow(props.projectId)
    Object.assign(data, res?.data || {})
    await nextTick()
    if (activeTab.value === 'reports') renderChart()
  } 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: '' })
  moduleVisible.value = true
}
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('模块执行人不能重复')
  saving.value = true
  try {
    await saveProjectModule({ ...moduleForm, projectId: props.projectId })
    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() { 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() {
  await followFormRef.value?.validate(); saving.value = true
  try { await saveProjectFollowUp({ ...followForm, projectId: props.projectId, attachments: normalizeFiles(followFiles.value) }); ElMessage.success('跟进已保存'); followVisible.value = false; await loadData() }
  finally { saving.value = false }
}
function openReport() {
  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: '' })
  reportFiles.value = []; reportVisible.value = true
}
function changeReportModule(moduleId) {
  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.reporterShares = userId ? [{ userId, userName, moduleShare: 100 }] : []
}
function reporterShareLimit(index) {
  const usedByOthers = reportForm.reporterShares.reduce((sum, member, memberIndex) =>
    memberIndex === index ? sum : sum + Number(member.moduleShare || 0), 0)
  return Math.max(0, Number((100 - usedByOthers).toFixed(2)))
}
async function submitReport() {
  await reportFormRef.value?.validate()
  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%')
  saving.value = true
  try { await saveProjectProgressReport({ ...reportForm, projectId: props.projectId, attachments: normalizeFiles(reportFiles.value) }); ElMessage.success('汇报已提交'); reportVisible.value = false; await loadData() }
  finally { saving.value = false }
}
function openMaterial() { Object.assign(materialForm, { materialName: '', materialType: '', remark: '' }); materialFiles.value = []; materialVisible.value = true }
async function submitMaterial() {
  await materialFormRef.value?.validate()
  if (!materialFiles.value.length) return ElMessage.warning('请上传至少一个文件')
  saving.value = true
  try { await saveProjectMaterial({ ...materialForm, projectId: props.projectId, attachments: normalizeFiles(materialFiles.value) }); ElMessage.success('资料已保存'); materialVisible.value = false; await loadData() }
  finally { saving.value = false }
}
async function removeRecord(type, row) {
  await ElMessageBox.confirm('确认删除这条记录吗?', '提示', { type: 'warning' })
  const api = { follow: deleteProjectFollowUp, report: deleteProjectProgressReport, material: deleteProjectMaterial }[type]
  await api(row.id); ElMessage.success('删除成功'); await loadData()
}
function download(file) { if (!file?.url) return ElMessage.warning('附件暂无下载地址'); proxy.$download.byUrl(file.url, file.name || file.fileName) }
function renderChart() {
  if (!chartRef.value) return
  chart ||= echarts.init(chartRef.value)
  const source = data.memberStatistics || []
  chart.setOption({
    title: { text: '成员已完成工作量比重', 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}' } }]
  })
}
function handleTabChange(name) { if (name === 'reports') nextTick(renderChart) }
function resizeChart() { chart?.resize() }
onMounted(async () => { await Promise.all([loadUsers(), loadData()]); window.addEventListener('resize', resizeChart) })
onBeforeUnmount(() => { window.removeEventListener('resize', resizeChart); chart?.dispose() })
defineExpose({ refresh: loadData })
</script>
<style scoped>
.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; }
.member-tag { margin: 2px 6px 2px 0; }
.report-layout { display: grid; grid-template-columns: minmax(300px, 34%) 1fr; gap: 14px; }
.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; }
.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; }
@media (max-width: 1200px) { .report-layout { grid-template-columns: 1fr; } }
</style>
src/views/projectManagement/Management/components/formDia.vue
@@ -1,1396 +1,153 @@
<template>
  <el-dialog
    v-model="dialogVisible"
    :title="dialogTitle"
    width="95%"
    top="5vh"
    destroy-on-close
    @close="closeDialog"
  >
    <el-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-position="top"
      label-width="120px"
      :disabled="isView"
    >
      <div class="section">
        <div class="section-header" @click="toggleSection('base')">
          <div class="section-title">
            <span class="section-bar" />
            <span>基础资料</span>
          </div>
          <el-icon class="toggle-icon">
            <ArrowDown v-if="sectionCollapsed.base" />
            <ArrowUp v-else />
          </el-icon>
        </div>
        <div v-show="!sectionCollapsed.base" class="section-body">
          <el-row :gutter="20">
            <el-col :span="6">
              <el-form-item label="单据编号" prop="billNo">
                <el-input v-model="form.billNo" placeholder="系统生成" disabled />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="项目名称" prop="projectName">
                <el-input v-model="form.projectName" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="客户名称" prop="customerName">
                <el-select
                  v-model="form.customerName"
                  placeholder="请选择客户"
                  clearable
                  filterable
                  :disabled="isFromContract"
                  style="width: 100%"
                  @change="handleCustomerChange"
                >
                  <el-option
                    v-for="item in customerOptions"
                    :key="item.id ?? item.customerName"
                    :label="item.customerName"
                    :value="item.customerName"
                  />
                </el-select>
              </el-form-item>
            </el-col>
  <el-dialog v-model="visible" :title="dialogTitle" :width="startProject ? '1100px' : '900px'" destroy-on-close>
    <el-alert v-if="isBusinessProject" title="该项目由业务审批生成,保存后可在项目详情查看来源业务及其附件。" 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-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-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-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-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="20">
            <el-col :span="6">
              <el-form-item label="项目分类" prop="projectClassification">
                <el-select
                  v-model="form.projectClassification"
                  placeholder="请选择"
                  clearable
                  filterable
                  allow-create
                  default-first-option
                  style="width: 100%"
                >
                  <el-option
                    v-for="c in projectClassificationOptions"
                    :key="c"
                    :label="c"
                    :value="c"
                  />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="项目来源" prop="projectSource">
                <el-select
                  v-model="form.projectSource"
                  placeholder="请选择"
                  clearable
                  filterable
                  allow-create
                  default-first-option
                  style="width: 100%"
                >
                  <el-option label="手动添加" value="手动添加" />
                  <el-option label="业务审批" value="业务审批" />
                  <el-option label="其他" value="其他" />
                </el-select>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row v-if="isFromContract" :gutter="20">
            <el-col :span="8">
              <el-form-item label="合同金额(元)">
                <el-input :model-value="form.projectAmount" disabled />
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="合同日期">
                <el-input :model-value="form.orderDate" disabled />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="10" >
              <el-col :span="24">
                <el-form-item label="附件" prop="attachmentIds">
                  <AttachmentUploadFile
                    v-model:file-list="fileList"
                    :disabled="isView"
                    :limit="10"
                  />
                </el-form-item>
            </el-col>
          </el-row>
          <el-row v-if="false" :gutter="20">
            <el-col :span="24">
              <el-form-item label="备注" prop="remark">
                <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入" maxlength="100" show-word-limit />
              </el-form-item>
            </el-col>
          <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>
      <div v-if="false" class="section">
        <div class="section-header" @click="toggleSection('team')">
          <div class="section-title">
            <span class="section-bar" />
            <span>项目团队</span>
          </div>
          <div class="section-actions" @click.stop>
            <el-button v-if="!isView" type="primary" :icon="Plus" @click="addTeamRow">新增行</el-button>
            <el-icon class="toggle-icon" @click="toggleSection('team')">
              <ArrowDown v-if="sectionCollapsed.team" />
              <ArrowUp v-else />
            </el-icon>
          </div>
        </div>
        <div v-show="!sectionCollapsed.team" class="section-body">
          <PIMTable
            :column="teamColumns"
            :tableData="form.teamList"
            :tableLoading="false"
            :isSelection="false"
            :isShowPagination="false"
            height="220"
          >
            <template #memberId="{ row }">
              <el-select v-model="row.memberId" placeholder="请选择" filterable clearable style="width: 100%" :disabled="isView">
                <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
              </el-select>
            </template>
            <template #roleId="{ row }">
              <el-select v-model="row.roleId" placeholder="请选择" clearable style="width: 100%" :disabled="isView">
                <el-option v-for="r in roleOptions" :key="r.value" :label="r.label" :value="r.value" />
              </el-select>
            </template>
            <template #enterDate="{ row }">
              <el-date-picker
                v-model="row.enterDate"
                type="date"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                placeholder="请选择"
                style="width: 100%"
                clearable
                :disabled="isView"
              />
            </template>
            <template #leaveDate="{ row }">
              <el-date-picker
                v-model="row.leaveDate"
                type="date"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                placeholder="请选择"
                style="width: 100%"
                clearable
                :disabled="isView"
              />
            </template>
            <template #phone="{ row }">
              <el-input v-model="row.phone" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #teamRemark="{ row }">
              <el-input v-model="row.remark" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #teamAction="{ row, index }">
              <el-button v-if="!isView" link type="danger" :icon="Delete" @click="removeTeamRow(index)">删除</el-button>
              <span v-else>—</span>
            </template>
          </PIMTable>
        </div>
      </div>
      <!-- <div class="section">
        <div class="section-header" @click="toggleSection('phase')">
          <div class="section-title">
            <span class="section-bar" />
            <span>项目阶段</span>
          </div>
          <div class="section-actions" @click.stop>
            <el-button v-if="!isView" type="primary" :icon="Plus" @click="addPhaseRow">新增行</el-button>
            <el-icon class="toggle-icon" @click="toggleSection('phase')">
              <ArrowDown v-if="sectionCollapsed.phase" />
              <ArrowUp v-else />
            </el-icon>
          </div>
        </div>
        <div v-show="!sectionCollapsed.phase" class="section-body">
          <PIMTable
            :column="phaseColumns"
            :tableData="form.phaseList"
            :tableLoading="false"
            :isSelection="false"
            :isShowPagination="false"
            height="240"
          >
            <template #phaseName="{ row }">
              <el-input v-model="row.phaseName" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #phaseDesc="{ row }">
              <el-input v-model="row.description" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #ownerId="{ row }">
              <el-select v-model="row.ownerId" placeholder="请选择" filterable clearable style="width: 100%" :disabled="isView">
                <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
              </el-select>
            </template>
            <template #planDays="{ row }">
              <el-input-number v-model="row.planDays" :min="0" controls-position="right" style="width: 100%" :disabled="isView" />
            </template>
            <template #planStart="{ row }">
              <el-date-picker
                v-model="row.planStartDate"
                type="date"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                placeholder="请选择"
                style="width: 100%"
                clearable
                :disabled="isView"
              />
            </template>
            <template #planEnd="{ row }">
              <el-date-picker
                v-model="row.planEndDate"
                type="date"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                placeholder="请选择"
                style="width: 100%"
                clearable
                :disabled="isView"
              />
            </template>
            <template #progress="{ row }">
              <el-input-number v-model="row.progress" :min="0" :max="100" controls-position="right" style="width: 100%" :disabled="isView" />
            </template>
            <template #actualStart="{ row }">
              <el-date-picker
                v-model="row.actualStartDate"
                type="date"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                placeholder="请选择"
                style="width: 100%"
                clearable
                :disabled="isView"
              />
            </template>
            <template #actualEnd="{ row }">
              <el-date-picker
                v-model="row.actualEndDate"
                type="date"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                placeholder="请选择"
                style="width: 100%"
                clearable
                :disabled="isView"
              />
            </template>
            <template #overdueDays="{ row }">
              <el-input-number v-model="row.overdueDays" :min="0" controls-position="right" style="width: 100%" :disabled="isView" />
            </template>
            <template #completion="{ row }">
              <el-input v-model="row.completionRemark" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #phaseAction="{ row, index }">
              <el-button v-if="!isView" link type="danger" :icon="Delete" @click="removePhaseRow(index)">删除</el-button>
              <span v-else>—</span>
            </template>
          </PIMTable>
        </div>
      </div> -->
      <div v-if="false" class="section">
        <div class="section-header" @click="toggleSection('address')">
          <div class="section-title">
            <span class="section-bar" />
            <span>收货地址</span>
          </div>
          <div class="section-actions" @click.stop>
            <el-button v-if="!isView" type="primary" :icon="Plus" @click="addAddressRow">新增行</el-button>
            <el-icon class="toggle-icon" @click="toggleSection('address')">
              <ArrowDown v-if="sectionCollapsed.address" />
              <ArrowUp v-else />
            </el-icon>
          </div>
        </div>
        <div v-show="!sectionCollapsed.address" class="section-body">
          <PIMTable
            :column="addressColumns"
            :tableData="form.addressList"
            :tableLoading="false"
            :isSelection="false"
            :isShowPagination="false"
            height="200"
          >
            <template #receiver="{ row }">
              <el-input v-model="row.receiver" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #receiverPhone="{ row }">
              <el-input v-model="row.phone" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #receiverAddress="{ row }">
              <el-input v-model="row.address" placeholder="请输入" clearable :disabled="isView" />
            </template>
            <template #addressAction="{ row, index }">
              <el-button v-if="!isView" link type="danger" :icon="Delete" @click="removeAddressRow(index)">删除</el-button>
              <span v-else>—</span>
            </template>
          </PIMTable>
        </div>
      </div>
      <div v-if="false" class="section">
        <div class="section-header" @click="toggleSection('contact')">
          <div class="section-title">
            <span class="section-bar" />
            <span>联系信息</span>
          </div>
          <el-icon class="toggle-icon">
            <ArrowDown v-if="sectionCollapsed.contact" />
            <ArrowUp v-else />
          </el-icon>
        </div>
        <div v-show="!sectionCollapsed.contact" class="section-body">
          <el-row :gutter="20">
            <el-col :span="6">
              <el-form-item label="联系人姓名" prop="contactName">
                <el-input v-model="form.contactName" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="性别" prop="contactGender">
                <el-select v-model="form.contactGender" placeholder="请选择" clearable style="width: 100%">
                  <el-option label="男" value="1" />
                  <el-option label="女" value="2" />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="生日" prop="contactBirthday">
                <el-date-picker
                  v-model="form.contactBirthday"
                  type="date"
                  value-format="YYYY-MM-DD"
                  format="YYYY-MM-DD"
                  placeholder="请选择"
                  style="width: 100%"
                  clearable
                />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="邮箱" prop="contactEmail">
                <el-input v-model="form.contactEmail" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="6">
              <el-form-item label="部门" prop="contactDept">
                <el-input v-model="form.contactDept" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="职务" prop="contactJob">
                <el-input v-model="form.contactJob" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="手机号码" prop="contactMobile">
                <el-input v-model="form.contactMobile" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="微信号码" prop="contactWechat">
                <el-input v-model="form.contactWechat" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="6">
              <el-form-item label="QQ" prop="contactQq">
                <el-input v-model="form.contactQq" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="6">
              <el-form-item label="企业微信" prop="contactWorkWechat">
                <el-input v-model="form.contactWorkWechat" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="地址" prop="contactAddress">
                <el-input v-model="form.contactAddress" placeholder="请输入" clearable />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="24">
              <el-form-item label="备注" prop="contactRemark">
                <el-input v-model="form.contactRemark" type="textarea" :rows="2" placeholder="请输入" maxlength="200" show-word-limit />
              </el-form-item>
            </el-col>
          </el-row>
        </div>
      </div>
        <div class="module-actions"><span>当前模块工作量合计:{{ startModuleWeight }}%</span><el-button type="primary" plain @click="addStartModule">+ æ·»åŠ æ¨¡å—</el-button></div>
      </template>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button v-if="!isView" type="primary" @click="submitForm">确认</el-button>
        <el-button @click="closeDialog">{{ isView ? '关闭' : '取消' }}</el-button>
      </div>
    </template>
    <template #footer><el-button @click="visible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">{{ personnelOnly ? '保存执行人' : startProject ? '保存并开始项目' : '保存' }}</el-button></template>
  </el-dialog>
  <FormDialog
    v-model="productFormVisible"
    :title="productOperationType === 'add' ? '新增产品' : '编辑产品'"
    :width="'40%'"
    :operation-type="productOperationType"
    @close="closeProductDia"
    @confirm="submitProduct"
    @cancel="closeProductDia"
  >
    <el-form ref="productFormRef" :model="productForm" label-width="140px" label-position="top" :rules="productRules">
      <el-row :gutter="30">
        <el-col :span="24">
          <el-form-item label="产品大类:" prop="productCategoryId">
            <el-tree-select
              v-model="productForm.productCategoryId"
              placeholder="请选择"
              clearable
              check-strictly
              :data="productCategoryOptions"
              :render-after-expand="false"
              style="width: 100%"
              @change="getModels"
            />
          </el-form-item>
        </el-col>
      </el-row>
      <el-row :gutter="30">
        <el-col :span="24">
          <el-form-item label="规格型号:" prop="productModelId">
            <el-select v-model="productForm.productModelId" placeholder="请选择" clearable filterable @change="getProductModel">
              <el-option v-for="item in modelOptions" :key="item.id" :label="item.model" :value="item.id" />
            </el-select>
          </el-form-item>
        </el-col>
      </el-row>
      <el-row :gutter="30">
        <el-col :span="12">
          <el-form-item label="单位:" prop="unit">
            <el-input v-model="productForm.unit" placeholder="请输入" clearable />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="税率(%):" prop="taxRate">
            <el-select v-model="productForm.taxRate" placeholder="请选择" clearable @change="calculateFromTaxRate">
              <el-option
                v-for="dict in tax_rate"
                :key="dict.value"
                :label="dict.label"
                :value="dict.value"
              />
            </el-select>
          </el-form-item>
        </el-col>
      </el-row>
      <el-row :gutter="30">
        <el-col :span="12">
          <el-form-item label="含税单价(元):" prop="taxInclusiveUnitPrice">
            <el-input-number
              v-model="productForm.taxInclusiveUnitPrice"
              :step="0.01"
              :min="0"
              :precision="2"
              style="width: 100%"
              placeholder="请输入"
              clearable
              @change="calculateFromUnitPrice"
            />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="数量:" prop="quantity">
            <el-input-number
              v-model="productForm.quantity"
              :step="0.1"
              :min="0"
              :precision="2"
              style="width: 100%"
              placeholder="请输入"
              clearable
              @change="calculateFromQuantity"
            />
          </el-form-item>
        </el-col>
      </el-row>
      <el-row :gutter="30">
        <el-col :span="12">
          <el-form-item label="含税总价(元):" prop="taxInclusiveTotalPrice">
            <el-input v-model="productForm.taxInclusiveTotalPrice" placeholder="请输入" clearable @change="calculateFromTotalPrice" />
          </el-form-item>
        </el-col>
        <el-col :span="12">
          <el-form-item label="不含税总价(元):" prop="taxExclusiveTotalPrice">
            <el-input v-model="productForm.taxExclusiveTotalPrice" placeholder="请输入" clearable @change="calculateFromExclusiveTotalPrice" />
          </el-form-item>
        </el-col>
      </el-row>
      <el-row :gutter="30">
        <el-col :span="12">
          <el-form-item label="发票类型:" prop="invoiceType">
            <el-select v-model="productForm.invoiceType" placeholder="请选择" clearable>
              <el-option label="增普票" value="增普票" />
              <el-option label="增专票" value="增专票" />
            </el-select>
          </el-form-item>
        </el-col>
      </el-row>
    </el-form>
  </FormDialog>
</template>
<script setup name="ProjectManagementFormDia">
import { computed, getCurrentInstance, reactive, ref, toRefs } from 'vue'
import { ArrowDown, ArrowUp, Delete, Plus } from '@element-plus/icons-vue'
<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AttachmentUploadFile from '@/components/AttachmentUpload/file/index.vue'
import { listCustomer } from '@/api/basicData/customer.js'
import PIMTable from '@/components/PIMTable/PIMTable.vue'
import FormDialog from '@/components/Dialog/FormDialog.vue'
import { listPlan } from '@/api/projectManagement/projectType'
import { findRoleListPage } from '@/api/projectManagement/role'
import { userListAll } from '@/api/publicApi'
import { addProject, getProject, updateProject } from '@/api/projectManagement/project'
import { modelList, productTreeList } from '@/api/basicData/product'
import { delProduct as delSalesProduct } from '@/api/salesManagement/salesLedger'
import { listCustomer } from '@/api/basicData/customer'
import { deleteProjectModule, getProjectWorkflow, listProjectCategories, saveProjectModule, startProjectWorkflow, updateProjectExecutors } from '@/api/projectManagement/workflow'
import { userListAll } from '@/api/publicApi'
const emit = defineEmits(['completed'])
const { proxy } = getCurrentInstance()
const { bill_status, project_management, plan_status, tax_rate } = proxy.useDict('bill_status', 'project_management', 'plan_status', 'tax_rate')
const dialogVisible = ref(false)
const visible = ref(false)
const saving = ref(false)
const startProject = ref(false)
const personnelOnly = ref(false)
const operationType = ref('add')
const formRef = ref()
const fileList = ref([])
const customerOptions = 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 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 projectTypeOptions = ref([])
// é¡¹ç›®åˆ†ç±»ï¼šå…ˆå†™æ­»å‡ ä¸ªï¼ŒåŽç»­å¯åˆ‡æ¢ä¸ºå­—å…¸
const projectClassificationOptions = ['数字化转型', '智能制造', '系统集成', '其他']
const roleOptions = ref([])
const userOptions = ref([])
const productData = ref([])
const productSelectedRows = ref([])
const productCategoryOptions = ref([])
const modelOptions = ref([])
const productFormVisible = ref(false)
const productOperationType = ref('add')
const productFormRef = ref()
const productIndex = ref(0)
const isCalculating = ref(false)
const productFormData = reactive({
  productForm: {
    productCategoryId: undefined,
    productCategory: '',
    productModelId: undefined,
    specificationModel: '',
    unit: '',
    quantity: '',
    taxInclusiveUnitPrice: '',
    taxRate: '',
    taxInclusiveTotalPrice: '',
    taxExclusiveTotalPrice: '',
    invoiceType: ''
  },
  productRules: {
    productCategoryId: [{ required: true, message: '请选择', trigger: 'change' }],
    productModelId: [{ required: true, message: '请选择', trigger: 'change' }],
    unit: [{ required: true, message: '请输入', trigger: 'blur' }],
    quantity: [{ required: true, message: '请输入', trigger: 'blur' }],
    taxInclusiveUnitPrice: [{ required: true, message: '请输入', trigger: 'blur' }],
    taxRate: [{ required: true, message: '请选择', trigger: 'change' }],
    taxInclusiveTotalPrice: [{ required: true, message: '请输入', trigger: 'blur' }],
    taxExclusiveTotalPrice: [{ required: true, message: '请输入', trigger: 'blur' }],
    invoiceType: [{ required: true, message: '请选择', trigger: 'change' }]
  }
})
const { productForm, productRules } = toRefs(productFormData)
const data = reactive({
  form: {
    id: undefined,
    clientId: undefined,
    parentProjectId: undefined,
    projectManagementPlanId: undefined,
    managerId: undefined,
    salesmanId: undefined,
    salesmanName: '',
    actualStartDate: '',
    actualEndDate: '',
    departmentId: undefined,
    departmentName: '',
    orderDate: '',
    billNo: '',
    projectName: '',
    customerName: '',
    parentProjectName: '',
    setupDate: todayStr(),
    projectSource: '手动添加',
    projectClassification: '',
    creatorName: '',
    billStatus: '',
    projectStage: '',
    estimatedDays: 0,
    planStartDate: '',
    planEndDate: '',
    projectManagementPlanId: undefined,
    projectAmount: 0,
    auditStatus: '',
    remark: '',
    attachmentIds: [],
    teamList: [],
    phaseList: [],
    addressList: [],
    contactName: '',
    contactGender: '',
    contactBirthday: '',
    contactEmail: '',
    contactDept: '',
    contactJob: '',
    contactMobile: '',
    contactWechat: '',
    contactQq: '',
    contactWorkWechat: '',
    contactAddress: '',
    contactRemark: ''
  },
  rules: {
    projectName: [{ required: true, message: '请输入项目名称', trigger: 'blur' }]
  }
})
const { form, rules } = toRefs(data)
const sectionCollapsed = reactive({
  base: false,
  product: false,
  team: false,
  phase: false,
  address: false,
  contact: false
})
const isView = computed(() => operationType.value === 'view')
// åˆåŒç”Ÿæˆçš„项目:来源为业务审批,合同信息只读展示
const isFromContract = computed(() => operationType.value !== 'add' && form.value.projectSource === '业务审批')
const dialogTitle = computed(() => {
  if (operationType.value === 'add') return '新增项目'
  if (operationType.value === 'edit') return '编辑项目'
  return '项目详情'
})
const teamColumns = [
  { label: '姓名', prop: 'memberId', align: 'center', width: 180, dataType: 'slot', slot: 'memberId' },
  { label: '项目组角色', prop: 'roleId', align: 'center', width: 160, dataType: 'slot', slot: 'roleId' },
  { label: '进入日期', prop: 'enterDate', align: 'center', width: 160, dataType: 'slot', slot: 'enterDate' },
  { label: '离开日期', prop: 'leaveDate', align: 'center', width: 160, dataType: 'slot', slot: 'leaveDate' },
  { label: '联系方式', prop: 'phone', align: 'center', width: 180, dataType: 'slot', slot: 'phone' },
  { label: '备注', prop: 'remark', align: 'center', dataType: 'slot', slot: 'teamRemark' },
  { label: '操作', prop: 'action', align: 'center', width: 100, dataType: 'slot', slot: 'teamAction', fixed: 'right' }
]
const phaseColumns = [
  { label: '阶段名称', prop: 'phaseName', align: 'center', width: 160, dataType: 'slot', slot: 'phaseName' },
  { label: '描述', prop: 'description', align: 'center', width: 200, dataType: 'slot', slot: 'phaseDesc' },
  { label: '负责人', prop: 'ownerId', align: 'center', width: 160, dataType: 'slot', slot: 'ownerId' },
  { label: '预计工期(天)', prop: 'planDays', align: 'center', width: 140, dataType: 'slot', slot: 'planDays' },
  { label: '计划开始日期', prop: 'planStartDate', align: 'center', width: 160, dataType: 'slot', slot: 'planStart' },
  { label: '计划结束日期', prop: 'planEndDate', align: 'center', width: 160, dataType: 'slot', slot: 'planEnd' },
  { label: '进度(%)', prop: 'progress', align: 'center', width: 120, dataType: 'slot', slot: 'progress' },
  { label: '实际开始日期', prop: 'actualStartDate', align: 'center', width: 160, dataType: 'slot', slot: 'actualStart' },
  { label: '实际结束日期', prop: 'actualEndDate', align: 'center', width: 160, dataType: 'slot', slot: 'actualEnd' },
  { label: '逾期天数', prop: 'overdueDays', align: 'center', width: 120, dataType: 'slot', slot: 'overdueDays' },
  { label: '完成情况', prop: 'completionRemark', align: 'center', width: 200, dataType: 'slot', slot: 'completion' },
  { label: '操作', prop: 'action', align: 'center', width: 100, dataType: 'slot', slot: 'phaseAction', fixed: 'right' }
]
const addressColumns = [
  { label: '收货人', prop: 'receiver', align: 'center', width: 180, dataType: 'slot', slot: 'receiver' },
  { label: '联系方式', prop: 'phone', align: 'center', width: 180, dataType: 'slot', slot: 'receiverPhone' },
  { label: '收货地址', prop: 'address', align: 'center', dataType: 'slot', slot: 'receiverAddress' },
  { label: '操作', prop: 'action', align: 'center', width: 100, dataType: 'slot', slot: 'addressAction', fixed: 'right' }
]
function toggleSection(key) {
  sectionCollapsed[key] = !sectionCollapsed[key]
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() {
  const [customerRes,userRes,categoryRes] = await Promise.all([listCustomer({ current:-1,size:-1,type:0 }),userListAll(),listProjectCategories()])
  const customerData=customerRes?.rows||customerRes?.data?.records||customerRes?.data||[]; customers.value=Array.isArray(customerData)?customerData:[]
  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 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
}
function todayStr() {
  const d = new Date()
  const pad = n => String(n).padStart(2, '0')
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
async function openDialog(options={}) {
  reset(); operationType.value=options.operationType||'add'; startProject.value=!!options.startProject; personnelOnly.value=!!options.personnelOnly; 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()}
  visible.value=true
}
function resetFormData() {
  Object.assign(form.value, {
    id: undefined,
    clientId: undefined,
    parentProjectId: undefined,
    projectManagementPlanId: undefined,
    managerId: undefined,
    salesmanId: undefined,
    salesmanName: '',
    actualStartDate: '',
    actualEndDate: '',
    departmentId: undefined,
    departmentName: '',
    orderDate: '',
    billNo: '',
    projectName: '',
    customerName: '',
    parentProjectName: '',
    setupDate: todayStr(),
    projectSource: '手动添加',
    projectClassification: '',
    creatorName: '',
    billStatus: '',
    projectStage: '',
    estimatedDays: 0,
    planStartDate: '',
    planEndDate: '',
    projectManagementPlanId: undefined,
    projectAmount: 0,
    auditStatus: '',
    remark: '',
    attachmentIds: [],
    teamList: [],
    phaseList: [],
    addressList: [],
    contactName: '',
    contactGender: '',
    contactBirthday: '',
    contactEmail: '',
    contactDept: '',
    contactJob: '',
    contactMobile: '',
    contactWechat: '',
    contactQq: '',
    contactWorkWechat: '',
    contactAddress: '',
    contactRemark: ''
  })
  fileList.value = []
  productData.value = []
}
function formattedNumber(row, column, cellValue) {
  const val = Number(cellValue ?? 0)
  return Number.isFinite(val) ? val.toFixed(2) : '0.00'
}
function summarizeProductTable(param) {
  return proxy.summarizeTable(param, ['taxInclusiveTotalPrice', 'taxExclusiveTotalPrice'])
}
function productSelected(selection) {
  productSelectedRows.value = selection
}
function convertIdToValue(data) {
  return (Array.isArray(data) ? data : []).map(item => {
    const { id, children, ...rest } = item
    const newItem = {
      ...rest,
      value: id
    }
    if (children && children.length > 0) {
      newItem.children = convertIdToValue(children)
    }
    return newItem
  })
}
function findNodeById(nodes, productId) {
  for (let i = 0; i < (nodes || []).length; i++) {
    if (nodes[i].value === productId) {
      return nodes[i].label
    }
    if (nodes[i].children && nodes[i].children.length > 0) {
      const foundNode = findNodeById(nodes[i].children, productId)
      if (foundNode) return foundNode
    }
  }
  return null
}
function findNodeIdByLabel(nodes, label) {
  if (!label) return null
  for (let i = 0; i < (nodes || []).length; i++) {
    const node = nodes[i]
    if (node.label === label) return node.value
    if (node.children && node.children.length > 0) {
      const found = findNodeIdByLabel(node.children, label)
      if (found !== null && found !== undefined) return found
    }
  }
  return null
}
function getProductOptions() {
  return productTreeList().then(res => {
    const list = res?.data || res
    productCategoryOptions.value = convertIdToValue(list)
    return productCategoryOptions.value
  })
}
function getModels(value) {
  const categoryLabel = findNodeById(productCategoryOptions.value, value)
  productForm.value.productCategory = categoryLabel || ''
  modelList({ id: value }).then(res => {
    modelOptions.value = res?.data || res || []
  })
}
function getProductModel(value) {
  const index = (modelOptions.value || []).findIndex(item => item.id === value)
  if (index !== -1) {
    productForm.value.specificationModel = modelOptions.value[index].model
    productForm.value.unit = modelOptions.value[index].unit
  } else {
    productForm.value.specificationModel = ''
    productForm.value.unit = ''
  }
}
async function openProductForm(type, row, index) {
  productOperationType.value = type
  productIndex.value = index || 0
  productForm.value = {}
  proxy.resetForm('productFormRef')
  if (!productCategoryOptions.value || productCategoryOptions.value.length === 0) {
    await getProductOptions()
  }
  if (type === 'edit' && row) {
    productForm.value = { ...row }
    try {
      const categoryId = findNodeIdByLabel(productCategoryOptions.value, productForm.value.productCategory)
      if (categoryId) {
        productForm.value.productCategoryId = categoryId
        const models = await modelList({ id: categoryId })
        modelOptions.value = models?.data || models || []
        const currentModel = (modelOptions.value || []).find(m => m.model === productForm.value.specificationModel)
        if (currentModel) {
          productForm.value.productModelId = currentModel.id
        }
      }
    } catch {}
  } else {
    productForm.value = {
      productCategoryId: undefined,
      productCategory: '',
      productModelId: undefined,
      specificationModel: '',
      unit: '',
      quantity: '',
      taxInclusiveUnitPrice: '',
      taxRate: '',
      taxInclusiveTotalPrice: '',
      taxExclusiveTotalPrice: '',
      invoiceType: ''
    }
  }
  productFormVisible.value = true
}
function closeProductDia() {
  proxy.resetForm('productFormRef')
  productFormVisible.value = false
}
function submitProduct() {
  productFormRef.value?.validate?.(valid => {
    if (!valid) return
    const payload = { ...productForm.value }
    if (productOperationType.value === 'add') {
      productData.value.push(payload)
    } else {
      productData.value[productIndex.value] = payload
    }
    closeProductDia()
  })
}
function deleteProduct() {
  if (!productSelectedRows.value || productSelectedRows.value.length === 0) {
    proxy.$modal?.msgWarning?.('请选择数据')
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
  }
  const selectedIds = productSelectedRows.value.map(r => r?.id).filter(Boolean)
  if (operationType.value !== 'add' && selectedIds.length > 0) {
    delSalesProduct(selectedIds)
      .then(() => {
        proxy.$modal?.msgSuccess?.('删除成功')
        productData.value = productData.value.filter(row => !selectedIds.includes(row?.id))
        productSelectedRows.value = []
      })
      .catch(() => {})
    return
  }
  productData.value = productData.value.filter(row => !productSelectedRows.value.includes(row))
  productSelectedRows.value = []
}
function calculateFromTotalPrice() {
  if (isCalculating.value) return
  const totalPrice = parseFloat(productForm.value.taxInclusiveTotalPrice)
  const quantity = parseFloat(productForm.value.quantity)
  if (!totalPrice || !quantity || quantity <= 0) return
  isCalculating.value = true
  productForm.value.taxInclusiveUnitPrice = (totalPrice / quantity).toFixed(2)
  if (productForm.value.taxRate) {
    productForm.value.taxExclusiveTotalPrice = proxy.calculateTaxExclusiveTotalPrice(totalPrice, productForm.value.taxRate)
  }
  isCalculating.value = false
}
function calculateFromExclusiveTotalPrice() {
  if (!productForm.value.taxRate) {
    proxy.$modal?.msgWarning?.('请先选择税率')
    return
  }
  if (isCalculating.value) return
  const exclusiveTotalPrice = parseFloat(productForm.value.taxExclusiveTotalPrice)
  const quantity = parseFloat(productForm.value.quantity)
  const taxRate = parseFloat(productForm.value.taxRate)
  if (!exclusiveTotalPrice || !quantity || quantity <= 0 || !taxRate) return
  isCalculating.value = true
  const taxRateDecimal = taxRate / 100
  const inclusiveTotalPrice = exclusiveTotalPrice / (1 - taxRateDecimal)
  productForm.value.taxInclusiveTotalPrice = inclusiveTotalPrice.toFixed(2)
  productForm.value.taxInclusiveUnitPrice = (inclusiveTotalPrice / quantity).toFixed(2)
  isCalculating.value = false
}
function calculateFromQuantity() {
  if (!productForm.value.taxRate) {
    proxy.$modal?.msgWarning?.('请先选择税率')
    return
  }
  if (isCalculating.value) return
  const quantity = parseFloat(productForm.value.quantity)
  const unitPrice = parseFloat(productForm.value.taxInclusiveUnitPrice)
  if (!quantity || quantity <= 0 || !unitPrice) return
  isCalculating.value = true
  productForm.value.taxInclusiveTotalPrice = (unitPrice * quantity).toFixed(2)
  if (productForm.value.taxRate) {
    productForm.value.taxExclusiveTotalPrice = proxy.calculateTaxExclusiveTotalPrice(
      productForm.value.taxInclusiveTotalPrice,
      productForm.value.taxRate
    )
  }
  isCalculating.value = false
}
function calculateFromUnitPrice() {
  if (!productForm.value.taxRate) {
    proxy.$modal?.msgWarning?.('请先选择税率')
    return
  }
  if (isCalculating.value) return
  const quantity = parseFloat(productForm.value.quantity)
  const unitPrice = parseFloat(productForm.value.taxInclusiveUnitPrice)
  if (!quantity || quantity <= 0 || !unitPrice) return
  isCalculating.value = true
  productForm.value.taxInclusiveTotalPrice = (unitPrice * quantity).toFixed(2)
  if (productForm.value.taxRate) {
    productForm.value.taxExclusiveTotalPrice = proxy.calculateTaxExclusiveTotalPrice(
      productForm.value.taxInclusiveTotalPrice,
      productForm.value.taxRate
    )
  }
  isCalculating.value = false
}
function calculateFromTaxRate() {
  if (!productForm.value.taxRate) {
    proxy.$modal?.msgWarning?.('请先选择税率')
    return
  }
  if (isCalculating.value) return
  const inclusiveTotalPrice = parseFloat(productForm.value.taxInclusiveTotalPrice)
  const taxRate = parseFloat(productForm.value.taxRate)
  if (!inclusiveTotalPrice || !taxRate) return
  isCalculating.value = true
  productForm.value.taxExclusiveTotalPrice = proxy.calculateTaxExclusiveTotalPrice(inclusiveTotalPrice, taxRate)
  isCalculating.value = false
}
async function loadProjectTypeOptions() {
  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 {
    const res = await listPlan({ current: 1, size: 999 })
    const records = res?.data?.records || res?.records || res?.rows || []
    projectTypeOptions.value = records.map(item => ({ label: item.name, value: item.id }))
    // æœªæŒ‡å®šè®¡åˆ’时,默认使用“项目申报六阶段”计划
    if (!form.value.projectManagementPlanId) {
      const defaultPlan = records.find(item => item.name === '项目申报六阶段') || records[0]
      if (defaultPlan) form.value.projectManagementPlanId = defaultPlan.id
    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)
    }
  } catch {
    projectTypeOptions.value = []
  }
    ElMessage.success(startProject.value?'项目已开始':'保存成功')
    visible.value=false
    emit('completed',{ id:form.id,startProject:startProject.value })
  } finally { saving.value=false }
}
async function loadRoleOptions() {
  try {
    const res = await findRoleListPage({ pageNum: 1, pageSize: 999 })
    const records = res?.data?.records || res?.rows || res?.records || []
    roleOptions.value = records.map(item => ({ label: item.roleName || item.name, value: item.id }))
  } catch {
    roleOptions.value = []
  }
}
async function loadUserOptions() {
  try {
    const res = await userListAll()
    const list = res?.data || res?.rows || res || []
    userOptions.value = (Array.isArray(list) ? list : []).map(u => ({
      label: u.nickName || u.userName || u.username || u.name,
      value: u.userId || u.id
    }))
  } catch {
    userOptions.value = []
  }
}
async function loadCustomerOptions() {
  try {
    const res = await listCustomer({ current: -1, size: -1, type: 0 })
    const records = res?.data?.records || res?.data || res?.records || []
    customerOptions.value = Array.isArray(records) ? records : []
  } catch {
    customerOptions.value = []
  }
}
function handleCustomerChange(customerName) {
  const customer = customerOptions.value.find(item => item.customerName === customerName)
  form.value.clientId = customer?.id ?? null
}
function addTeamRow() {
  form.value.teamList.push({
    memberId: undefined,
    roleId: undefined,
    enterDate: '',
    leaveDate: '',
    phone: '',
    remark: ''
  })
}
function removeTeamRow(index) {
  if (index > -1) form.value.teamList.splice(index, 1)
}
function addPhaseRow() {
  form.value.phaseList.push({
    phaseName: '',
    description: '',
    ownerId: undefined,
    planDays: 0,
    planStartDate: '',
    planEndDate: '',
    progress: 0,
    actualStartDate: '',
    actualEndDate: '',
    overdueDays: 0,
    completionRemark: ''
  })
}
function removePhaseRow(index) {
  if (index > -1) form.value.phaseList.splice(index, 1)
}
function addAddressRow() {
  form.value.addressList.push({
    receiver: '',
    phone: '',
    address: ''
  })
}
function removeAddressRow(index) {
  if (index > -1) form.value.addressList.splice(index, 1)
}
async function openDialog(payload = {}) {
  operationType.value = payload.operationType || 'add'
  resetFormData()
  await Promise.all([loadProjectTypeOptions(), loadRoleOptions(), loadUserOptions(), loadCustomerOptions(), getProductOptions()])
  if (payload.row?.id) {
    try {
      const res = await getProject(payload.row.id)
      const detail = res?.data?.data ?? res?.data ?? res
      const info = detail?.info || {}
      const shippingAddress = detail?.shippingAddress || {}
      const contractInfo = detail?.contractInfo || {}
      const normalizeId = v => {
        if (v === undefined || v === null || v === '') return undefined
        const n = Number(v)
        return Number.isNaN(n) ? v : n
      }
      const normalizeDictValue = v => {
        if (v === undefined || v === null || v === '') return ''
        return String(v)
      }
      const computeEstimatedDays = (start, end) => {
        if (!start || !end) return 0
        const startTime = new Date(`${start}T00:00:00`).getTime()
        const endTime = new Date(`${end}T00:00:00`).getTime()
        if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return 0
        if (endTime < startTime) return 0
        return Math.floor((endTime - startTime) / (24 * 60 * 60 * 1000)) + 1
      }
      Object.assign(form.value, {
        id: info.id,
        billNo: info.no ?? '',
        projectManagementPlanId: info.projectManagementPlanId ?? '',
        estimatedDays: Number(info.estimatedDays) || computeEstimatedDays(info.planStartTime, info.planEndTime) || 0,
        projectName: info.title ?? '',
        customerName: info.clientName ?? '',
        clientId: info.clientId ?? undefined,
        parentProjectName: info.projectManagementInfoParentName ?? '',
        setupDate: info.establishTime ?? '',
        projectSource: info.source ?? '',
        projectClassification: info.projectClassification ?? '',
        creatorName: info.managerName ?? '',
        billStatus: normalizeDictValue(info.status),
        projectStage: normalizeDictValue(info.stage ?? info.projectStage),
        planStartDate: info.planStartTime ?? '',
        planEndDate: info.planEndTime ?? '',
        projectAmount: info.orderAmount ?? 0,
        orderDate: info.orderDate ?? '',
        auditStatus: normalizeDictValue(info.reviewStatus),
        remark: info.remark ?? '',
        attachmentIds: Array.isArray(info.attachmentIds) ? info.attachmentIds : [],
        teamList: Array.isArray(info.teamList) ? info.teamList.map(t => ({
          memberId: normalizeId(t.userId),
          roleId: normalizeId(t.userRoleId),
          enterDate: t.joinTime,
          leaveDate: t.departTime,
          phone: t.contact,
          remark: t.remark
        })) : [],
        addressList: shippingAddress?.address
          ? [{
              receiver: shippingAddress.consignee,
              phone: shippingAddress.contract,
              address: shippingAddress.address
            }]
          : [],
        contactName: contractInfo.name ?? '',
        contactGender: contractInfo.sex === '男' ? '1' : contractInfo.sex === '女' ? '2' : '',
        contactBirthday: contractInfo.birthday ?? '',
        contactDept: contractInfo.department ?? '',
        contactJob: contractInfo.job ?? '',
        contactMobile: contractInfo.phoneNumber ?? '',
        contactEmail: contractInfo.email ?? '',
        contactQq: contractInfo.qq ?? '',
        contactWechat: contractInfo.wx ?? '',
        contactWorkWechat: contractInfo.lineaFissa ?? '',
        contactAddress: contractInfo.origineEtnica ?? '',
        contactRemark: contractInfo.rappresentanteLegale ?? ''
      })
      fileList.value = Array.isArray(info.attachmentList)
        ? info.attachmentList.map(a => ({
            id: a.id ?? a.fileId,
            name: a.fileName ?? a.name,
            url: a.url ?? a.fileUrl ?? a.path
          }))
        : []
      const rawPhaseList =
        detail?.phaseList ||
        detail?.projectPhaseList ||
        detail?.projectStageList ||
        info?.phaseList ||
        info?.projectPhaseList ||
        []
      form.value.phaseList = Array.isArray(rawPhaseList)
        ? rawPhaseList.map(p => ({
            phaseName: p.phaseName ?? p.name ?? p.title ?? '',
            description: p.description ?? p.workContent ?? p.desc ?? '',
            ownerId: normalizeId(p.ownerId ?? p.leaderId ?? p.userId),
            planDays: Number(p.planDays ?? p.estimatedDuration ?? p.estimatedDays) || 0,
            planStartDate: p.planStartDate ?? p.planStartTime ?? p.startDate ?? '',
            planEndDate: p.planEndDate ?? p.planEndTime ?? p.endDate ?? '',
            progress: Number(p.progress ?? p.schedule) || 0,
            actualStartDate: p.actualStartDate ?? p.actualStartTime ?? '',
            actualEndDate: p.actualEndDate ?? p.actualEndTime ?? '',
            overdueDays: Number(p.overdueDays ?? p.overDays) || 0,
            completionRemark: p.completionRemark ?? p.remark ?? ''
          }))
        : []
      productData.value = detail?.salesLedgerProductList || detail?.productData || []
    } catch {}
  }
  if (form.value.teamList.length === 0 && !isView.value) addTeamRow()
  if (form.value.phaseList.length === 0 && !isView.value) addPhaseRow()
  dialogVisible.value = true
}
function downloadAttachment(att) {
  if (att) {
    try {
            proxy.$download.byUrl(att.url, att.originalFilename);
      return
    } catch (e) {}
  }
  ElMessage.warning('附件暂无下载地址')
}
function closeDialog() {
  dialogVisible.value = false
}
async function submitForm() {
  if (isView.value) {
    closeDialog()
    return
  }
  await formRef.value?.validate?.()
  const info = {
    id: form.value.id ?? null,
    no: form.value.billNo,
    title: form.value.projectName,
    clientId: form.value.clientId ?? null,
    clientName: form.value.customerName,
    projectManagementInfoParentId: form.value.parentProjectId ?? null,
    projectManagementPlanId: form.value.projectManagementPlanId ?? null,
    establishTime: form.value.setupDate || todayStr(),
    source: form.value.projectSource,
    projectClassification: form.value.projectClassification,
    managerId: form.value.managerId ?? null,
    managerName: form.value.creatorName,
    salesmanId: form.value.salesmanId ?? null,
    salesmanName: form.value.salesmanName ?? '',
    planStartTime: form.value.planStartDate,
    planEndTime: form.value.planEndDate,
    actualStartTime: form.value.actualStartDate,
    actualEndTime: form.value.actualEndDate,
    status: form.value.billStatus === '' || form.value.billStatus === undefined || form.value.billStatus === null ? null : Number(form.value.billStatus),
    departmentId: form.value.departmentId ?? null,
    departmentName: form.value.departmentName ?? '',
    orderDate: form.value.orderDate,
    orderAmount: form.value.projectAmount,
    reviewStatus: form.value.auditStatus === '' || form.value.auditStatus === undefined || form.value.auditStatus === null ? null : Number(form.value.auditStatus),
    stage: form.value.projectStage === '' || form.value.projectStage === undefined || form.value.projectStage === null ? null : Number(form.value.projectStage),
    remark: form.value.remark,
    attachmentIds: (fileList.value || [])
      .map(f => (f?.id ?? f?.rawData?.id))
      .filter(v => v !== undefined && v !== null && v !== '')
      .map(String)
  }
  const payload = {
    info,
    shippingAddress: null,
    contractInfo: null,
    salesLedgerProductList: null
  }
  const req = operationType.value === 'edit' ? updateProject : addProject
  const res = await req(payload)
  if (res?.code === 200) {
    ElMessage.success('保存成功')
    closeDialog()
    emit('completed')
    return
  }
  ElMessage.error(res?.msg || '保存失败')
}
defineExpose({ openDialog })
</script>
<style scoped lang="scss">
.section {
  border: 1px solid #ebeef5;
  border-radius: 8px;
  margin-bottom: 14px;
  background: #fff;
}
.section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 14px;
  cursor: pointer;
}
.section-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 600;
  color: #303133;
}
.section-bar {
  width: 3px;
  height: 14px;
  background: #002FA7;
  border-radius: 2px;
}
.section-actions {
  display: flex;
  align-items: center;
  gap: 10px;
}
.toggle-icon {
  color: #909399;
}
.section-body {
  padding: 0 14px 14px;
}
.dialog-footer {
  display: flex;
  justify-content: center;
  gap: 12px;
}
.attachment-upload{
}
</style>
<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>
src/views/projectManagement/Management/index.vue
@@ -1,428 +1,58 @@
<template>
  <div class="app-container">
    <SearchPanel
      v-model="queryParams"
      :schema="searchSchema"
      @search="handleQuery"
      @reset="resetQuery"
    >
      <template #billStatus="{ item }">
        <el-select v-model="queryParams[item.prop]" placeholder="请选择单据状态" clearable style="width: 100%">
          <el-option v-for="dict in bill_status" :key="dict.value" :label="dict.label" :value="dict.value" />
        </el-select>
      </template>
      <template #auditStatus="{ item }">
        <el-select v-model="queryParams[item.prop]" placeholder="请选择计划状态" clearable style="width: 100%">
          <el-option v-for="dict in project_management" :key="dict.value" :label="dict.label" :value="dict.value" />
        </el-select>
      </template>
      <template #projectStage="{ item }">
        <el-select v-model="queryParams[item.prop]" placeholder="请选择审核状态" clearable style="width: 100%">
          <el-option v-for="dict in plan_status" :key="dict.value" :label="dict.label" :value="dict.value" />
        </el-select>
      </template>
    </SearchPanel>
    <div class="table-container">
      <div class="table-actions">
        <el-button style="background-color: #002FA7; color: #fff" @click="handleAdd">新增</el-button>
        <!-- <el-dropdown split-button type="default" @command="handleGenerateBill" style="margin-left: 10px;">
          ç”Ÿæˆå•据
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item command="1">生成单据1</el-dropdown-item>
              <el-dropdown-item command="2">生成单据2</el-dropdown-item>
            </el-dropdown-menu>
    <el-card shadow="never" class="search-card">
      <el-form :inline="true" :model="query">
        <el-form-item label="项目名称/编号"><el-input v-model="query.keyword" clearable /></el-form-item>
        <el-form-item label="客户名称"><el-input v-model="query.customerName" clearable /></el-form-item>
        <el-form-item label="项目来源"><el-select v-model="query.source" clearable style="width:150px"><el-option label="手动创建" value="手动创建" /><el-option label="业务生成" value="业务生成" /></el-select></el-form-item>
        <el-form-item><el-button type="primary" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
      </el-form>
    </el-card>
    <el-card shadow="never">
      <div class="actions"><el-button type="primary" @click="add">手动创建项目</el-button></div>
      <el-table :data="rows" v-loading="loading" border>
        <el-table-column prop="no" label="项目编号" width="170" />
        <el-table-column prop="title" label="项目名称" min-width="180" />
        <el-table-column prop="clientName" label="客户名称" min-width="160" />
        <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="planStartTime" label="预计开始" width="120" />
        <el-table-column prop="planEndTime" label="预计结束" width="120" />
        <el-table-column label="操作" width="270" 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 link type="primary" @click="detail(row)">详情</el-button>
            <el-button link type="danger" @click="remove(row)">删除</el-button>
          </template>
        </el-dropdown> -->
        <el-button :loading="submitLoading" @click="handleSubmit">提交</el-button>
        <el-button :loading="auditLoading" @click="handleAudit">审核</el-button>
        <!-- <el-button :loading="reverseAuditLoading" @click="handleReverseAudit">反审核</el-button> -->
        <el-button :loading="deleteLoading" @click="handleDelete">删除</el-button>
      </div>
      <PIMTable
        :column="columns"
        :tableData="tableData"
        :page="pagination"
        :tableLoading="loading"
        :isSelection="true"
        @selection-change="handleSelectionChange"
        @pagination="handlePagination"
      >
        <template #auditStatus="{ row }">
          <dict-tag :options="project_management" :value="row.auditStatus" />
        </template>
        <template #projectStage="{ row }">
          <dict-tag :options="plan_status" :value="row.projectStage" />
        </template>
        <template #action="{ row }">
          <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
          <el-button link type="primary" :loading="progressBtnLoadingId===row.id" @click="handleProgressReport(row)">进度汇报</el-button>
          <el-button link type="primary" @click="handleDetail(row)">详情</el-button>
        </template>
      </PIMTable>
    </div>
    <FormDia ref="formDiaRef" @completed="getList" />
    <ProgressReportDialog
      v-model="progressReportVisible"
      :project-id="progressProjectId"
      :project-info="progressProjectInfo"
      :plan-nodes="progressPlanNodes"
      :default-plan-node-id="progressDefaultPlanNodeId"
      @submitted="handleProgressSubmitted"
    />
        </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" />
  </div>
</template>
<script setup name="ProjectManagement">
import { ref, reactive, toRefs, onMounted, getCurrentInstance } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import SearchPanel from '@/components/SearchPanel/index.vue'
import PIMTable from '@/components/PIMTable/PIMTable.vue'
import FormDia from './components/formDia.vue'
import ProgressReportDialog from '@/components/ProjectManagement/ProgressReportDialog.vue'
import {
  listProject,
  delProject,
  submitProject,
  auditProject,
  reverseAuditProject,
  getProject,
  saveStage
} from '@/api/projectManagement/project'
import { listPlan } from '@/api/projectManagement/projectType'
import { ElMessage, ElMessageBox } from 'element-plus'
import useUserStore from '@/store/modules/user'
import { listProject, delProject } from '@/api/projectManagement/project'
import FormDia from './components/formDia.vue'
const { proxy } = getCurrentInstance()
const { bill_status, project_management, plan_status } = proxy.useDict('bill_status', 'project_management', 'plan_status')
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const ids = ref([])
const tableData = ref([])
const formDiaRef = ref()
const progressReportVisible = ref(false)
const progressProjectId = ref(undefined)
const progressProjectInfo = ref({})
const progressPlanNodes = ref([])
const progressDefaultPlanNodeId = ref(undefined)
const progressBtnLoadingId = ref(null)
const submitLoading = ref(false)
const auditLoading = ref(false)
const reverseAuditLoading = ref(false)
const deleteLoading = ref(false)
const data = reactive({
  queryParams: {
    projectNameOrCode: undefined,
    customerName: undefined,
    billStatus: undefined,
    projectStage: undefined,
    auditStatus: undefined,
    salesperson: undefined,
    pageNum: 1,
    pageSize: 10
  },
  pagination: {
    current: 1,
    size: 10,
    total: 0,
    layout: 'total, sizes, prev, pager, next, jumper'
  }
})
const { queryParams, pagination } = toRefs(data)
const searchSchema = [
  { prop: 'projectNameOrCode', label: '项目名称/编号', type: 'input', placeholder: '请输入项目名称/编号' },
  { prop: 'customerName', label: '客户名称', type: 'input', placeholder: '请输入客户名称' },
  { prop: 'billStatus', label: '单据状态', slot: 'billStatus' },
  { prop: 'projectStage', label: '计划状态', slot: 'projectStage' },
  { prop: 'auditStatus', label: '审核状态', slot: 'auditStatus' },
  { prop: 'salesperson', label: '业务人员', type: 'input', placeholder: '请输入业务人员' }
]
const columns = [
  { label: '单据编号', prop: 'billNo', align: 'center', width: '150' },
  { label: '项目名称', prop: 'projectName', align: 'center' },
  { label: '审核状态', prop: 'auditStatus', align: 'center', dataType: 'slot', slot: 'auditStatus' },
  { label: '客户名称', prop: 'customerName', align: 'center' },
  { label: '立项日期', prop: 'setupDate', align: 'center', width: '120' },
  { label: '项目来源', prop: 'projectSource', align: 'center' },
  { label: '项目分类', prop: 'projectClassification', align: 'center' },
  { label: '操作', prop: 'action', align: 'center', width: '250', dataType: 'slot', slot: 'action', fixed: 'right' }
]
function getList() {
  loading.value = true
  const params = {
    noOrName: queryParams.value.projectNameOrCode,
    clientName: queryParams.value.customerName,
    salesmanName: queryParams.value.salesperson,
    reviewStatus: queryParams.value.auditStatus,
    stage: queryParams.value.projectStage,
    current: queryParams.value.pageNum,
    size: queryParams.value.pageSize
  }
  listProject(params)
    .then(response => {
      const records = response?.data?.records || response?.rows || response?.records || []
      const billFilter = queryParams.value.billStatus
      const filtered = billFilter === undefined || billFilter === null || billFilter === ''
        ? records
        : records.filter(r => String(r.billStatus ?? r.status) === String(billFilter))
      tableData.value = filtered.map(r => ({
        id: r.id,
        billNo: r.no ?? r.billNo,
        projectName: r.title ?? r.projectName,
        billStatus: r.billStatus ?? r.status,
        auditStatus: r.reviewStatus ?? r.auditStatus,
        projectStage: r.stage ?? r.projectStage,
        customerName: r.clientName ?? r.customerName,
        parentProject: r.parentTitle ?? r.parentName ?? r.parentProject,
        setupDate: r.establishTime ?? r.setupDate,
        projectType: r.planName ?? r.projectType,
        projectSource: r.source ?? r.projectSource,
        projectClassification: r.projectClassification,
        raw: r
      }))
      pagination.value.total = response?.total || response?.data?.total || 0
    })
    .finally(() => {
      loading.value = false
    })
}
function handleQuery() {
  queryParams.value.pageNum = 1
  pagination.value.current = 1
  getList()
}
function resetQuery() {
  queryParams.value = {
    projectNameOrCode: undefined,
    customerName: undefined,
    billStatus: undefined,
    projectStage: undefined,
    auditStatus: undefined,
    salesperson: undefined,
    pageNum: 1,
    pageSize: 10
  }
  handleQuery()
}
function handleSelectionChange(selection) {
  ids.value = selection.map(item => item.id)
}
function handlePagination({ page, limit }) {
  queryParams.value.pageNum = page
  queryParams.value.pageSize = limit
  pagination.value.current = page
  pagination.value.size = limit
  getList()
}
function handleAdd() {
  formDiaRef.value?.openDialog({ operationType: 'add' })
}
async function handleDelete() {
  const delIds = ids.value
  if (delIds.length === 0) {
    ElMessage.warning('请选择要删除的数据项')
    return
  }
  try {
    await ElMessageBox.confirm('是否确认删除所选数据项?', '警告', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    deleteLoading.value = true
    await delProject(delIds)
    getList()
    ElMessage.success('删除成功')
  } catch {} finally {
    deleteLoading.value = false
  }
}
async function handleSubmit() {
  const submitIds = ids.value
  if (submitIds.length === 0) {
    ElMessage.warning('请选择要提交的数据项')
    return
  }
  try {
    await ElMessageBox.confirm('是否确认提交所选数据项?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    submitLoading.value = true
    await Promise.all(submitIds.map(id => submitProject({ id })))
    getList()
    ElMessage.success('提交成功')
  } catch {} finally {
    submitLoading.value = false
  }
}
async function handleAudit() {
  const auditIds = ids.value
  if (auditIds.length === 0) {
    ElMessage.warning('请选择要审核的数据项')
    return
  }
  try {
    await ElMessageBox.confirm('是否确认审核所选数据项?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    auditLoading.value = true
    await Promise.all(auditIds.map(id => auditProject({ id })))
    getList()
    ElMessage.success('审核成功')
  } catch {} finally {
    auditLoading.value = false
  }
}
async function handleReverseAudit() {
  const reverseAuditIds = ids.value
  if (reverseAuditIds.length === 0) {
    ElMessage.warning('请选择要反审核的数据项')
    return
  }
  try {
    await ElMessageBox.confirm('是否确认反审核所选数据项?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    reverseAuditLoading.value = true
    await Promise.all(reverseAuditIds.map(id => reverseAuditProject({ id })))
    getList()
    ElMessage.success('反审核成功')
  } catch {} finally {
    reverseAuditLoading.value = false
  }
}
function handleGenerateBill(command) {
  ElMessage.info(`生成单据: ${command}`)
}
function computeDefaultPlanNodeId(stageVal, nodes) {
  const list = Array.isArray(nodes) ? nodes : []
  if (list.length === 0) return undefined
  const direct = list.find(n => String(n.id) === String(stageVal))
  if (direct?.id) return direct.id
  const sorted = [...list].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
  const idx = Number(stageVal)
  if (Number.isFinite(idx)) {
    const byIndex = sorted[idx - 1] || sorted[idx] || sorted[0]
    if (byIndex?.id) return byIndex.id
  }
  return sorted[0]?.id
}
async function handleProgressReport(row) {
  if (!row?.id) return
  try {
    progressBtnLoadingId.value = row.id
    const res = await getProject(row.id)
    const detail = res?.data?.data ?? res?.data ?? res
    const info = detail?.info || {}
    progressProjectId.value = info.id
    progressProjectInfo.value = info
    const planId = info.projectManagementPlanId
    if (planId) {
      const planRes = await listPlan({ current: 1, size: 999 })
      const records = planRes?.data?.records || planRes?.records || planRes?.rows || []
      const plan = (records || []).find(p => String(p.id) === String(planId)) || {}
      progressPlanNodes.value = Array.isArray(plan?.planNodeList) ? plan.planNodeList : []
    } else {
      progressPlanNodes.value = []
    }
    progressDefaultPlanNodeId.value = computeDefaultPlanNodeId(info.stage, progressPlanNodes.value)
    progressReportVisible.value = true
  } catch (e) {
    ElMessage.error('获取项目详情失败')
  } finally {
    progressBtnLoadingId.value = null
  }
}
async function handleProgressSubmitted(payload) {
  try {
    const nodes = Array.isArray(progressPlanNodes.value) ? progressPlanNodes.value : []
    const node = nodes.find(n => String(n.id) === String(payload.planNodeId)) || {}
    const description = payload.remark
      ? `${payload.reportDate || ''} ${payload.remark}`.trim()
      : `${payload.reportDate || ''} è¿›åº¦æ±‡æŠ¥`.trim()
    const req = {
      id: null,
      projectManagementPlanNodeId: payload.planNodeId,
      projectManagementInfoId: progressProjectId.value,
      description,
      actualLeaderId: userStore.id || progressProjectInfo.value?.managerId,
      actualLeaderName: userStore.nickName || progressProjectInfo.value?.managerName,
      estimatedDuration: Number(node.estimatedDuration ?? 0) || 0,
      planStartTime: payload.planStartTime || progressProjectInfo.value?.planStartTime,
      planEndTime: payload.planEndTime || progressProjectInfo.value?.planEndTime,
      actualStartTime: payload.actualStartTime || null,
      actualEndTime: payload.actualEndTime || null,
      progress: Number(payload.totalProgress ?? payload.completionProgress ?? 0) || 0,
      storageBlobDTOs: Array.isArray(payload.storageBlobDTOs) ? payload.storageBlobDTOs : []
    }
    const res = await saveStage(req)
    if (res?.code === 200) {
      ElMessage.success('提交成功')
      getList()
      return
    }
    ElMessage.error(res?.msg || '提交失败')
  } catch (e) {
    ElMessage.error('提交失败')
  }
}
function handleDetail(row) {
  if (!row?.id) return
  router.push(`/projectManagement/Management/detail/${row.id}`)
}
function handleEdit(row) {
  formDiaRef.value?.openDialog({ operationType: 'edit', row })
}
onMounted(() => {
  getList()
})
const router=useRouter(); const loading=ref(false); const rows=ref([]); const total=ref(0); const formRef=ref()
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})}
function detail(row){router.push(`/projectManagement/Management/detail/${row.id}`)}
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 lang="scss">
.app-container {
  padding: 20px;
}
.table-container {
  background-color: #fff;
  padding: 20px;
  border-radius: 4px;
}
.table-actions {
  text-align: right;
    margin-bottom: 10px;
}
</style>
<style scoped>.app-container{padding:20px}.search-card{margin-bottom:16px}.actions{margin-bottom:14px;text-align:right}</style>
src/views/projectManagement/Management/projectDetail.vue
@@ -1,423 +1,87 @@
<template>
  <div class="app-container">
    <el-card class="header-card" shadow="never">
  <div class="app-container" v-loading="loading">
    <el-card shadow="never" class="header-card">
      <div class="header-row">
        <div class="header-title">项目详情</div>
        <div class="header-actions">
          <el-button style="color: white;background: #002FA7;" @click="openProgressReport">进度汇报</el-button>
          <el-button @click="goBack">返回</el-button>
        </div>
      </div>
      <div v-if="stageSteps.length > 0" class="progress-block">
        <el-steps :active="activeStep" align-center finish-status="success">
          <el-step v-for="(s, idx) in stageSteps" :key="idx" :title="s.name" />
        </el-steps>
        <div class="progress-percent">
          <span class="progress-label">整体进度</span>
          <el-progress :percentage="overallProgress" :stroke-width="12" style="flex: 1" />
        <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-button @click="router.back()">返回</el-button>
        </div>
      </div>
    </el-card>
    <el-card class="content-card" shadow="never" v-loading="loading">
      <el-tabs v-model="activeTab">
        <el-tab-pane label="基础资料" name="base">
          <el-descriptions :column="4" border>
            <el-descriptions-item label="单据编号">{{ info.no || '-' }}</el-descriptions-item>
            <el-descriptions-item label="项目名称">{{ info.title || '-' }}</el-descriptions-item>
            <el-descriptions-item label="客户名称">{{ info.clientName || '-' }}</el-descriptions-item>
            <el-descriptions-item label="项目分类">{{ info.projectClassification || '-' }}</el-descriptions-item>
            <el-descriptions-item label="父项目">{{ parentProjectLabel }}</el-descriptions-item>
            <el-descriptions-item label="立项日期">{{ info.establishTime || '-' }}</el-descriptions-item>
            <el-descriptions-item label="项目来源">{{ info.source || '-' }}</el-descriptions-item>
            <el-descriptions-item label="当前阶段">{{ currentStageName }}</el-descriptions-item>
            <el-descriptions-item label="审核状态">
              <dict-tag :options="project_management" :value="String(info.reviewStatus ?? '')" />
            </el-descriptions-item>
          </el-descriptions>
          <el-descriptions v-if="isFromContract" :column="3" border style="margin-top: 14px;">
            <el-descriptions-item label="合同客户">{{ info.clientName || '-' }}</el-descriptions-item>
            <el-descriptions-item label="合同金额(元)">{{ info.orderAmount ?? '-' }}</el-descriptions-item>
            <el-descriptions-item label="合同日期">{{ info.orderDate || '-' }}</el-descriptions-item>
          </el-descriptions>
          <div class="attachment-block" v-if="attachments.length > 0">
            <div class="attachment-title">附件</div>
            <div class="attachment-list">
              <div v-for="(att, idx) in attachments" :key="att.id || att.url || idx" class="attachment-item">
                <el-icon><Document /></el-icon>
                <span class="attachment-name">{{ att.name || att.fileName || att.url || '附件' }}</span>
                <el-button link type="primary" size="small" @click="downloadAttachment(att)">下载</el-button>
              </div>
            </div>
          </div>
        </el-tab-pane>
        <el-tab-pane label="项目阶段" name="stage">
          <el-table :data="stageNodeRows" border style="margin-top: 14px;">
            <el-table-column align="center" label="序号" type="index" width="60" />
            <el-table-column label="阶段" prop="stageName" min-width="160" show-overflow-tooltip />
            <el-table-column label="描述" prop="description" min-width="220" show-overflow-tooltip />
            <el-table-column label="实际负责人" prop="actualLeaderName" width="140" show-overflow-tooltip />
            <el-table-column label="进度(%)" prop="progress" width="110" />
            <el-table-column label="计划开始" prop="planStartTime" width="120" />
            <el-table-column label="计划结束" prop="planEndTime" width="120" />
            <el-table-column label="实际开始" prop="actualStartTime" width="120" />
            <el-table-column label="实际结束" prop="actualEndTime" width="120" />
            <el-table-column label="本次进度日期" prop="reportDate" width="130" />
            <el-table-column label="附件" width="90" align="center">
              <template #default="{ row }">
                <span>{{ row.attachmentCount }}</span>
              </template>
            </el-table-column>
            <el-table-column v-if="false" label="操作" width="100" align="center" fixed="right">
              <template #default="{ row }">
                <el-button link type="danger" size="small" @click="handleDeleteStage(row)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
        </el-tab-pane>
      </el-tabs>
    <el-card shadow="never" class="content-card">
      <el-descriptions title="项目基本情况" :column="4" border>
        <el-descriptions-item label="项目名称">{{ info.title || '-' }}</el-descriptions-item>
        <el-descriptions-item label="客户名称">{{ info.clientName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="项目来源">{{ info.source || '-' }}</el-descriptions-item>
        <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.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>
      </el-descriptions>
      <div v-if="attachments.length" class="attachment-block">
        <div class="section-title">项目附件</div>
        <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" />
  </div>
  <ProgressReportDialog
    v-model="progressReportVisible"
    :project-id="info.id"
    :project-info="info"
    :plan-nodes="planNodeRows"
    :stage-records="stageNodeRows"
    :default-plan-node-id="defaultPlanNodeId"
    @submitted="handleProgressSubmitted"
  />
</template>
<script setup name="ProjectManagementDetail">
import { computed, getCurrentInstance, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Document } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getProject, saveStage, listStage, deleteStage, updateProject } from '@/api/projectManagement/project'
import { listPlan } from '@/api/projectManagement/projectType'
import ProgressReportDialog from '@/components/ProjectManagement/ProgressReportDialog.vue'
import useUserStore from '@/store/modules/user'
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()
const router = useRouter()
const { project_management } = proxy.useDict('project_management')
const loading = ref(false)
const activeTab = ref('base')
const progressReportVisible = ref(false)
const userStore = useUserStore()
const info = ref({})
const attachments = ref([])
const projectTypeMap = ref(new Map())
const planNodeRows = ref([])
const stageNodeRows = ref([])
const isFromContract = computed(() => info.value?.source === '业务审批')
const parentProjectLabel = computed(() => {
  return (
    info.value?.parentTitle ||
    info.value?.projectManagementInfoParentName ||
    info.value?.projectManagementInfoParentId ||
    '-'
  )
})
// å„阶段当前进度(同一阶段多次汇报取最大值)
const stageProgressMap = computed(() => {
  const map = {}
  stageNodeRows.value.forEach(r => {
    const key = String(r.projectManagementPlanNodeId ?? '')
    if (!key) return
    map[key] = Math.max(map[key] || 0, Number(r.progress) || 0)
  })
  return map
})
// é˜¶æ®µè¿›åº¦æ¡æ•°æ®ï¼šä¼˜å…ˆä½¿ç”¨é¡¹ç›®ä¸ŠåŒæ­¥çš„ planStage
const stageSteps = computed(() => {
  const planStages = Array.isArray(info.value?.planStage) ? info.value.planStage : []
  if (planStages.length > 0) {
    return [...planStages]
      .sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
      .map(i => ({
        id: i.id,
        name: i.name || String(i.id ?? ''),
        status: Number(i.planStageEnum) || 1,
        progress: stageProgressMap.value[String(i.id)] ?? 0
      }))
  }
  const nodes = [...(planNodeRows.value || [])].sort((a, b) => Number(a.sort ?? 0) - Number(b.sort ?? 0))
  return nodes.map(n => {
    const progress = stageProgressMap.value[String(n.id)] ?? 0
    return {
      id: n.id,
      name: n.name || n.workContent || String(n.id ?? ''),
      status: progress >= 100 ? 3 : progress > 0 ? 2 : 1,
      progress
    }
  })
})
const activeStep = computed(() => {
  const idx = stageSteps.value.findIndex(s => s.status !== 3)
  return idx === -1 ? stageSteps.value.length : idx
})
const currentStageName = computed(() => {
  const current = stageSteps.value.find(s => s.status === 2) || stageSteps.value[activeStep.value]
  return current?.name || '-'
})
const overallProgress = computed(() => {
  const list = stageSteps.value
  if (list.length === 0) return 0
  const sum = list.reduce((acc, s) => acc + (Number(s.progress) || 0), 0)
  return Math.round(sum / list.length)
})
const defaultPlanNodeId = computed(() => {
  const next = stageSteps.value.find(s => s.status !== 3)
  return next?.id ?? stageSteps.value[stageSteps.value.length - 1]?.id
})
function goBack() {
  router.back()
}
function openProgressReport() {
  progressReportVisible.value = true
}
async function saveTeamIfNeeded(teamList) {
  if (!Array.isArray(teamList)) return
  const attachmentIds = attachments.value
    .map(a => a.id ?? a.fileId)
    .filter(v => v !== undefined && v !== null && v !== '')
    .map(String)
  const res = await updateProject({
    info: {
      id: info.value?.id,
      no: info.value?.no,
      title: info.value?.title,
      projectManagementPlanId: info.value?.projectManagementPlanId,
      teamList,
      attachmentIds
    }
  })
  if (res?.code !== 200) throw new Error(res?.msg || '团队保存失败')
}
async function handleProgressSubmitted(payload) {
  try {
    if (Array.isArray(payload.teamList)) {
      await saveTeamIfNeeded(payload.teamList)
    }
    const nodes = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
    const node = nodes.find(n => String(n.id) === String(payload.planNodeId)) || {}
    const description = payload.remark
      ? `${payload.reportDate || ''} ${payload.remark}`.trim()
      : `${payload.reportDate || ''} è¿›åº¦æ±‡æŠ¥`.trim()
    const req = {
      id: null,
      projectManagementPlanNodeId: payload.planNodeId,
      projectManagementInfoId: info.value?.id,
      description,
      actualLeaderId: userStore.id || info.value?.managerId,
      actualLeaderName: userStore.nickName || info.value?.managerName || '',
      estimatedDuration: Number(node.estimatedDuration ?? 0) || 0,
      planStartTime: payload.planStartTime || info.value?.planStartTime,
      planEndTime: payload.planEndTime || info.value?.planEndTime,
      actualStartTime: payload.actualStartTime || null,
      actualEndTime: payload.actualEndTime || null,
      progress: Number(payload.completionProgress ?? payload.totalProgress ?? 0) || 0,
      storageBlobDTOs: Array.isArray(payload.storageBlobDTOs) ? payload.storageBlobDTOs : []
    }
    const res = await saveStage(req)
    if (res?.code === 200) {
      ElMessage.success('提交成功')
      await Promise.all([loadDetail(), loadStageList()])
      return
    }
    ElMessage.error(res?.msg || '提交失败')
  } catch (e) {
    ElMessage.error(e?.message || '提交失败')
  }
}
function downloadAttachment(row) {
  if (row?.url) {
    try {
      proxy.$download.byUrl(row.url, row.originalFilename)
      return
    } catch (e) {}
  }
  ElMessage.warning('附件暂无下载地址')
}
async function loadProjectTypeMap() {
  try {
    const res = await listPlan({ current: 1, size: 999 })
    const records = res?.data?.records || res?.records || res?.rows || []
    projectTypeMap.value = new Map((records || []).map(r => [Number(r.id), r]))
  } catch {
    projectTypeMap.value = new Map()
  }
}
function getPlanNodeName(planNodeId) {
  const list = Array.isArray(planNodeRows.value) ? planNodeRows.value : []
  const node = list.find(n => String(n.id) === String(planNodeId))
  return node?.name || node?.workContent || String(planNodeId ?? '')
}
async function loadStageList() {
  const projectId = info.value?.id
  if (!projectId) {
    stageNodeRows.value = []
    return
  }
  try {
    const res = await listStage(projectId)
    const data = res?.data?.data ?? res?.data ?? res
    const list = data?.records || data?.rows || data?.list || data || []
    const records = Array.isArray(list) ? list : []
    stageNodeRows.value = records.map(r => {
      const attachmentList = Array.isArray(r.attachmentList) ? r.attachmentList : []
      const blobList = Array.isArray(r.storageBlobVOs) ? r.storageBlobVOs : []
      return {
        ...r,
        stageName: getPlanNodeName(r.projectManagementPlanNodeId),
        reportDate: r.createTime ? String(r.createTime).slice(0, 10) : '',
        attachmentCount: attachmentList.length || blobList.length || 0
      }
    })
  } catch {
    stageNodeRows.value = []
  }
}
async function handleDeleteStage(row) {
  const stageId = row?.id
  if (!stageId) return
  try {
    await ElMessageBox.confirm('是否确认删除该项目阶段?', '提示', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning'
    })
    const res = await deleteStage(stageId)
    if (res?.code === 200) {
      ElMessage.success('删除成功')
      await loadStageList()
      return
    }
    ElMessage.error(res?.msg || '删除失败')
  } catch {}
}
function syncProjectPlan() {
  const id = info.value?.projectManagementPlanId
  if (id === undefined || id === null || id === '') {
    planNodeRows.value = []
    return
  }
  const plan = projectTypeMap.value.get(Number(id)) || {}
  planNodeRows.value = Array.isArray(plan?.planNodeList) ? plan.planNodeList : []
}
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] || ''))
async function loadDetail() {
  const id = route.params?.id
  if (!id) return
  loading.value = true
  try {
    const res = await getProject(id)
    const res = await getProject(route.params.id)
    const detail = res?.data?.data ?? res?.data ?? res
    info.value = detail?.info || {}
    attachments.value = Array.isArray(detail?.info?.attachmentList) ? detail.info.attachmentList : []
    syncProjectPlan()
    await loadStageList()
  } finally {
    loading.value = false
  }
    attachments.value = info.value.attachmentList || []
    await executionRef.value?.refresh?.()
  } catch { ElMessage.error('获取项目详情失败') } finally { loading.value = false }
}
onMounted(async () => {
  await loadProjectTypeMap()
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 lang="scss">
.header-card {
  margin-bottom: 14px;
}
.header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.header-title {
  font-weight: 600;
  font-size: 16px;
}
.progress-block {
  padding-top: 6px;
}
.progress-percent {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 16px;
}
.progress-label {
  font-size: 13px;
  color: #606266;
  white-space: nowrap;
}
.content-card {
  border-radius: 8px;
}
.attachment-block {
  margin-top: 14px;
}
.attachment-title {
  font-weight: 600;
  margin-bottom: 8px;
}
.attachment-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.attachment-item {
  display: flex;
  align-items: center;
  gap: 8px;
}
.attachment-name {
  max-width: 520px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
<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; }
</style>
src/views/projectManagement/projectCategory/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
<template>
  <div class="app-container">
    <el-card shadow="never">
      <template #header>
        <div class="card-header">
          <div>
            <div class="title">项目分类</div>
            <div class="subtitle">维护多层树形分类,项目开始时逐级选择</div>
          </div>
          <el-button v-hasPermi="['project:category:edit']" type="primary" @click="openDialog()">新增一级分类</el-button>
        </div>
      </template>
      <el-table
        v-loading="loading"
        :data="treeRows"
        row-key="id"
        border
        default-expand-all
        :tree-props="{ children: 'children' }"
      >
        <el-table-column prop="categoryName" label="分类名称" min-width="260" />
        <el-table-column prop="sort" label="排序" width="100" align="center" />
        <el-table-column prop="remark" label="备注" min-width="260" show-overflow-tooltip />
        <el-table-column label="操作" width="250" align="center">
          <template #default="{ row }">
            <el-button v-hasPermi="['project:category:edit']" link type="primary" @click="openDialog(row, true)">新增下级</el-button>
            <el-button v-hasPermi="['project:category:edit']" link type="primary" @click="openDialog(row)">修改</el-button>
            <el-button v-hasPermi="['project:category:remove']" link type="danger" @click="removeRow(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
    <el-dialog v-model="dialogVisible" :title="form.id ? '修改项目分类' : '新增项目分类'" width="520px" destroy-on-close>
      <el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
        <el-form-item label="上级分类">
          <el-tree-select
            v-model="form.parentId"
            :data="parentOptions"
            node-key="id"
            :props="{ label: 'categoryName', children: 'children' }"
            check-strictly
            clearable
            placeholder="不选表示一级分类"
            style="width: 100%"
          />
        </el-form-item>
        <el-form-item label="分类名称" prop="categoryName">
          <el-input v-model="form.categoryName" maxlength="100" show-word-limit />
        </el-form-item>
        <el-form-item label="排序">
          <el-input-number v-model="form.sort" :min="0" :max="999" style="width: 100%" />
        </el-form-item>
        <el-form-item label="备注">
          <el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" :loading="saving" @click="submit">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup name="ProjectCategory">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { deleteProjectCategory, listProjectCategories, saveProjectCategory } from '@/api/projectManagement/workflow'
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const formRef = ref()
const rows = ref([])
const form = reactive({ id: null, parentId: null, categoryName: '', sort: 0, remark: '' })
const rules = { categoryName: [{ required: true, message: '请输入分类名称', trigger: 'blur' }] }
function buildTree(list, excludedId = null) {
  const source = list.filter(item => String(item.id) !== String(excludedId ?? ''))
  const map = new Map(source.map(item => [String(item.id), { ...item, children: [] }]))
  const roots = []
  map.forEach(item => {
    const parent = map.get(String(item.parentId || 0))
    if (parent) parent.children.push(item)
    else roots.push(item)
  })
  const sortRows = list => list
    .sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
    .map(item => ({ ...item, children: sortRows(item.children || []) }))
  return sortRows(roots)
}
const treeRows = computed(() => buildTree(rows.value))
const parentOptions = computed(() => buildTree(rows.value, form.id))
async function loadData() {
  loading.value = true
  try {
    const res = await listProjectCategories()
    rows.value = Array.isArray(res?.data) ? res.data : []
  } finally {
    loading.value = false
  }
}
function openDialog(row = null, asChild = false) {
  Object.assign(form, {
    id: asChild ? null : row?.id ?? null,
    parentId: asChild ? row?.id : row?.parentId || null,
    categoryName: asChild ? '' : row?.categoryName || '',
    sort: asChild ? 0 : Number(row?.sort || 0),
    remark: asChild ? '' : row?.remark || ''
  })
  dialogVisible.value = true
}
async function submit() {
  await formRef.value?.validate()
  saving.value = true
  try {
    await saveProjectCategory({ ...form, parentId: form.parentId || 0 })
    ElMessage.success('保存成功')
    dialogVisible.value = false
    await loadData()
  } finally {
    saving.value = false
  }
}
async function removeRow(row) {
  await ElMessageBox.confirm(`确认删除分类“${row.categoryName}”吗?`, '提示', { type: 'warning' })
  await deleteProjectCategory(row.id)
  ElMessage.success('删除成功')
  await loadData()
}
onMounted(loadData)
</script>
<style scoped>
.card-header { display: flex; align-items: center; justify-content: space-between; }
.title { font-size: 17px; font-weight: 600; color: #303133; }
.subtitle { margin-top: 5px; font-size: 13px; color: #909399; }
</style>
src/views/salesManagement/salesLedger/index.vue
@@ -11,15 +11,6 @@
            @change="handleQuery"
          />
        </el-form-item>
        <el-form-item label="销售合同号:">
          <el-input
            v-model="searchForm.salesContractNo"
            placeholder="请输入"
            clearable
            prefix-icon="Search"
            @change="handleQuery"
          />
        </el-form-item>
        <el-form-item label="项目名称:">
          <el-input
            v-model="searchForm.projectName"
@@ -189,12 +180,6 @@
        </el-table-column>
        <el-table-column align="center" label="序号" type="index" width="60" />
        <el-table-column
          label="销售合同号"
          prop="salesContractNo"
          width="180"
          show-overflow-tooltip
        />
        <el-table-column
          label="客户名称"
          prop="customerName"
          width="300"
@@ -211,18 +196,6 @@
          prop="projectName"
          width="180"
          show-overflow-tooltip
        />
        <el-table-column
          label="付款方式"
          prop="paymentMethod"
          show-overflow-tooltip
        />
        <el-table-column
          label="合同金额(元)"
          prop="contractAmount"
          width="220"
          show-overflow-tooltip
          :formatter="formattedNumber"
        />
        <el-table-column
          label="录入人"
@@ -340,49 +313,6 @@
      >
        <el-row :gutter="30">
          <el-col :span="12">
            <el-form-item label="合同类型:" prop="contractType">
              <el-select
                v-model="form.contractType"
                placeholder="请选择"
                clearable
                :disabled="operationType === 'view' || operationType === 'edit'"
              >
                <el-option label="普通销售合同" :value="1" />
                <el-option label="框架合同" :value="2" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="30">
          <el-col :span="12">
            <el-form-item label="销售合同号:" prop="salesContractNo">
              <div
                style="
                  display: flex;
                  align-items: center;
                  gap: 12px;
                  width: 100%;
                "
              >
                <el-checkbox
                  v-model="form.autoGenerateContractNo"
                  v-if="operationType === 'add'"
                  >自动生成
                </el-checkbox>
                <el-input
                  v-model="form.salesContractNo"
                  :placeholder="
                    form.autoGenerateContractNo ? '保存后自动生成' : '请输入'
                  "
                  clearable
                  :disabled="
                    form.autoGenerateContractNo || operationType === 'view'
                  "
                />
              </div>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="业务员:" prop="salesman">
              <el-select
                v-model="form.salesman"
@@ -430,46 +360,6 @@
                v-model="form.projectName"
                placeholder="请输入"
                clearable
                :disabled="operationType === 'view'"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="30">
          <el-col :span="12">
            <el-form-item label="签订日期:" prop="executionDate">
              <el-date-picker
                style="width: 100%"
                v-model="form.executionDate"
                value-format="YYYY-MM-DD"
                format="YYYY-MM-DD"
                type="date"
                placeholder="请选择"
                clearable
                :disabled="operationType === 'view'"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="付款方式">
              <el-input
                v-model="form.paymentMethod"
                placeholder="请输入"
                clearable
                :disabled="operationType === 'view'"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="30">
          <el-col :span="12">
            <el-form-item label="合同金额(元):" prop="contractAmount">
              <el-input-number
                v-model="form.contractAmount"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                :disabled="operationType === 'view'"
              />
            </el-form-item>
@@ -1519,7 +1409,6 @@
        trigger: "change",
      },
    ],
    executionDate: [{ required: true, message: "请选择", trigger: "change" }],
  },
});
const { form, rules } = toRefs(data);
@@ -2020,7 +1909,6 @@
// ä¸»è¡¨åˆè®¡æ–¹æ³•
const summarizeMainTable = (param) => {
  return proxy.summarizeTable(param, [
    "contractAmount",
    "taxInclusiveTotalPrice",
    "taxExclusiveTotalPrice",
  ]);
vite.config.js
@@ -8,7 +8,7 @@
  const { VITE_APP_ENV } = env;
  const baseUrl =
      env.VITE_APP_ENV === "development"
          ? "http://127.0.0.1:7005"
          ? (process.env.VITE_DEV_API_TARGET || "http://127.0.0.1:7006")
          : env.VITE_BASE_API;
  const javaUrl =
      env.VITE_APP_ENV === "development"