From 465945db554196f1d91cd49d2ed27611e954d200 Mon Sep 17 00:00:00 2001 From: ymj <522495731@qq.com> Date: Thu, 21 May 2026 14:24:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=8C=E7=8E=B0=E5=9C=A8=E8=BF=99=E4=B8=AA=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E5=9F=BA=E7=A1=80=E5=B7=B2=E7=BB=8F=E5=AE=8C=E6=88=90?= =?UTF-8?q?=EF=BC=8C=E7=AD=89=E5=BE=85=E5=90=8E=E7=BB=AD=E7=9A=84=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/sql/init_cpu_guard.sql | 17 +- .../2026-05-21_project_geo_modules.sql | 263 ++++++++ packages/backend/src/entities.ts | 96 +-- packages/backend/src/modules/base/menu.json | 173 ++++- packages/backend/src/modules/geo/config.ts | 19 + .../modules/geo/controller/admin/account.ts | 65 ++ .../modules/geo/controller/admin/proxy_ip.ts | 43 ++ .../backend/src/modules/geo/entity/account.ts | 48 ++ .../src/modules/geo/entity/proxy_ip.ts | 66 ++ .../modules/geo/provider/proxy/interface.ts | 32 + .../src/modules/geo/provider/proxy/local.ts | 34 + .../src/modules/geo/provider/proxy/tianqi.ts | 17 + .../src/modules/geo/service/account.ts | 479 ++++++++++++++ .../src/modules/geo/service/encrypt.ts | 51 ++ .../src/modules/geo/service/proxy_ip.ts | 188 ++++++ .../backend/src/modules/project/config.ts | 19 + .../modules/project/controller/admin/info.ts | 22 + .../modules/project/controller/admin/phase.ts | 21 + .../modules/project/controller/admin/task.ts | 79 +++ .../controller/admin/task_dependency.ts | 16 + .../project/controller/admin/time_log.ts | 21 + .../src/modules/project/entity/info.ts | 36 ++ .../src/modules/project/entity/phase.ts | 33 + .../src/modules/project/entity/task.ts | 63 ++ .../modules/project/entity/task_dependency.ts | 19 + .../src/modules/project/entity/time_log.ts | 28 + .../src/modules/project/service/gantt.ts | 134 ++++ .../src/modules/project/service/info.ts | 92 +++ .../src/modules/project/service/phase.ts | 73 +++ .../src/modules/project/service/task.ts | 240 +++++++ .../src/modules/project/service/time_log.ts | 78 +++ packages/frontend/src/modules/geo/config.ts | 26 + .../src/modules/geo/views/accounts.vue | 589 ++++++++++++++++++ .../src/modules/geo/views/dashboard.vue | 17 + .../src/modules/geo/views/proxies.vue | 304 +++++++++ .../frontend/src/modules/project/config.ts | 21 + .../src/modules/project/store/project.ts | 114 ++++ .../project/views/components/calendar.vue | 76 +++ .../project/views/components/gantt.vue | 183 ++++++ .../project/views/components/kanban.vue | 158 +++++ .../views/components/phase-manager.vue | 143 +++++ .../project/views/components/table.vue | 454 ++++++++++++++ .../project/views/components/task-drawer.vue | 281 +++++++++ .../views/components/time-log-dialog.vue | 71 +++ .../src/modules/project/views/detail.vue | 179 ++++++ .../src/modules/project/views/list.vue | 296 +++++++++ 46 files changed, 5403 insertions(+), 74 deletions(-) create mode 100644 packages/backend/sql/migration/2026-05-21_project_geo_modules.sql create mode 100644 packages/backend/src/modules/geo/config.ts create mode 100644 packages/backend/src/modules/geo/controller/admin/account.ts create mode 100644 packages/backend/src/modules/geo/controller/admin/proxy_ip.ts create mode 100644 packages/backend/src/modules/geo/entity/account.ts create mode 100644 packages/backend/src/modules/geo/entity/proxy_ip.ts create mode 100644 packages/backend/src/modules/geo/provider/proxy/interface.ts create mode 100644 packages/backend/src/modules/geo/provider/proxy/local.ts create mode 100644 packages/backend/src/modules/geo/provider/proxy/tianqi.ts create mode 100644 packages/backend/src/modules/geo/service/account.ts create mode 100644 packages/backend/src/modules/geo/service/encrypt.ts create mode 100644 packages/backend/src/modules/geo/service/proxy_ip.ts create mode 100644 packages/backend/src/modules/project/config.ts create mode 100644 packages/backend/src/modules/project/controller/admin/info.ts create mode 100644 packages/backend/src/modules/project/controller/admin/phase.ts create mode 100644 packages/backend/src/modules/project/controller/admin/task.ts create mode 100644 packages/backend/src/modules/project/controller/admin/task_dependency.ts create mode 100644 packages/backend/src/modules/project/controller/admin/time_log.ts create mode 100644 packages/backend/src/modules/project/entity/info.ts create mode 100644 packages/backend/src/modules/project/entity/phase.ts create mode 100644 packages/backend/src/modules/project/entity/task.ts create mode 100644 packages/backend/src/modules/project/entity/task_dependency.ts create mode 100644 packages/backend/src/modules/project/entity/time_log.ts create mode 100644 packages/backend/src/modules/project/service/gantt.ts create mode 100644 packages/backend/src/modules/project/service/info.ts create mode 100644 packages/backend/src/modules/project/service/phase.ts create mode 100644 packages/backend/src/modules/project/service/task.ts create mode 100644 packages/backend/src/modules/project/service/time_log.ts create mode 100644 packages/frontend/src/modules/geo/config.ts create mode 100644 packages/frontend/src/modules/geo/views/accounts.vue create mode 100644 packages/frontend/src/modules/geo/views/dashboard.vue create mode 100644 packages/frontend/src/modules/geo/views/proxies.vue create mode 100644 packages/frontend/src/modules/project/config.ts create mode 100644 packages/frontend/src/modules/project/store/project.ts create mode 100644 packages/frontend/src/modules/project/views/components/calendar.vue create mode 100644 packages/frontend/src/modules/project/views/components/gantt.vue create mode 100644 packages/frontend/src/modules/project/views/components/kanban.vue create mode 100644 packages/frontend/src/modules/project/views/components/phase-manager.vue create mode 100644 packages/frontend/src/modules/project/views/components/table.vue create mode 100644 packages/frontend/src/modules/project/views/components/task-drawer.vue create mode 100644 packages/frontend/src/modules/project/views/components/time-log-dialog.vue create mode 100644 packages/frontend/src/modules/project/views/detail.vue create mode 100644 packages/frontend/src/modules/project/views/list.vue diff --git a/packages/backend/sql/init_cpu_guard.sql b/packages/backend/sql/init_cpu_guard.sql index ae18e3d..de24b66 100644 --- a/packages/backend/sql/init_cpu_guard.sql +++ b/packages/backend/sql/init_cpu_guard.sql @@ -56,6 +56,17 @@ USE `cpu_guard`; -- audit_order - 审核订单 -- audit_result - 审核结果 -- +-- project 模块(项目管理): +-- project_info - 项目信息 +-- project_phase - 项目阶段 +-- project_task - 项目任务 +-- project_task_dependency - 任务依赖 +-- project_time_log - 工时记录 +-- +-- geo 模块(账号环境管理): +-- geo_account - 账号矩阵 +-- geo_proxy_ip - 代理 IP 池 +-- -- user 模块(应用用户): -- user_info - 用户信息 -- user_wx - 微信用户 @@ -90,9 +101,3 @@ USE `cpu_guard`; -- demo 模块(示例): -- demo_goods - 示例商品 -- --- ============================================================ --- 已移除模块(不再创建): --- project_info, project_phase, project_task, --- project_task_dependency, project_time_log --- geo_account, geo_proxy_ip --- ============================================================ diff --git a/packages/backend/sql/migration/2026-05-21_project_geo_modules.sql b/packages/backend/sql/migration/2026-05-21_project_geo_modules.sql new file mode 100644 index 0000000..27bba1d --- /dev/null +++ b/packages/backend/sql/migration/2026-05-21_project_geo_modules.sql @@ -0,0 +1,263 @@ +-- 2026-05-21_project_geo_modules.sql +-- 迁移源项目的项目管理与账号环境管理模块。 +-- 适用:MySQL 8 / InnoDB / utf8mb4。生产环境 typeorm.synchronize=false 时请先执行本脚本。 + +CREATE TABLE IF NOT EXISTS `project_info` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `name` varchar(100) NOT NULL COMMENT '项目名称', + `description` text NULL COMMENT '项目描述', + `status` int NOT NULL DEFAULT 0 COMMENT '状态 0未开始 1进行中 2已完成 3已归档', + `startDate` date NULL COMMENT '计划开始日期', + `endDate` date NULL COMMENT '计划结束日期', + `progress` int NOT NULL DEFAULT 0 COMMENT '进度百分比 0-100', + `ownerId` int NULL COMMENT '项目经理ID', + `ownerName` varchar(50) NULL COMMENT '项目经理姓名', + `color` varchar(20) NULL COMMENT '主题色', + PRIMARY KEY (`id`), + KEY `IDX_project_info_createTime` (`createTime`), + KEY `IDX_project_info_updateTime` (`updateTime`), + KEY `IDX_project_info_tenantId` (`tenantId`), + KEY `IDX_project_info_ownerId` (`ownerId`), + KEY `IDX_project_info_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目信息'; + +CREATE TABLE IF NOT EXISTS `project_phase` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `projectId` int NOT NULL COMMENT '所属项目ID', + `name` varchar(100) NOT NULL COMMENT '阶段名称', + `type` varchar(50) NULL COMMENT '分类', + `status` int NOT NULL DEFAULT 0 COMMENT '状态 0未开始 1进行中 2已完成', + `startDate` date NULL COMMENT '开始日期', + `endDate` date NULL COMMENT '结束日期', + `progress` int NOT NULL DEFAULT 0 COMMENT '进度 0-100', + `sortOrder` int NOT NULL DEFAULT 0 COMMENT '排序序号', + PRIMARY KEY (`id`), + KEY `IDX_project_phase_createTime` (`createTime`), + KEY `IDX_project_phase_updateTime` (`updateTime`), + KEY `IDX_project_phase_tenantId` (`tenantId`), + KEY `IDX_project_phase_projectId` (`projectId`), + KEY `IDX_project_phase_project_sort` (`projectId`, `sortOrder`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目阶段'; + +CREATE TABLE IF NOT EXISTS `project_task` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `projectId` int NOT NULL COMMENT '所属项目ID', + `phaseId` int NULL COMMENT '所属阶段ID', + `parentId` int NULL COMMENT '父任务ID', + `name` varchar(200) NOT NULL COMMENT '任务名称', + `description` text NULL COMMENT '任务描述', + `status` int NOT NULL DEFAULT 0 COMMENT '状态 0待办 1进行中 2已完成 3已关闭', + `priority` int NOT NULL DEFAULT 2 COMMENT '优先级 0紧急 1高 2中 3低', + `category` varchar(50) NULL COMMENT '分类', + `assigneeId` int NULL COMMENT '负责人ID', + `assigneeName` varchar(50) NULL COMMENT '负责人姓名', + `startDate` date NULL COMMENT '计划开始日期', + `endDate` date NULL COMMENT '计划结束日期', + `estimatedHours` decimal(8,1) NOT NULL DEFAULT 0.0 COMMENT '预估工时(小时)', + `actualHours` decimal(8,1) NOT NULL DEFAULT 0.0 COMMENT '实际工时(小时)', + `progress` int NOT NULL DEFAULT 0 COMMENT '进度 0-100', + `sortOrder` int NOT NULL DEFAULT 0 COMMENT '排序序号', + `color` varchar(20) NULL COMMENT '自定义颜色', + PRIMARY KEY (`id`), + KEY `IDX_project_task_createTime` (`createTime`), + KEY `IDX_project_task_updateTime` (`updateTime`), + KEY `IDX_project_task_tenantId` (`tenantId`), + KEY `IDX_project_task_projectId` (`projectId`), + KEY `IDX_project_task_phaseId` (`phaseId`), + KEY `IDX_project_task_parentId` (`parentId`), + KEY `IDX_project_task_assigneeId` (`assigneeId`), + KEY `IDX_project_task_project_status` (`projectId`, `status`, `sortOrder`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目任务'; + +CREATE TABLE IF NOT EXISTS `project_task_dependency` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `taskId` int NOT NULL COMMENT '当前任务ID', + `dependsOnTaskId` int NOT NULL COMMENT '前置任务ID', + `type` int NOT NULL DEFAULT 0 COMMENT '依赖类型 0:FS 1:SS 2:FF 3:SF', + PRIMARY KEY (`id`), + KEY `IDX_project_task_dependency_createTime` (`createTime`), + KEY `IDX_project_task_dependency_updateTime` (`updateTime`), + KEY `IDX_project_task_dependency_tenantId` (`tenantId`), + KEY `IDX_project_task_dependency_taskId` (`taskId`), + KEY `IDX_project_task_dependency_dependsOnTaskId` (`dependsOnTaskId`), + UNIQUE KEY `UK_project_task_dependency_pair` (`taskId`, `dependsOnTaskId`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务依赖关系'; + +CREATE TABLE IF NOT EXISTS `project_time_log` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `taskId` int NOT NULL COMMENT '所属任务ID', + `userId` int NOT NULL COMMENT '记录人ID', + `userName` varchar(50) NOT NULL COMMENT '记录人姓名', + `logDate` date NOT NULL COMMENT '工作日期', + `hours` decimal(5,1) NOT NULL COMMENT '工时(小时)', + `description` varchar(500) NULL COMMENT '工作内容描述', + PRIMARY KEY (`id`), + KEY `IDX_project_time_log_createTime` (`createTime`), + KEY `IDX_project_time_log_updateTime` (`updateTime`), + KEY `IDX_project_time_log_tenantId` (`tenantId`), + KEY `IDX_project_time_log_taskId` (`taskId`), + KEY `IDX_project_time_log_userId` (`userId`), + KEY `IDX_project_time_log_task_date` (`taskId`, `logDate`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工时记录'; + +CREATE TABLE IF NOT EXISTS `geo_proxy_ip` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `name` varchar(255) NOT NULL COMMENT '名称', + `provider` varchar(32) NOT NULL COMMENT '所属 Provider: local / tianqi', + `mode` varchar(16) NOT NULL COMMENT '模式: local / third_party', + `host` varchar(128) NULL COMMENT '代理主机', + `port` int NULL COMMENT '代理端口', + `protocol` varchar(8) NOT NULL DEFAULT 'http' COMMENT '协议 http/socks5', + `username` varchar(256) NULL COMMENT '用户名(明文)', + `password` varchar(512) NULL COMMENT '密码(明文)', + `region` varchar(64) NULL COMMENT '区域', + `isp` varchar(32) NULL COMMENT 'ISP', + `exitIp` varchar(64) NULL COMMENT '出口 IP(与代理 IP 不同时填,测试得出)', + `city` varchar(64) NULL COMMENT '开通城市', + `packageId` varchar(64) NULL COMMENT '所属套餐 ID', + `externalId` varchar(128) NULL COMMENT 'Provider 侧外部 ID', + `bindAccountId` int NULL COMMENT '绑定账号 ID(强 1:1)', + `status` varchar(16) NOT NULL DEFAULT 'unbound' COMMENT '状态 active/expired/error/unbound', + `latencyMs` int NULL COMMENT '健康检查延迟 ms', + `lastCheckAt` datetime NULL COMMENT '上次检查时间', + `expiresAt` datetime NULL COMMENT '过期时间', + `extra` json NULL COMMENT '扩展字段', + PRIMARY KEY (`id`), + KEY `IDX_geo_proxy_ip_createTime` (`createTime`), + KEY `IDX_geo_proxy_ip_updateTime` (`updateTime`), + KEY `IDX_geo_proxy_ip_tenantId` (`tenantId`), + KEY `IDX_geo_proxy_ip_provider` (`provider`), + KEY `IDX_geo_proxy_ip_mode` (`mode`), + KEY `IDX_geo_proxy_ip_status` (`status`), + KEY `IDX_geo_proxy_ip_packageId` (`packageId`), + UNIQUE KEY `UK_geo_proxy_ip_bindAccountId` (`bindAccountId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='代理 IP 池'; + +CREATE TABLE IF NOT EXISTS `geo_account` ( + `id` int NOT NULL AUTO_INCREMENT COMMENT 'ID', + `createTime` varchar(255) NOT NULL COMMENT '创建时间', + `updateTime` varchar(255) NOT NULL COMMENT '更新时间', + `tenantId` int NULL COMMENT '租户ID', + `name` varchar(255) NOT NULL COMMENT '账号昵称(用户填写)', + `platform` varchar(32) NOT NULL COMMENT '平台 xiaohongshu/douyin/weibo/zhihu/taobao/wechat', + `loginAccount` varchar(128) NULL COMMENT '登录账号/手机号', + `sessionName` varchar(128) NULL COMMENT 'netabrowser-cli sessionName', + `agentId` int NULL COMMENT '关联 Agent ID', + `fingerprintSeed` int NULL COMMENT '浏览器指纹 seed', + `cookies` text NULL COMMENT 'cookies(明文 JSON,本地后台工具)', + `cookieCapturedAt` datetime NULL COMMENT 'Cookie 抓取时间', + `cookieExpiresAt` datetime NULL COMMENT 'Cookie 最早过期时间', + `loginStatus` varchar(16) NOT NULL DEFAULT 'never' COMMENT '登录状态 never/logged_in/expired/banned/deleted', + `proxyId` int NULL COMMENT '绑定 IP', + `lastActiveAt` datetime NULL COMMENT '上次活跃时间', + `extra` json NULL COMMENT '平台特定字段', + PRIMARY KEY (`id`), + KEY `IDX_geo_account_createTime` (`createTime`), + KEY `IDX_geo_account_updateTime` (`updateTime`), + KEY `IDX_geo_account_tenantId` (`tenantId`), + KEY `IDX_geo_account_platform` (`platform`), + KEY `IDX_geo_account_agentId` (`agentId`), + KEY `IDX_geo_account_loginStatus` (`loginStatus`), + UNIQUE KEY `UK_geo_account_sessionName` (`sessionName`), + UNIQUE KEY `UK_geo_account_proxyId` (`proxyId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账号矩阵'; + +SET @now = DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT NULL, '项目管理', NULL, NULL, 0, 'icon-task', 9, NULL, 1, 0, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `name` = '项目管理' AND `parentId` IS NULL); + +SET @project_parent_id = ( + SELECT `id` FROM `base_sys_menu` + WHERE `name` = '项目管理' AND `parentId` IS NULL + ORDER BY `id` DESC LIMIT 1 +); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @project_parent_id, '项目列表', '/project/list', NULL, 1, 'icon-list', 0, 'modules/project/views/list.vue', 1, 0, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `router` = '/project/list'); + +SET @project_list_id = (SELECT `id` FROM `base_sys_menu` WHERE `router` = '/project/list' ORDER BY `id` DESC LIMIT 1); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @project_list_id, '权限', NULL, + 'project:info:page,project:info:list,project:info:info,project:info:add,project:info:update,project:info:delete,project:phase:page,project:phase:list,project:phase:info,project:phase:add,project:phase:update,project:phase:delete,project:task:page,project:task:list,project:task:info,project:task:add,project:task:update,project:task:delete,project:task:tree,project:task:ganttData,project:task:ganttUpdate,project:task:kanban,project:task:kanbanSort,project:task:hasChildren,project:task:cascadeStatus,project:task_dependency:list,project:task_dependency:add,project:task_dependency:delete,project:time_log:page,project:time_log:add,project:time_log:delete', + 2, NULL, 0, NULL, 1, 0, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `parentId` = @project_list_id AND `name` = '权限'); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT NULL, '账号环境管理', NULL, NULL, 0, 'icon-data', 12, NULL, 1, 1, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `name` = '账号环境管理' AND `parentId` IS NULL); + +SET @geo_parent_id = ( + SELECT `id` FROM `base_sys_menu` + WHERE `name` = '账号环境管理' AND `parentId` IS NULL + ORDER BY `id` DESC LIMIT 1 +); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @geo_parent_id, '环境总览', '/geo/dashboard', NULL, 1, 'icon-count', 0, 'modules/geo/views/dashboard.vue', 1, 1, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `router` = '/geo/dashboard'); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @geo_parent_id, '账号矩阵', '/geo/accounts', NULL, 1, 'icon-user', 1, 'modules/geo/views/accounts.vue', 1, 1, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `router` = '/geo/accounts'); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @geo_parent_id, 'IP 池', '/geo/proxies', NULL, 1, 'icon-data', 2, 'modules/geo/views/proxies.vue', 1, 1, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `router` = '/geo/proxies'); + +SET @geo_accounts_id = (SELECT `id` FROM `base_sys_menu` WHERE `router` = '/geo/accounts' ORDER BY `id` DESC LIMIT 1); +SET @geo_proxies_id = (SELECT `id` FROM `base_sys_menu` WHERE `router` = '/geo/proxies' ORDER BY `id` DESC LIMIT 1); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @geo_accounts_id, '权限', NULL, + 'geo:account:page,geo:account:list,geo:account:info,geo:account:add,geo:account:update,geo:account:delete,geo:account:launch,geo:account:close,geo:account:captureCookies,geo:account:resetSession,geo:account:rebindIp,geo:account:setProxy,geo:account:deleteAccount,netaclaw:agent:options', + 2, NULL, 0, NULL, 1, 1, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `parentId` = @geo_accounts_id AND `name` = '权限'); + +INSERT INTO `base_sys_menu` +(`parentId`, `name`, `router`, `perms`, `type`, `icon`, `orderNum`, `viewPath`, `keepAlive`, `isShow`, `createTime`, `updateTime`, `tenantId`) +SELECT @geo_proxies_id, '权限', NULL, + 'geo:proxy_ip:page,geo:proxy_ip:list,geo:proxy_ip:info,geo:proxy_ip:add,geo:proxy_ip:update,geo:proxy_ip:delete,geo:proxy_ip:test,geo:proxy_ip:healthCheck,geo:proxy_ip:healthCheckAll', + 2, NULL, 0, NULL, 1, 1, @now, @now, NULL +WHERE NOT EXISTS (SELECT 1 FROM `base_sys_menu` WHERE `parentId` = @geo_proxies_id AND `name` = '权限'); + +INSERT INTO `base_sys_role_menu` (`roleId`, `menuId`, `createTime`, `updateTime`, `tenantId`) +SELECT 1, m.`id`, @now, @now, NULL +FROM `base_sys_menu` m +WHERE ( + m.`name` IN ('项目管理', '项目列表', '账号环境管理', '环境总览', '账号矩阵', 'IP 池') + OR (m.`name` = '权限' AND m.`parentId` IN (@project_list_id, @geo_accounts_id, @geo_proxies_id)) + ) + AND NOT EXISTS ( + SELECT 1 FROM `base_sys_role_menu` rm + WHERE rm.`roleId` = 1 AND rm.`menuId` = m.`id` + ); diff --git a/packages/backend/src/entities.ts b/packages/backend/src/entities.ts index 1cb5232..2e37fe5 100644 --- a/packages/backend/src/entities.ts +++ b/packages/backend/src/entities.ts @@ -7,44 +7,54 @@ import * as entity4 from './modules/task/entity/info'; import * as entity5 from './modules/space/entity/type'; import * as entity6 from './modules/space/entity/info'; import * as entity7 from './modules/recycle/entity/data'; -import * as entity8 from './modules/plugin/entity/info'; -import * as entity9 from './modules/notification/entity/user'; -import * as entity10 from './modules/notification/entity/log'; -import * as entity11 from './modules/netaclaw/entity/tool'; -import * as entity12 from './modules/netaclaw/entity/subagent_session'; -import * as entity13 from './modules/netaclaw/entity/skill'; -import * as entity14 from './modules/netaclaw/entity/session'; -import * as entity15 from './modules/netaclaw/entity/model_channel'; -import * as entity16 from './modules/netaclaw/entity/message'; -import * as entity17 from './modules/netaclaw/entity/memory_type'; -import * as entity18 from './modules/netaclaw/entity/memory'; -import * as entity19 from './modules/netaclaw/entity/data_source_query_audit'; -import * as entity20 from './modules/netaclaw/entity/data_source'; -import * as entity21 from './modules/netaclaw/entity/crew_task'; -import * as entity22 from './modules/netaclaw/entity/crew_run'; -import * as entity23 from './modules/netaclaw/entity/crew_agent'; -import * as entity24 from './modules/netaclaw/entity/crew'; -import * as entity25 from './modules/netaclaw/entity/agent_session_entry'; -import * as entity26 from './modules/netaclaw/entity/agent_session'; -import * as entity27 from './modules/netaclaw/entity/agent_channel_group'; -import * as entity28 from './modules/netaclaw/entity/agent_channel'; -import * as entity29 from './modules/netaclaw/entity/agent'; -import * as entity30 from './modules/dict/entity/type'; -import * as entity31 from './modules/dict/entity/info'; -import * as entity32 from './modules/desktop_op/entity/desktop_op_config'; -import * as entity33 from './modules/desktop_op/entity/desktop_op_action_log'; -import * as entity34 from './modules/demo/entity/goods'; -import * as entity35 from './modules/base/entity/base'; -import * as entity36 from './modules/base/entity/sys/user_role'; -import * as entity37 from './modules/base/entity/sys/user'; -import * as entity38 from './modules/base/entity/sys/role_menu'; -import * as entity39 from './modules/base/entity/sys/role_department'; -import * as entity40 from './modules/base/entity/sys/role'; -import * as entity41 from './modules/base/entity/sys/param'; -import * as entity42 from './modules/base/entity/sys/menu'; -import * as entity43 from './modules/base/entity/sys/log'; -import * as entity44 from './modules/base/entity/sys/department'; -import * as entity45 from './modules/base/entity/sys/conf'; +import * as entity8 from './modules/project/entity/time_log'; +import * as entity9 from './modules/project/entity/task_dependency'; +import * as entity10 from './modules/project/entity/task'; +import * as entity11 from './modules/project/entity/phase'; +import * as entity12 from './modules/project/entity/info'; +import * as entity13 from './modules/plugin/entity/info'; +import * as entity14 from './modules/notification/entity/user'; +import * as entity15 from './modules/notification/entity/log'; +import * as entity16 from './modules/netaclaw/entity/tool'; +import * as entity17 from './modules/netaclaw/entity/subagent_session'; +import * as entity18 from './modules/netaclaw/entity/skill'; +import * as entity19 from './modules/netaclaw/entity/session'; +import * as entity20 from './modules/netaclaw/entity/model_channel'; +import * as entity21 from './modules/netaclaw/entity/message'; +import * as entity22 from './modules/netaclaw/entity/memory_type'; +import * as entity23 from './modules/netaclaw/entity/memory'; +import * as entity24 from './modules/netaclaw/entity/data_source_query_audit'; +import * as entity25 from './modules/netaclaw/entity/data_source'; +import * as entity26 from './modules/netaclaw/entity/crew_task'; +import * as entity27 from './modules/netaclaw/entity/crew_run'; +import * as entity28 from './modules/netaclaw/entity/crew_agent'; +import * as entity29 from './modules/netaclaw/entity/crew'; +import * as entity30 from './modules/netaclaw/entity/agent_session_entry'; +import * as entity31 from './modules/netaclaw/entity/agent_session'; +import * as entity32 from './modules/netaclaw/entity/agent_channel_group'; +import * as entity33 from './modules/netaclaw/entity/agent_channel'; +import * as entity34 from './modules/netaclaw/entity/agent'; +import * as entity35 from './modules/geo/entity/proxy_ip'; +import * as entity36 from './modules/geo/entity/account'; +import * as entity37 from './modules/dict/entity/type'; +import * as entity38 from './modules/dict/entity/info'; +import * as entity39 from './modules/desktop_op/entity/desktop_op_config'; +import * as entity40 from './modules/desktop_op/entity/desktop_op_action_log'; +import * as entity41 from './modules/demo/entity/goods'; +import * as entity42 from './modules/base/entity/base'; +import * as entity43 from './modules/base/entity/sys/user_role'; +import * as entity44 from './modules/base/entity/sys/user'; +import * as entity45 from './modules/base/entity/sys/role_menu'; +import * as entity46 from './modules/base/entity/sys/role_department'; +import * as entity47 from './modules/base/entity/sys/role'; +import * as entity48 from './modules/base/entity/sys/param'; +import * as entity49 from './modules/base/entity/sys/menu'; +import * as entity50 from './modules/base/entity/sys/log'; +import * as entity51 from './modules/base/entity/sys/department'; +import * as entity52 from './modules/base/entity/sys/conf'; +import * as entity53 from './modules/audit/entity/order_detail'; +import * as entity54 from './modules/audit/entity/order'; +import * as entity55 from './modules/audit/entity/config'; export const entities = [ ...Object.values(entity0), ...Object.values(entity1), @@ -92,4 +102,14 @@ export const entities = [ ...Object.values(entity43), ...Object.values(entity44), ...Object.values(entity45), + ...Object.values(entity46), + ...Object.values(entity47), + ...Object.values(entity48), + ...Object.values(entity49), + ...Object.values(entity50), + ...Object.values(entity51), + ...Object.values(entity52), + ...Object.values(entity53), + ...Object.values(entity54), + ...Object.values(entity55), ]; diff --git a/packages/backend/src/modules/base/menu.json b/packages/backend/src/modules/base/menu.json index 4e7d75f..95d4195 100644 --- a/packages/backend/src/modules/base/menu.json +++ b/packages/backend/src/modules/base/menu.json @@ -463,7 +463,7 @@ "orderNum": 98, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [ { "name": "文档官网", @@ -474,7 +474,7 @@ "orderNum": 0, "viewPath": "https://admin.cool-js.com", "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -486,7 +486,7 @@ "orderNum": 1, "viewPath": "modules/demo/views/crud/index.vue", "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] } ] @@ -559,7 +559,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -571,7 +571,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -583,7 +583,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -595,7 +595,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -607,7 +607,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -619,7 +619,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -631,7 +631,7 @@ "orderNum": 0, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] }, { @@ -843,7 +843,7 @@ "orderNum": 8, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [ { "tenantId": null, @@ -855,7 +855,7 @@ "orderNum": 1, "viewPath": "modules/helper/views/plugins.vue", "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [ { "tenantId": null, @@ -957,7 +957,7 @@ "router": null, "perms": null, "type": 0, - "icon": "icon-ai", + "icon": "icon-workbench", "orderNum": 5, "viewPath": null, "keepAlive": true, @@ -968,7 +968,7 @@ "router": "/agent/chat", "perms": null, "type": 1, - "icon": "icon-chat", + "icon": "icon-msg", "orderNum": 0, "viewPath": "modules/agent/views/chat.vue", "keepAlive": true, @@ -980,7 +980,7 @@ "router": "/agent/agents", "perms": null, "type": 1, - "icon": "icon-agent", + "icon": "icon-user", "orderNum": 1, "viewPath": "modules/agent/views/agent-list.vue", "keepAlive": true, @@ -1005,7 +1005,7 @@ "router": "/agent/tools", "perms": null, "type": 1, - "icon": "icon-tool", + "icon": "icon-set", "orderNum": 2, "viewPath": "modules/agent/views/tools.vue", "keepAlive": true, @@ -1030,7 +1030,7 @@ "router": "/agent/skills", "perms": null, "type": 1, - "icon": "icon-skill", + "icon": "icon-work", "orderNum": 3, "viewPath": "modules/agent/views/skills.vue", "keepAlive": true, @@ -1055,7 +1055,7 @@ "router": "/agent/model-channel", "perms": null, "type": 1, - "icon": "icon-model", + "icon": "icon-db", "orderNum": 4, "viewPath": "modules/agent/views/model-channel.vue", "keepAlive": true, @@ -1080,7 +1080,7 @@ "router": "/agent/channel-management", "perms": null, "type": 1, - "icon": "icon-channel", + "icon": "icon-discover", "orderNum": 5, "viewPath": "modules/agent/views/channel-management.vue", "keepAlive": true, @@ -1105,7 +1105,7 @@ "router": "/agent/detection-result", "perms": null, "type": 1, - "icon": "icon-detection", + "icon": "icon-warn", "orderNum": 6, "viewPath": "modules/agent/views/detection-result.vue", "keepAlive": true, @@ -1117,7 +1117,7 @@ "router": "/agent/crew-editor", "perms": null, "type": 1, - "icon": "icon-crew", + "icon": "icon-workbench", "orderNum": 7, "viewPath": "modules/agent/views/crew-editor.vue", "keepAlive": true, @@ -1141,7 +1141,7 @@ "router": "/agent/memory", "perms": null, "type": 1, - "icon": "icon-memory", + "icon": "icon-file", "orderNum": 9, "viewPath": "modules/agent/views/memory.vue", "keepAlive": true, @@ -1168,7 +1168,7 @@ "router": null, "perms": null, "type": 0, - "icon": "icon-audit", + "icon": "icon-approve", "orderNum": 6, "viewPath": null, "keepAlive": true, @@ -1179,7 +1179,7 @@ "router": "/audit/orders", "perms": null, "type": 1, - "icon": "icon-order", + "icon": "icon-list", "orderNum": 0, "viewPath": "modules/audit/views/order-list.vue", "keepAlive": true, @@ -1204,7 +1204,7 @@ "router": "/audit/config", "perms": null, "type": 1, - "icon": "icon-config", + "icon": "icon-set", "orderNum": 1, "viewPath": "modules/audit/views/config.vue", "keepAlive": true, @@ -1229,7 +1229,7 @@ "router": "/audit/dashboard", "perms": null, "type": 1, - "icon": "icon-dashboard", + "icon": "icon-count", "orderNum": 2, "viewPath": "modules/audit/views/dashboard.vue", "keepAlive": true, @@ -1238,27 +1238,140 @@ } ] }, + { + "name": "项目管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-task", + "orderNum": 9, + "viewPath": null, + "keepAlive": true, + "isShow": false, + "childMenus": [ + { + "name": "项目列表", + "router": "/project/list", + "perms": null, + "type": 1, + "icon": "icon-list", + "orderNum": 0, + "viewPath": "modules/project/views/list.vue", + "keepAlive": true, + "isShow": false, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "project:info:page,project:info:list,project:info:info,project:info:add,project:info:update,project:info:delete,project:phase:page,project:phase:list,project:phase:info,project:phase:add,project:phase:update,project:phase:delete,project:task:page,project:task:list,project:task:info,project:task:add,project:task:update,project:task:delete,project:task:tree,project:task:ganttData,project:task:ganttUpdate,project:task:kanban,project:task:kanbanSort,project:task:hasChildren,project:task:cascadeStatus,project:task_dependency:list,project:task_dependency:add,project:task_dependency:delete,project:time_log:page,project:time_log:add,project:time_log:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": false, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "账号环境管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-data", + "orderNum": 12, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "环境总览", + "router": "/geo/dashboard", + "perms": null, + "type": 1, + "icon": "icon-count", + "orderNum": 0, + "viewPath": "modules/geo/views/dashboard.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "账号矩阵", + "router": "/geo/accounts", + "perms": null, + "type": 1, + "icon": "icon-user", + "orderNum": 1, + "viewPath": "modules/geo/views/accounts.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "geo:account:page,geo:account:list,geo:account:info,geo:account:add,geo:account:update,geo:account:delete,geo:account:launch,geo:account:close,geo:account:captureCookies,geo:account:resetSession,geo:account:rebindIp,geo:account:setProxy,geo:account:deleteAccount,netaclaw:agent:options", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "IP 池", + "router": "/geo/proxies", + "perms": null, + "type": 1, + "icon": "icon-data", + "orderNum": 2, + "viewPath": "modules/geo/views/proxies.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "geo:proxy_ip:page,geo:proxy_ip:list,geo:proxy_ip:info,geo:proxy_ip:add,geo:proxy_ip:update,geo:proxy_ip:delete,geo:proxy_ip:test,geo:proxy_ip:healthCheck,geo:proxy_ip:healthCheckAll", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, { "name": "本体建模", "router": null, "perms": null, "type": 0, - "icon": "icon-tree", + "icon": "icon-component", "orderNum": 10, "viewPath": null, "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [ { "name": "模型树管理", "router": "/ontology/model-tree", "perms": null, "type": 1, - "icon": "icon-tree", + "icon": "icon-component", "orderNum": 0, "viewPath": "modules/ontology/views/model-tree.vue", "keepAlive": true, - "isShow": true, + "isShow": false, "childMenus": [] } ] diff --git a/packages/backend/src/modules/geo/config.ts b/packages/backend/src/modules/geo/config.ts new file mode 100644 index 0000000..8e1ba68 --- /dev/null +++ b/packages/backend/src/modules/geo/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 账号环境模块配置 + */ +export default () => { + return { + // 模块名称 + name: '账号环境管理', + // 模块描述 + description: '账号矩阵、代理 IP、浏览器 profile 与 Agent 绑定管理', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 60, + } as ModuleConfig; +}; diff --git a/packages/backend/src/modules/geo/controller/admin/account.ts b/packages/backend/src/modules/geo/controller/admin/account.ts new file mode 100644 index 0000000..68abe58 --- /dev/null +++ b/packages/backend/src/modules/geo/controller/admin/account.ts @@ -0,0 +1,65 @@ +import { Provide, Inject, Post, Body } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { GeoAccountEntity } from '../../entity/account.js'; +import { GeoAccountService } from '../../service/account.js'; + +@Provide() +@CoolController({ + api: ['delete', 'update', 'info', 'list', 'page'], + entity: GeoAccountEntity, + service: GeoAccountService, + pageQueryOp: { + keyWordLikeFields: ['name', 'loginAccount', 'sessionName'], + fieldEq: ['platform', 'loginStatus', 'agentId'], + addOrderBy: { createTime: 'DESC' }, + }, +}) +export class AdminGeoAccountController extends BaseController { + @Inject() accountService: GeoAccountService; + + @Post('/add', { summary: '创建账号(绑定 IP + 关联 agent)' }) + async addAccount(@Body() dto: any) { + return this.ok(await this.accountService.add(dto)); + } + + @Post('/launch', { summary: '启动浏览器登录(通过 netabrowser-cli daemon)' }) + async launch(@Body('id') id: number, @Body('url') url?: string) { + return this.ok(await this.accountService.launch(id, url)); + } + + @Post('/close', { summary: '关闭浏览器' }) + async close(@Body('id') id: number) { + await this.accountService.close(id); + return this.ok(); + } + + @Post('/captureCookies', { summary: '抓 cookie' }) + async captureCookies(@Body('id') id: number, @Body('domain') domain?: string) { + return this.ok(await this.accountService.captureCookies(id, domain)); + } + + @Post('/rebindIp', { summary: '重新绑定 IP' }) + async rebindIp(@Body('id') id: number, @Body('newIpId') newIpId: number) { + await this.accountService.rebindIp(id, newIpId); + return this.ok(); + } + + @Post('/setProxy', { summary: '设置 IP 模式(本地/第三方双向切换)' }) + async setProxy(@Body() body: { id: number; proxyId?: number | null }) { + const proxyId = body.proxyId == null || body.proxyId === 0 ? null : body.proxyId; + await this.accountService.setProxy(body.id, proxyId); + return this.ok(); + } + + @Post('/resetSession', { summary: '重置会话(清 cookie + 新 sessionName + 新指纹,不动 IP)' }) + async resetSession(@Body('id') id: number) { + await this.accountService.resetSession(id); + return this.ok(); + } + + @Post('/deleteAccount', { summary: '逻辑删除账号' }) + async deleteAccount(@Body('id') id: number) { + await this.accountService.deleteAccount(id); + return this.ok(); + } +} diff --git a/packages/backend/src/modules/geo/controller/admin/proxy_ip.ts b/packages/backend/src/modules/geo/controller/admin/proxy_ip.ts new file mode 100644 index 0000000..33790f1 --- /dev/null +++ b/packages/backend/src/modules/geo/controller/admin/proxy_ip.ts @@ -0,0 +1,43 @@ +import { Provide, Inject, Post, Body } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { GeoProxyIpEntity } from '../../entity/proxy_ip.js'; +import { GeoProxyIpService } from '../../service/proxy_ip.js'; + +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GeoProxyIpEntity, + service: GeoProxyIpService, + pageQueryOp: { + keyWordLikeFields: ['name', 'host', 'exitIp', 'city', 'region'], + fieldEq: ['provider', 'mode', 'status', 'packageId'], + addOrderBy: { createTime: 'DESC' }, + }, +}) +export class AdminGeoProxyIpController extends BaseController { + @Inject() proxyIpService: GeoProxyIpService; + + @Post('/healthCheck', { summary: '触发单条 IP 健康检查' }) + async healthCheck(@Body('id') id: number) { + const ip = await this.proxyIpService.proxyIpEntity.findOneBy({ id }); + if (!ip) return this.fail('IP 不存在'); + const result = await this.proxyIpService.getProvider(ip.provider).healthCheck(this.proxyIpService.toProxyInfo(ip)); + await this.proxyIpService.proxyIpEntity.update(id, { + latencyMs: result.latencyMs, lastCheckAt: new Date(), + status: result.ok ? 'active' : 'error', + }); + return this.ok(result); + } + + @Post('/healthCheckAll', { summary: '批量健康检查' }) + async healthCheckAll() { + await this.proxyIpService.healthCheckAll(); + return this.ok(); + } + + @Post('/test', { summary: '测试单个 IP 连通性(入站+出站)' }) + async test(@Body('id') id: number) { + const r = await this.proxyIpService.testProxy(id); + return this.ok(r); + } +} diff --git a/packages/backend/src/modules/geo/entity/account.ts b/packages/backend/src/modules/geo/entity/account.ts new file mode 100644 index 0000000..66b0dfb --- /dev/null +++ b/packages/backend/src/modules/geo/entity/account.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity('geo_account') +export class GeoAccountEntity extends BaseEntity { + @Column({ comment: '账号昵称(用户填写)' }) + name: string; + + @Index() + @Column({ comment: '平台 xiaohongshu/douyin/weibo/zhihu/taobao/wechat', length: 32 }) + platform: string; + + @Column({ comment: '登录账号/手机号(登录后自动从浏览器抓取)', length: 128, nullable: true }) + loginAccount: string; + + @Index({ unique: true, where: 'session_name IS NOT NULL' }) + @Column({ comment: 'netabrowser-cli sessionName,唯一标识浏览器 profile', length: 128, nullable: true }) + sessionName: string; + + @Index() + @Column({ comment: '关联 Agent ID(哪个 Agent 来执行账号管理)', nullable: true }) + agentId: number; + + @Column({ comment: '浏览器指纹 seed(fingerprint-chromium 用,同账号每次启动指纹一致;不同账号 seed 不同→看作不同物理设备)', nullable: true }) + fingerprintSeed: number; + + @Column({ comment: 'cookies(明文 JSON,本地后台工具)', type: 'text', nullable: true }) + cookies: string; + + @Column({ comment: 'Cookie 抓取时间', type: 'datetime', nullable: true }) + cookieCapturedAt: Date; + + @Column({ comment: 'Cookie 最早过期时间(关键登录 cookie 中最早失效的)', type: 'datetime', nullable: true }) + cookieExpiresAt: Date; + + @Column({ comment: '登录状态 never/logged_in/expired/banned/deleted', length: 16, default: 'never' }) + loginStatus: string; + + @Index({ unique: true, where: 'proxy_id IS NOT NULL' }) + @Column({ comment: '绑定 IP', nullable: true }) + proxyId: number; + + @Column({ comment: '上次活跃时间', type: 'datetime', nullable: true }) + lastActiveAt: Date; + + @Column({ comment: '平台特定字段', type: 'json', nullable: true }) + extra: any; +} diff --git a/packages/backend/src/modules/geo/entity/proxy_ip.ts b/packages/backend/src/modules/geo/entity/proxy_ip.ts new file mode 100644 index 0000000..ec0d4a5 --- /dev/null +++ b/packages/backend/src/modules/geo/entity/proxy_ip.ts @@ -0,0 +1,66 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity('geo_proxy_ip') +export class GeoProxyIpEntity extends BaseEntity { + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '所属 Provider:local / tianqi', length: 32 }) + provider: string; + + @Column({ comment: '模式:local / third_party', length: 16 }) + mode: string; + + @Column({ comment: '代理主机', nullable: true, length: 128 }) + host: string; + + @Column({ comment: '代理端口', nullable: true }) + port: number; + + @Column({ comment: '协议 http/socks5', length: 8, default: 'http' }) + protocol: string; + + @Column({ comment: '用户名(明文)', nullable: true, length: 256 }) + username: string; + + @Column({ comment: '密码(明文)', nullable: true, length: 512 }) + password: string; + + @Column({ comment: '区域', nullable: true, length: 64 }) + region: string; + + @Column({ comment: 'ISP', nullable: true, length: 32 }) + isp: string; + + @Column({ comment: '出口 IP(与代理 IP 不同时填,测试得出)', nullable: true, length: 64 }) + exitIp: string; + + @Column({ comment: '开通城市(精确城市名,如「上海」)', nullable: true, length: 64 }) + city: string; + + @Column({ comment: '所属套餐 ID(如 202605031635378987)', nullable: true, length: 64 }) + packageId: string; + + @Column({ comment: 'Provider 侧外部 ID', nullable: true, length: 128 }) + externalId: string; + + @Index({ unique: true, where: 'bind_account_id IS NOT NULL' }) + @Column({ comment: '绑定账号 ID(强 1:1)', nullable: true }) + bindAccountId: number; + + @Column({ comment: '状态 active/expired/error/unbound', length: 16, default: 'unbound' }) + status: string; + + @Column({ comment: '健康检查延迟 ms', nullable: true }) + latencyMs: number; + + @Column({ comment: '上次检查时间', type: 'datetime', nullable: true }) + lastCheckAt: Date; + + @Column({ comment: '过期时间', type: 'datetime', nullable: true }) + expiresAt: Date; + + @Column({ comment: '扩展字段', type: 'json', nullable: true }) + extra: any; +} diff --git a/packages/backend/src/modules/geo/provider/proxy/interface.ts b/packages/backend/src/modules/geo/provider/proxy/interface.ts new file mode 100644 index 0000000..cff75a4 --- /dev/null +++ b/packages/backend/src/modules/geo/provider/proxy/interface.ts @@ -0,0 +1,32 @@ +export interface AcquireOpts { + region?: string; + isp?: string; + duration?: 'fixed' | 'rotating'; + extra?: Record; +} + +export interface ProxyInfo { + externalId: string; + mode: 'local' | 'third_party'; + protocol: 'http' | 'socks5'; + host?: string; + port?: number; + username?: string; + password?: string; + region?: string; + isp?: string; + expiresAt?: Date; +} + +export interface HealthCheckResult { + ok: boolean; + latencyMs: number; +} + +export interface IProxyProvider { + readonly name: string; + acquire(opts: AcquireOpts): Promise; + release(externalId: string): Promise; + healthCheck(p: ProxyInfo): Promise; + list?(): Promise; +} diff --git a/packages/backend/src/modules/geo/provider/proxy/local.ts b/packages/backend/src/modules/geo/provider/proxy/local.ts new file mode 100644 index 0000000..300f5a7 --- /dev/null +++ b/packages/backend/src/modules/geo/provider/proxy/local.ts @@ -0,0 +1,34 @@ +import { IProxyProvider, AcquireOpts, ProxyInfo, HealthCheckResult } from './interface.js'; + +const HEALTH_URL = 'https://www.baidu.com'; +const HEALTH_TIMEOUT = 5000; + +export class LocalProxyProvider implements IProxyProvider { + readonly name = 'local'; + + async acquire(opts: AcquireOpts): Promise { + return { + externalId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + mode: 'local', + protocol: 'http', + region: opts.region, + isp: opts.isp, + }; + } + + async release(_externalId: string): Promise {} + + async healthCheck(_p: ProxyInfo): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), HEALTH_TIMEOUT); + const start = Date.now(); + try { + const res = await fetch(HEALTH_URL, { signal: ctrl.signal, method: 'HEAD' }); + return { ok: res.ok, latencyMs: Date.now() - start }; + } catch { + return { ok: false, latencyMs: Date.now() - start }; + } finally { + clearTimeout(timer); + } + } +} diff --git a/packages/backend/src/modules/geo/provider/proxy/tianqi.ts b/packages/backend/src/modules/geo/provider/proxy/tianqi.ts new file mode 100644 index 0000000..8815135 --- /dev/null +++ b/packages/backend/src/modules/geo/provider/proxy/tianqi.ts @@ -0,0 +1,17 @@ +import type { AcquireOpts, HealthCheckResult, IProxyProvider, ProxyInfo } from './interface.js'; + +export class TianqiProxyProvider implements IProxyProvider { + readonly name = 'tianqi'; + + async acquire(opts: AcquireOpts): Promise { + throw new Error('NotImplemented: Tianqi 占位'); + } + + async release(externalId: string): Promise { + throw new Error('NotImplemented: Tianqi 占位'); + } + + async healthCheck(p: ProxyInfo): Promise { + throw new Error('NotImplemented: Tianqi 占位'); + } +} diff --git a/packages/backend/src/modules/geo/service/account.ts b/packages/backend/src/modules/geo/service/account.ts new file mode 100644 index 0000000..170d214 --- /dev/null +++ b/packages/backend/src/modules/geo/service/account.ts @@ -0,0 +1,479 @@ +import { Provide, Inject, Logger, ILogger } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel, InjectDataSource } from '@midwayjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import axios from 'axios'; +import { GeoAccountEntity } from '../entity/account.js'; +import { GeoProxyIpEntity } from '../entity/proxy_ip.js'; +import { GeoProxyIpService } from './proxy_ip.js'; + +export interface AddAccountDto { + name: string; // 昵称 + platform: string; // 平台 + proxyId?: number; // 第三方 IP 模式时填,本地模式不填 + agentId?: number; // 关联的 Agent ID + sessionName?: string; // 不填后端自动生成 `${platform}-${id}` + loginAccount?: string; // 一般留空,登录后回填 +} + +const NB_DAEMON_BASE = process.env.NB_DAEMON_BASE || 'http://127.0.0.1:8003'; + +@Provide() +export class GeoAccountService extends BaseService { + @InjectEntityModel(GeoAccountEntity) + accountEntity: Repository; + + @InjectEntityModel(GeoProxyIpEntity) + proxyIpEntity: Repository; + + @InjectDataSource() + dataSource: DataSource; + + @Inject() proxyService: GeoProxyIpService; + + @Logger() logger: ILogger; + + /** + * 新增账号: + * - IP 模式:不传 proxyId = 本地直连;传 proxyId = 第三方独立 IP(1:1 绑定) + * - 用户选择 agent(可选,由该 agent 执行后续操作) + * - sessionName 自动生成 `${platform}-${id}`,account 通过它跟 netabrowser-cli 的 profile 绑定 + * - loginAccount 通常留空,等用户在 launch 出来的浏览器里登录后从 cookie 自动获取 + */ + async add(dto: AddAccountDto): Promise { + // 1. 校验 IP(仅第三方模式) + if (dto.proxyId) { + const ip = await this.proxyIpEntity.findOneBy({ id: dto.proxyId }); + if (!ip) throw new Error(`IP ${dto.proxyId} 不存在`); + if (ip.bindAccountId) throw new Error(`IP ${dto.proxyId} 已被其他账号绑定`); + if (ip.status === 'deleted') throw new Error(`IP ${dto.proxyId} 已删除`); + } + + // 2. 事务:创建 account + 反向绑定 IP(如果有) + return this.dataSource.transaction(async manager => { + const repo = manager.getRepository(GeoAccountEntity); + let account = await repo.save(repo.create({ + name: dto.name, + platform: dto.platform, + loginAccount: dto.loginAccount || null, + loginStatus: 'never', + proxyId: dto.proxyId || null, + agentId: dto.agentId || null, + })); + // 自动生成 sessionName + fingerprintSeed(每个账号独立指纹,不同账号 seed 不同 → 看作不同物理设备) + // sessionName 带时间戳后缀,避免复用同名旧 profile(fingerprint-chromium 复用旧 profile 时 --fingerprint 不生效) + const sessionName = dto.sessionName || `${dto.platform}-${account.id}-${Date.now().toString(36)}`; + const fingerprintSeed = this.genFingerprintSeed(); + await repo.update(account.id, { sessionName, fingerprintSeed }); + account.sessionName = sessionName; + account.fingerprintSeed = fingerprintSeed; + // 绑定 IP(仅第三方模式) + if (dto.proxyId) { + await manager.update(GeoProxyIpEntity, dto.proxyId, { bindAccountId: account.id, status: 'active' }); + } + return account; + }); + } + + /** 生成随机 fingerprintSeed(1-2147483647,避开 0) */ + private genFingerprintSeed(): number { + return Math.floor(Math.random() * 2147483646) + 1; + } + + /** + * 启动浏览器(通过 netabrowser-cli daemon) + * - 用 account.sessionName 作为浏览器 session 标识 + * - 把账号绑定的 IP 作为 SOCKS5/HTTP 代理传给 daemon + */ + async launch(id: number, url?: string): Promise<{ sessionName: string; url: string }> { + const acc = await this.accountEntity.findOneBy({ id }); + if (!acc) throw new Error(`account ${id} not found`); + if (!acc.sessionName) throw new Error(`account ${id} sessionName 缺失`); + + // 拿绑定的 IP + let proxy: any; + if (acc.proxyId) { + const ip = await this.proxyIpEntity.findOneBy({ id: acc.proxyId }); + if (ip) { + const info = this.proxyService.toProxyInfo(ip); + proxy = { + server: `${info.protocol}://${info.host}:${info.port}`, + username: info.username, + password: info.password, + }; + } + } + + // 老账号没生成过 seed 的兜底(迁移用) + let fingerprintSeed = acc.fingerprintSeed; + if (!fingerprintSeed) { + fingerprintSeed = this.genFingerprintSeed(); + await this.accountEntity.update(id, { fingerprintSeed }); + } + + // 检测 profile 是否是 fresh(首次启动,没有 Cookies 文件) + const profileDir = await this.getProfileDir(acc.sessionName); + const isFreshProfile = !await this.profileHasCookies(profileDir); + + const targetUrl = url || this.guessLaunchUrl(acc.platform, acc.loginStatus); + // 尝试 open → 409 已存在则 goto 复用 → goto 也失败(浏览器被关了)则 close + 重新 open + let opened = false; + for (let attempt = 0; attempt < 2 && !opened; attempt++) { + try { + const r = await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/open`, { + sessionName: acc.sessionName, + url: targetUrl, + headed: true, + proxy, + fingerprintSeed, + }, { timeout: 90_000 }); + if (r.data?.code === 1000) { opened = true; break; } + throw new Error(r.data?.error || 'open failed'); + } catch (e: any) { + const is409 = e?.response?.status === 409 + || /already exists/i.test(e?.response?.data?.error || e?.message || ''); + if (!is409) throw e; + // session 已存在 → 尝试 goto 复用 + try { + const g = await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/goto`, { + sessionName: acc.sessionName, url: targetUrl, + }, { timeout: 60_000 }); + if (g.data?.code === 1000) { opened = true; break; } + throw new Error(g.data?.error || 'goto failed'); + } catch (gotoErr: any) { + // goto 也失败(浏览器窗口被关了)→ close 清理后下一轮 open + this.logger?.warn?.(`[GEO] session ${acc.sessionName} goto failed, closing and retrying: ${gotoErr.message}`); + await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/close`, { + sessionName: acc.sessionName, + }, { timeout: 15_000 }).catch(() => undefined); + } + } + } + if (!opened) throw new Error(`无法启动浏览器 session ${acc.sessionName}`); + + // Fresh profile + 数据库有 cookies → 注入数据库 cookies 避免重新登录 + if (isFreshProfile && acc.cookies) { + try { + const cookies = JSON.parse(acc.cookies); + if (Array.isArray(cookies) && cookies.length > 0) { + // 写临时文件给 daemon state-load 用 + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + const tmpFile = path.join(os.tmpdir(), `geo-state-${id}-${Date.now()}.json`); + await fs.writeFile(tmpFile, JSON.stringify({ cookies, origins: [] }), 'utf8'); + try { + await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/state-load`, { + sessionName: acc.sessionName, + filePath: tmpFile, + }, { timeout: 30_000 }); + this.logger?.info?.(`[GEO] account ${id} 注入 ${cookies.length} 条 cookie 到新 profile`); + // 注入 cookie 后 reload 页面让 cookie 生效 + await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/goto`, { + sessionName: acc.sessionName, + url: this.guessHomeUrl(acc.platform), + }, { timeout: 30_000 }).catch(() => undefined); + } finally { + await fs.unlink(tmpFile).catch(() => undefined); + } + } + } catch (e: any) { + this.logger?.warn?.(`[GEO] account ${id} cookie 注入失败: ${e.message}`); + } + } + + return { sessionName: acc.sessionName, url: targetUrl }; + } + + /** 拿账号 profile 目录的绝对路径 */ + private async getProfileDir(sessionName: string): Promise { + const path = await import('node:path'); + return path.resolve(process.cwd(), '..', '..', '.netabrowser-data', 'profiles', sessionName); + } + + /** 判断 profile 目录是否有 Cookies 文件(首次启动后才会创建) */ + private async profileHasCookies(profileDir: string): Promise { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + // chromium 把 cookies 存在 Default/Network/Cookies + const cookieFile = path.join(profileDir, 'Default', 'Network', 'Cookies'); + try { + const stat = await fs.stat(cookieFile); + return stat.size > 0; + } catch { + return false; + } + } + + /** 根据登录状态决定打开哪个 URL:已登录→首页,未登录→登录页 */ + private guessLaunchUrl(platform: string, loginStatus: string): string { + if (loginStatus === 'logged_in') return this.guessHomeUrl(platform); + return this.guessLoginUrl(platform); + } + + /** 平台首页(已登录时用) */ + private guessHomeUrl(platform: string): string { + const map: Record = { + taobao: 'https://www.taobao.com', + xiaohongshu: 'https://www.xiaohongshu.com', + douyin: 'https://www.douyin.com', + weibo: 'https://weibo.com', + zhihu: 'https://www.zhihu.com', + wechat: 'https://wx.qq.com', + }; + return map[platform] || 'https://www.baidu.com'; + } + + /** 平台登录页(未登录时用) */ + private guessLoginUrl(platform: string): string { + const map: Record = { + taobao: 'https://login.taobao.com/havanaone/login/login.htm', + xiaohongshu: 'https://www.xiaohongshu.com', + douyin: 'https://www.douyin.com', + weibo: 'https://passport.weibo.com/sso/signin', + zhihu: 'https://www.zhihu.com/signin', + wechat: 'https://wx.qq.com', + }; + return map[platform] || 'https://www.baidu.com'; + } + + /** + * 抓取 cookie:通过 netabrowser-cli daemon 的 cookie-list 接口 + * 抓完直接明文写库(本地后台工具,无需加密) + * 同时尝试从 cookie/页面 DOM 中提取登录账号(手机号/用户名),更新 status=active + */ + async captureCookies(id: number, domain?: string): Promise<{ captured: number; loginAccount?: string }> { + const acc = await this.accountEntity.findOneBy({ id }); + if (!acc) throw new Error(`account ${id} not found`); + if (!acc.sessionName) throw new Error(`account ${id} sessionName 缺失`); + + const r = await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/cookie-list`, { + sessionName: acc.sessionName, + domain, + }, { timeout: 30_000 }); + if (r.data?.code !== 1000) throw new Error(r.data?.error || 'cookie-list failed'); + const cookies: any[] = r.data.data || []; + if (!cookies.length) return { captured: 0 }; + + // 尝试从 cookie + DOM 提取登录账号(手机号/用户名/uid) + const loginAccount = await this.extractLoginAccount(acc.platform, acc.sessionName, cookies); + // 计算最早过期时间(剔除 session cookie 即 expires < 0) + const cookieExpiresAt = this.computeEarliestExpiry(cookies); + + await this.accountEntity.update(id, { + cookies: JSON.stringify(cookies), + cookieCapturedAt: new Date(), + cookieExpiresAt, + loginStatus: 'logged_in', + ...(loginAccount ? { loginAccount } : {}), + lastActiveAt: new Date(), + }); + return { captured: cookies.length, loginAccount }; + } + + /** 取所有非 session cookie 中最早的过期时间(即最先失效的关键登录 cookie) */ + private computeEarliestExpiry(cookies: any[]): Date | null { + let earliest: number | null = null; + for (const c of cookies) { + // playwright cookie expires 是 unix 秒(-1 表示 session cookie) + const e = typeof c.expires === 'number' ? c.expires : -1; + if (e <= 0) continue; + if (earliest === null || e < earliest) earliest = e; + } + return earliest ? new Date(earliest * 1000) : null; + } + + /** + * 尝试从 cookies 和页面 JS 里提取登录账号(手机号/用户名/uid) + * 各平台 cookie 字段不同,按平台分别处理;失败时返回 undefined + */ + private async extractLoginAccount(platform: string, sessionName: string, cookies: any[]): Promise { + const cookieMap = new Map(); + for (const c of cookies) cookieMap.set(c.name, c.value); + + // 1. 从 cookie 直接解析(淘宝/拼多多等会把昵称/uid 写在 cookie 里) + const cookieField = (platform: string): string | undefined => { + if (platform === 'taobao') { + // 淘宝:dnk = URL编码的昵称;tracknick = 昵称;t/_tb_token_ 是 uid 相关 + const dnk = cookieMap.get('dnk') || cookieMap.get('tracknick'); + if (dnk) { + try { return decodeURIComponent(dnk).replace(/^"|"$/g, '').replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))); } + catch { return dnk; } + } + } + if (platform === 'xiaohongshu') { + const u = cookieMap.get('webBuild') || cookieMap.get('userId'); + if (u) return u; + } + if (platform === 'douyin') { + const u = cookieMap.get('passport_csrf_token') || cookieMap.get('sessionid'); + if (u) return u.slice(0, 16) + '...'; // 太长截断 + } + return undefined; + }; + const fromCookie = cookieField(platform); + if (fromCookie) return fromCookie; + + // 2. 从页面 DOM 拿(兜底)—— 通过 daemon eval 执行 JS + try { + const r = await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/eval`, { + sessionName, + script: `(() => { + const sels = [ + '.site-nav-login-info-nick', '.user-nick', '[class*="user-name"]', '[class*="userName"]', + '[data-user-nick]', '.username', '.nick-name' + ]; + for (const s of sels) { + const el = document.querySelector(s); + if (el && el.textContent && el.textContent.trim().length > 0 && el.textContent.trim().length < 32) { + return el.textContent.trim(); + } + } + return null; + })()`, + }, { timeout: 10_000 }); + const v = r.data?.data?.result; + if (typeof v === 'string' && v.length > 0) return v; + } catch { /* 兜底失败就放弃 */ } + + return undefined; + } + + /** + * 重置账号会话:关浏览器 + 清 cookie + 重置 sessionName + 重置 fingerprintSeed + 删旧 profile 目录 + * 用于「无历史负担重新登录」场景。不动 IP 绑定。 + */ + async resetSession(accountId: number): Promise { + const acc = await this.accountEntity.findOneByOrFail({ id: accountId }); + const oldSessionName = acc.sessionName; + + // 1. 关闭可能正在跑的浏览器 + if (oldSessionName) { + await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/close`, { + sessionName: oldSessionName, + }, { timeout: 15_000 }).catch(() => undefined); + } + + // 2. 重置 sessionName + fingerprintSeed + 清 cookie 状态 + const newSessionName = `${acc.platform}-${accountId}-${Date.now().toString(36)}`; + await this.accountEntity.update(accountId, { + sessionName: newSessionName, + fingerprintSeed: this.genFingerprintSeed(), + cookies: null, + cookieCapturedAt: null, + cookieExpiresAt: null, + loginAccount: null, + loginStatus: 'never', + lastActiveAt: null, + }); + + // 3. 删除旧 profile 目录(释放磁盘) + if (oldSessionName) { + try { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const profileDir = path.resolve(process.cwd(), '..', '..', '.netabrowser-data', 'profiles', oldSessionName); + await fs.rm(profileDir, { recursive: true, force: true }); + this.logger?.info?.(`[GEO] account ${accountId} reset: 旧 profile ${oldSessionName} 已删除`); + } catch (e: any) { + this.logger?.warn?.(`[GEO] account ${accountId} 删除旧 profile 失败: ${e.message}`); + } + } + } + + /** 关闭浏览器 */ + async close(id: number): Promise { + const acc = await this.accountEntity.findOneBy({ id }); + if (!acc?.sessionName) return; + await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/close`, { + sessionName: acc.sessionName, + }, { timeout: 15_000 }).catch(() => undefined); + } + + /** 换绑 IP(沿用 setProxy 的重置逻辑:换 IP = 全新 sessionName + 清 cookie) */ + async rebindIp(accountId: number, newIpId: number): Promise { + return this.setProxy(accountId, newIpId); + } + + /** + * 设置账号的 IP 模式(本地 / 第三方双向切换) + * - newProxyId === null/undefined → 切到本地(解绑现有 IP) + * - newProxyId === number → 切到第三方(绑定该 IP,自动解绑旧的) + * + * 关键养号约束:IP 变更时必须重置 sessionName + cookies + 关闭浏览器, + * 否则风控会发现"同一 fingerprint+cookie,IP 突然变了" → 直接判异常。 + * 重置后用户需要重新登录。 + */ + async setProxy(accountId: number, newProxyId: number | null): Promise { + const acc = await this.accountEntity.findOneByOrFail({ id: accountId }); + // 同 IP 直接返回 + if ((acc.proxyId ?? null) === (newProxyId ?? null)) return; + + // 1. 关闭可能正在跑的浏览器(带旧 IP 的 session) + if (acc.sessionName) { + await axios.post(`${NB_DAEMON_BASE}/admin/browser-daemon/close`, { + sessionName: acc.sessionName, + }, { timeout: 15_000 }).catch(() => undefined); + } + + await this.dataSource.transaction(async manager => { + // 2. 解绑旧 IP(如果有) + if (acc.proxyId) { + await manager.update(GeoProxyIpEntity, acc.proxyId, { bindAccountId: null, status: 'unbound' }); + } + + if (newProxyId == null) { + // 切到本地 + await manager.update(GeoAccountEntity, accountId, { proxyId: null }); + } else { + // 切到第三方 + const newIp = await manager.findOne(GeoProxyIpEntity, { where: { id: newProxyId } }); + if (!newIp) throw new Error(`IP ${newProxyId} not found`); + if (newIp.bindAccountId && newIp.bindAccountId !== accountId) { + throw new Error(`IP ${newProxyId} 已绑定其他账号`); + } + await manager.update(GeoProxyIpEntity, newProxyId, { bindAccountId: accountId, status: 'active' }); + await manager.update(GeoAccountEntity, accountId, { proxyId: newProxyId }); + } + + // 3. 重置 sessionName + fingerprintSeed(生成新的,让 netabrowser-cli 用全新 profile 目录、全新指纹) + // 4. 清空 cookies + 重置登录状态 + const newSessionName = `${acc.platform}-${accountId}-${Date.now().toString(36)}`; + const newFingerprintSeed = this.genFingerprintSeed(); + await manager.update(GeoAccountEntity, accountId, { + sessionName: newSessionName, + fingerprintSeed: newFingerprintSeed, + cookies: null, + cookieCapturedAt: null, + cookieExpiresAt: null, + loginAccount: null, + loginStatus: 'never', + lastActiveAt: null, + }); + }); + + // 5. 删除旧 profile 目录(避免磁盘累积,下次 launch 用新 sessionName 创建全新目录) + if (acc.sessionName) { + try { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const profileDir = path.join(process.cwd(), '..', '..', '.netabrowser-data', 'profiles', acc.sessionName); + await fs.rm(profileDir, { recursive: true, force: true }).catch(() => undefined); + this.logger?.info?.(`[GEO] cleaned old profile dir: ${profileDir}`); + } catch (e: any) { + this.logger?.warn?.(`[GEO] clean old profile dir failed: ${e.message}`); + } + } + } + + /** 删除账号(软删除 + 释放 IP 回池;profile 目录在 .netabrowser-data/profiles/ 保留) */ + async deleteAccount(id: number): Promise { + await this.dataSource.transaction(async manager => { + const acc = await manager.findOneOrFail(GeoAccountEntity, { where: { id } }); + if (acc.proxyId) { + await manager.update(GeoProxyIpEntity, acc.proxyId, { bindAccountId: null, status: 'unbound' }); + } + await manager.update(GeoAccountEntity, id, { loginStatus: 'deleted', proxyId: null }); + }); + } +} diff --git a/packages/backend/src/modules/geo/service/encrypt.ts b/packages/backend/src/modules/geo/service/encrypt.ts new file mode 100644 index 0000000..a01ac2d --- /dev/null +++ b/packages/backend/src/modules/geo/service/encrypt.ts @@ -0,0 +1,51 @@ +import { Provide, Scope, ScopeEnum } from '@midwayjs/core'; +import * as crypto from 'crypto'; + +@Provide() +@Scope(ScopeEnum.Singleton) +export class GeoEncryptService { + private readonly algorithm = 'aes-256-gcm'; + /** 本地开发兜底 key(生产请配 SKILL_SECRET_KEY 或 APP_SECRET 环境变量) */ + private static readonly DEV_FALLBACK_KEY = 'gpu-guard-geo-encrypt-dev-only-change-in-production-2026'; + private warned = false; + + /** 从环境变量派生 AES-256 密钥(SHA-256 哈希)。未配置环境变量时用兜底 key(开发场景) */ + private deriveKey(): Buffer { + const raw = process.env.SKILL_SECRET_KEY || process.env.APP_SECRET || GeoEncryptService.DEV_FALLBACK_KEY; + if (!process.env.SKILL_SECRET_KEY && !process.env.APP_SECRET && !this.warned) { + this.warned = true; + // eslint-disable-next-line no-console + console.warn('[GeoEncrypt] 未配置 SKILL_SECRET_KEY / APP_SECRET,使用开发兜底密钥(生产环境必须配置)'); + } + return crypto.createHash('sha256').update(raw).digest(); + } + + /** + * 加密字符串,返回 base64 编码的密文 + * 格式: IV(12字节) + AuthTag(16字节) + 密文 + */ + encrypt(plain: string): string { + const key = this.deriveKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(this.algorithm, key, iv); + const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, ct]).toString('base64'); + } + + /** + * 解密 base64 密文,返回原始字符串 + * 若密文被篡改,GCM 认证标签校验会抛出错误 + */ + decrypt(cipherText: string): string { + const key = this.deriveKey(); + const buf = Buffer.from(cipherText, 'base64'); + if (buf.length < 28) throw new Error('Invalid ciphertext: too short'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const ct = buf.subarray(28); + const decipher = crypto.createDecipheriv(this.algorithm, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); + } +} diff --git a/packages/backend/src/modules/geo/service/proxy_ip.ts b/packages/backend/src/modules/geo/service/proxy_ip.ts new file mode 100644 index 0000000..94e6ac0 --- /dev/null +++ b/packages/backend/src/modules/geo/service/proxy_ip.ts @@ -0,0 +1,188 @@ +import { Provide, Inject, ILogger, Logger } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { GeoProxyIpEntity } from '../entity/proxy_ip.js'; +import { IProxyProvider, AcquireOpts, ProxyInfo } from '../provider/proxy/interface.js'; +import { LocalProxyProvider } from '../provider/proxy/local.js'; +import { TianqiProxyProvider } from '../provider/proxy/tianqi.js'; + +@Provide() +export class GeoProxyIpService extends BaseService { + @InjectEntityModel(GeoProxyIpEntity) + proxyIpEntity: Repository; + + @Logger() + logger: ILogger; + + private readonly providers = new Map([ + ['local', new LocalProxyProvider()], + ['tianqi', new TianqiProxyProvider()], + ]); + + /** 按名称获取 Provider,未知名称抛错 */ + getProvider(name: string): IProxyProvider { + const p = this.providers.get(name); + if (!p) throw new Error(`Unknown proxy provider: ${name}`); + return p; + } + + /** 通过指定 Provider 获取代理 IP */ + async acquire(dto: { provider: string } & AcquireOpts): Promise { + return this.getProvider(dto.provider).acquire(dto); + } + + /** 将 ProxyInfo 持久化到数据库(明文存储——后台内部管理工具,无需加密) */ + async persist( + info: ProxyInfo & { name?: string; provider?: string }, + manager?: any, + ): Promise { + const repo = manager ? manager.getRepository(GeoProxyIpEntity) : this.proxyIpEntity; + const entity = repo.create({ + name: info.name || `${info.mode}-${info.region || 'unknown'}`, + provider: info.provider || (info.mode === 'local' ? 'local' : 'tianqi'), + mode: info.mode, + host: info.host, + port: info.port, + protocol: info.protocol, + username: info.username, + password: info.password, + region: info.region, + isp: info.isp, + externalId: info.externalId, + status: 'active', + expiresAt: info.expiresAt, + }); + return repo.save(entity); + } + + /** 数据库实体 → ProxyInfo(明文直读) */ + toProxyInfo(e: GeoProxyIpEntity): ProxyInfo { + return { + externalId: e.externalId, + mode: e.mode as any, + protocol: e.protocol as any, + host: e.host, + port: e.port, + username: e.username || undefined, + password: e.password || undefined, + region: e.region, + isp: e.isp, + expiresAt: e.expiresAt, + }; + } + + /** + * 测试单个 IP 连通性(入站+出站): + * - 通过代理请求 https://httpbin.org/ip → 验证代理能转发(入站通) + * - 解析返回的 origin → 验证出口 IP(出站正常 + IP 是否符合预期) + * 返回详细诊断信息给前端展示 + */ + async testProxy(id: number): Promise<{ ok: boolean; latencyMs: number; exitIp?: string; expectedIp?: string; ipMatch?: boolean; error?: string }> { + const ip = await this.proxyIpEntity.findOneBy({ id }); + if (!ip) return { ok: false, latencyMs: 0, error: `IP ${id} not found` }; + const info = this.toProxyInfo(ip); + const expectedIp = ip.host; + const startedAt = Date.now(); + try { + const exitIp = await this.fetchExitIpThroughProxy(info); + const latencyMs = Date.now() - startedAt; + const ipMatch = !!exitIp && (exitIp === expectedIp || exitIp === ip.exitIp); + // 把测试结果写回 entity(exitIp + 状态 + 延迟) + await this.proxyIpEntity.update(ip.id, { + exitIp: exitIp || ip.exitIp, + latencyMs, + lastCheckAt: new Date(), + status: 'active', + }); + return { ok: true, latencyMs, exitIp, expectedIp, ipMatch }; + } catch (e: any) { + const latencyMs = Date.now() - startedAt; + await this.proxyIpEntity.update(ip.id, { + latencyMs, + lastCheckAt: new Date(), + status: 'error', + }); + return { ok: false, latencyMs, error: e.message ?? String(e) }; + } + } + + /** 通过代理请求 httpbin.org/ip 拿出口 IP(支持 SOCKS5 + HTTP 认证) */ + private async fetchExitIpThroughProxy(p: ProxyInfo): Promise { + if (!p.host || !p.port) throw new Error('proxy host/port missing'); + const { SocksClient } = await import('socks'); + const tls = await import('node:tls'); + const target = { host: 'httpbin.org', port: 443 }; + + let socket: any; + if (p.protocol === 'socks5') { + const conn = await SocksClient.createConnection({ + proxy: { host: p.host, port: p.port, type: 5, userId: p.username, password: p.password }, + command: 'connect', + destination: target, + timeout: 8000, + }); + socket = conn.socket; + } else { + // HTTP CONNECT + const net = await import('node:net'); + socket = await new Promise((resolve, reject) => { + const s = net.connect(p.port!, p.host!); + s.once('error', reject); + s.once('connect', () => { + const auth = p.username ? 'Proxy-Authorization: Basic ' + Buffer.from(`${p.username}:${p.password}`).toString('base64') + '\r\n' : ''; + s.write(`CONNECT ${target.host}:${target.port} HTTP/1.1\r\nHost: ${target.host}:${target.port}\r\n${auth}\r\n`); + let buf = ''; + const onData = (d: Buffer) => { + buf += d.toString(); + if (buf.includes('\r\n\r\n')) { + s.removeListener('data', onData); + if (/^HTTP\/1\.[01] 200/.test(buf)) resolve(s); + else reject(new Error('CONNECT failed: ' + buf.split('\r\n')[0])); + } + }; + s.on('data', onData); + }); + }); + } + + // TLS upgrade + HTTP GET + return new Promise((resolve, reject) => { + const tlsSocket = tls.connect({ socket, servername: target.host, ALPNProtocols: ['http/1.1'] }, () => { + tlsSocket.write('GET /ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n'); + }); + let body = ''; + const timer = setTimeout(() => { tlsSocket.destroy(); reject(new Error('proxy test timeout')); }, 10000); + tlsSocket.on('data', (d: Buffer) => { body += d.toString(); }); + tlsSocket.on('end', () => { + clearTimeout(timer); + try { + const i = body.indexOf('\r\n\r\n'); + const json = JSON.parse(body.slice(i + 4)); + resolve(json.origin?.split(',')[0]?.trim() || ''); + } catch (e: any) { + reject(new Error('parse exit ip failed: ' + e.message)); + } + }); + tlsSocket.on('error', (e: any) => { clearTimeout(timer); reject(e); }); + }); + } + + /** 对所有 active 状态的 IP 执行健康检查并更新状态 */ + async healthCheckAll(): Promise { + const ips = await this.proxyIpEntity.find({ where: { status: 'active' } }); + for (const ip of ips) { + try { + const result = await this.getProvider(ip.provider).healthCheck(this.toProxyInfo(ip)); + await this.proxyIpEntity.update(ip.id, { + latencyMs: result.latencyMs, + lastCheckAt: new Date(), + status: result.ok ? 'active' : 'error', + }); + if (!result.ok) this.logger?.warn?.(`[GEO] IP ${ip.id} health check failed`); + } catch (e: any) { + this.logger?.error?.(`[GEO] IP ${ip.id} health check error: ${e.message}`); + } + } + } +} diff --git a/packages/backend/src/modules/project/config.ts b/packages/backend/src/modules/project/config.ts new file mode 100644 index 0000000..2c62166 --- /dev/null +++ b/packages/backend/src/modules/project/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '项目管理', + // 模块描述 + description: '项目进度管理,支持甘特图、日历、看板等视图', + // 中间件 + middlewares: [], + // 全局中间件 + globalMiddlewares: [], + // 模块加载顺序 + order: 0, + } as ModuleConfig; +}; diff --git a/packages/backend/src/modules/project/controller/admin/info.ts b/packages/backend/src/modules/project/controller/admin/info.ts new file mode 100644 index 0000000..91126cf --- /dev/null +++ b/packages/backend/src/modules/project/controller/admin/info.ts @@ -0,0 +1,22 @@ +import { Provide } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { ProjectInfoEntity } from '../../entity/info'; +import { ProjectInfoService } from '../../service/info'; + +/** + * 项目管理 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: ProjectInfoEntity, + service: ProjectInfoService, + pageQueryOp: { + keyWordLikeFields: ['name', 'ownerName'], + fieldEq: ['status'], + addOrderBy: { + createTime: 'DESC', + }, + }, +}) +export class AdminProjectInfoController extends BaseController {} diff --git a/packages/backend/src/modules/project/controller/admin/phase.ts b/packages/backend/src/modules/project/controller/admin/phase.ts new file mode 100644 index 0000000..0fd264c --- /dev/null +++ b/packages/backend/src/modules/project/controller/admin/phase.ts @@ -0,0 +1,21 @@ +import { Provide } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { ProjectPhaseEntity } from '../../entity/phase'; +import { ProjectPhaseService } from '../../service/phase'; + +/** + * 项目阶段 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: ProjectPhaseEntity, + service: ProjectPhaseService, + listQueryOp: { + fieldEq: ['projectId'], + addOrderBy: { + sortOrder: 'ASC', + }, + }, +}) +export class AdminProjectPhaseController extends BaseController {} diff --git a/packages/backend/src/modules/project/controller/admin/task.ts b/packages/backend/src/modules/project/controller/admin/task.ts new file mode 100644 index 0000000..95a95aa --- /dev/null +++ b/packages/backend/src/modules/project/controller/admin/task.ts @@ -0,0 +1,79 @@ +import { Body, Get, Inject, Post, Provide, Query } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { ProjectTaskEntity } from '../../entity/task'; +import { ProjectTaskService } from '../../service/task'; +import { ProjectGanttService } from '../../service/gantt'; + +/** + * 项目任务 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: ProjectTaskEntity, + service: ProjectTaskService, + pageQueryOp: { + keyWordLikeFields: ['name', 'assigneeName'], + fieldEq: ['projectId', 'phaseId', 'status', 'priority', 'category', 'assigneeId'], + addOrderBy: { + sortOrder: 'ASC', + createTime: 'ASC', + }, + }, +}) +export class AdminProjectTaskController extends BaseController { + @Inject() + projectTaskService: ProjectTaskService; + + @Inject() + projectGanttService: ProjectGanttService; + + @Get('/tree', { summary: '任务树' }) + async tree(@Query('projectId') projectId: number) { + return this.ok(await this.projectTaskService.tree(projectId)); + } + + @Get('/ganttData', { summary: '甘特图数据' }) + async ganttData(@Query('projectId') projectId: number) { + return this.ok(await this.projectGanttService.ganttData(projectId)); + } + + @Post('/ganttUpdate', { summary: '甘特图拖拽更新' }) + async ganttUpdate(@Body('items') items: any[]) { + await this.projectGanttService.ganttUpdate(items); + return this.ok(); + } + + @Get('/kanban', { summary: '看板数据' }) + async kanban(@Query('projectId') projectId: number) { + return this.ok(await this.projectTaskService.kanban(projectId)); + } + + @Post('/kanbanSort', { summary: '看板排序' }) + async kanbanSort(@Body('items') items: any[]) { + await this.projectTaskService.kanbanSort(items); + return this.ok(); + } + + @Get('/hasChildren', { summary: '检查任务是否有子任务' }) + async hasChildren(@Query('id') id: number) { + const has = await this.projectTaskService.hasChildren(id); + return this.ok(has); + } + + @Post('/cascadeStatus', { summary: '级联更新子任务字段' }) + async cascadeStatus(@Body('id') id: number, @Body('fields') fields: Record) { + // 只允许级联特定字段,防止越权修改 + const allowed = ['status', 'priority', 'category', 'assigneeName', 'startDate', 'endDate', 'estimatedHours']; + const safeFields: Record = {}; + for (const key of allowed) { + if (key in fields) { + safeFields[key] = fields[key]; + } + } + if (Object.keys(safeFields).length > 0) { + await this.projectTaskService.cascadeUpdateFields(id, safeFields); + } + return this.ok(); + } +} diff --git a/packages/backend/src/modules/project/controller/admin/task_dependency.ts b/packages/backend/src/modules/project/controller/admin/task_dependency.ts new file mode 100644 index 0000000..c954b7d --- /dev/null +++ b/packages/backend/src/modules/project/controller/admin/task_dependency.ts @@ -0,0 +1,16 @@ +import { Provide } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { ProjectTaskDependencyEntity } from '../../entity/task_dependency'; + +/** + * 任务依赖关系 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'list'], + entity: ProjectTaskDependencyEntity, + listQueryOp: { + fieldEq: ['taskId'], + }, +}) +export class AdminProjectTaskDependencyController extends BaseController {} diff --git a/packages/backend/src/modules/project/controller/admin/time_log.ts b/packages/backend/src/modules/project/controller/admin/time_log.ts new file mode 100644 index 0000000..abf3284 --- /dev/null +++ b/packages/backend/src/modules/project/controller/admin/time_log.ts @@ -0,0 +1,21 @@ +import { Provide } from '@midwayjs/core'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { ProjectTimeLogEntity } from '../../entity/time_log'; +import { ProjectTimeLogService } from '../../service/time_log'; + +/** + * 工时记录 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'page'], + entity: ProjectTimeLogEntity, + service: ProjectTimeLogService, + pageQueryOp: { + fieldEq: ['taskId', 'userId'], + addOrderBy: { + logDate: 'DESC', + }, + }, +}) +export class AdminProjectTimeLogController extends BaseController {} diff --git a/packages/backend/src/modules/project/entity/info.ts b/packages/backend/src/modules/project/entity/info.ts new file mode 100644 index 0000000..a209226 --- /dev/null +++ b/packages/backend/src/modules/project/entity/info.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 项目信息 + */ +@Entity('project_info') +export class ProjectInfoEntity extends BaseEntity { + @Column({ comment: '项目名称', length: 100 }) + name: string; + + @Column({ comment: '项目描述', type: 'text', nullable: true }) + description: string; + + @Column({ comment: '状态 0未开始 1进行中 2已完成 3已归档', default: 0 }) + status: number; + + @Column({ comment: '计划开始日期', type: 'date', nullable: true }) + startDate: string; + + @Column({ comment: '计划结束日期', type: 'date', nullable: true }) + endDate: string; + + @Column({ comment: '进度百分比 0-100', default: 0 }) + progress: number; + + @Index() + @Column({ comment: '项目经理ID', nullable: true }) + ownerId: number; + + @Column({ comment: '项目经理姓名', length: 50, nullable: true }) + ownerName: string; + + @Column({ comment: '主题色', length: 20, nullable: true }) + color: string; +} diff --git a/packages/backend/src/modules/project/entity/phase.ts b/packages/backend/src/modules/project/entity/phase.ts new file mode 100644 index 0000000..8103659 --- /dev/null +++ b/packages/backend/src/modules/project/entity/phase.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 项目阶段 + */ +@Entity('project_phase') +export class ProjectPhaseEntity extends BaseEntity { + @Index() + @Column({ comment: '所属项目ID' }) + projectId: number; + + @Column({ comment: '阶段名称', length: 100 }) + name: string; + + @Column({ comment: '分类', length: 50, nullable: true }) + type: string; + + @Column({ comment: '状态 0未开始 1进行中 2已完成', default: 0 }) + status: number; + + @Column({ comment: '开始日期', type: 'date', nullable: true }) + startDate: string; + + @Column({ comment: '结束日期', type: 'date', nullable: true }) + endDate: string; + + @Column({ comment: '进度 0-100', default: 0 }) + progress: number; + + @Column({ comment: '排序序号', default: 0 }) + sortOrder: number; +} diff --git a/packages/backend/src/modules/project/entity/task.ts b/packages/backend/src/modules/project/entity/task.ts new file mode 100644 index 0000000..f4d00d7 --- /dev/null +++ b/packages/backend/src/modules/project/entity/task.ts @@ -0,0 +1,63 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 项目任务 + */ +@Entity('project_task') +export class ProjectTaskEntity extends BaseEntity { + @Index() + @Column({ comment: '所属项目ID' }) + projectId: number; + + @Index() + @Column({ comment: '所属阶段ID', nullable: true }) + phaseId: number; + + @Index() + @Column({ comment: '父任务ID', nullable: true }) + parentId: number; + + @Column({ comment: '任务名称', length: 200 }) + name: string; + + @Column({ comment: '任务描述', type: 'text', nullable: true }) + description: string; + + @Column({ comment: '状态 0待办 1进行中 2已完成 3已关闭', default: 0 }) + status: number; + + @Column({ comment: '优先级 0紧急 1高 2中 3低', default: 2 }) + priority: number; + + @Column({ comment: '分类', length: 50, nullable: true }) + category: string; + + @Index() + @Column({ comment: '负责人ID', nullable: true }) + assigneeId: number; + + @Column({ comment: '负责人姓名', length: 50, nullable: true }) + assigneeName: string; + + @Column({ comment: '计划开始日期', type: 'date', nullable: true }) + startDate: string; + + @Column({ comment: '计划结束日期', type: 'date', nullable: true }) + endDate: string; + + @Column({ comment: '预估工时(小时)', type: 'decimal', precision: 8, scale: 1, default: 0 }) + estimatedHours: number; + + @Column({ comment: '实际工时(小时)', type: 'decimal', precision: 8, scale: 1, default: 0 }) + actualHours: number; + + @Column({ comment: '进度 0-100', default: 0 }) + progress: number; + + @Column({ comment: '排序序号', default: 0 }) + sortOrder: number; + + @Column({ comment: '自定义颜色', length: 20, nullable: true }) + color: string; +} diff --git a/packages/backend/src/modules/project/entity/task_dependency.ts b/packages/backend/src/modules/project/entity/task_dependency.ts new file mode 100644 index 0000000..3ff908e --- /dev/null +++ b/packages/backend/src/modules/project/entity/task_dependency.ts @@ -0,0 +1,19 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 任务依赖关系 + */ +@Entity('project_task_dependency') +export class ProjectTaskDependencyEntity extends BaseEntity { + @Index() + @Column({ comment: '当前任务ID' }) + taskId: number; + + @Index() + @Column({ comment: '前置任务ID' }) + dependsOnTaskId: number; + + @Column({ comment: '依赖类型 0:FS 1:SS 2:FF 3:SF', default: 0 }) + type: number; +} diff --git a/packages/backend/src/modules/project/entity/time_log.ts b/packages/backend/src/modules/project/entity/time_log.ts new file mode 100644 index 0000000..e705318 --- /dev/null +++ b/packages/backend/src/modules/project/entity/time_log.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '../../base/entity/base'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 工时记录 + */ +@Entity('project_time_log') +export class ProjectTimeLogEntity extends BaseEntity { + @Index() + @Column({ comment: '所属任务ID' }) + taskId: number; + + @Index() + @Column({ comment: '记录人ID' }) + userId: number; + + @Column({ comment: '记录人姓名', length: 50 }) + userName: string; + + @Column({ comment: '工作日期', type: 'date' }) + logDate: string; + + @Column({ comment: '工时(小时)', type: 'decimal', precision: 5, scale: 1 }) + hours: number; + + @Column({ comment: '工作内容描述', length: 500, nullable: true }) + description: string; +} diff --git a/packages/backend/src/modules/project/service/gantt.ts b/packages/backend/src/modules/project/service/gantt.ts new file mode 100644 index 0000000..5887f37 --- /dev/null +++ b/packages/backend/src/modules/project/service/gantt.ts @@ -0,0 +1,134 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { ProjectTaskEntity } from '../entity/task'; +import { ProjectPhaseEntity } from '../entity/phase'; +import { ProjectTaskDependencyEntity } from '../entity/task_dependency'; + +/** + * 甘特图数据服务 + */ +@Provide() +export class ProjectGanttService extends BaseService { + @InjectEntityModel(ProjectTaskEntity) + projectTaskEntity: Repository; + + @InjectEntityModel(ProjectPhaseEntity) + projectPhaseEntity: Repository; + + @InjectEntityModel(ProjectTaskDependencyEntity) + projectTaskDependencyEntity: Repository; + + /** + * 获取甘特图数据(DHTMLX Gantt 格式) + */ + async ganttData(projectId: number) { + const phases = await this.projectPhaseEntity.find({ + where: { projectId }, + order: { sortOrder: 'ASC' }, + }); + const tasks = await this.projectTaskEntity.find({ + where: { projectId }, + order: { sortOrder: 'ASC', createTime: 'ASC' }, + }); + const deps = await this.projectTaskDependencyEntity + .createQueryBuilder('d') + .where('d.taskId IN (:...taskIds)', { + taskIds: tasks.length > 0 ? tasks.map(t => t.id) : [0], + }) + .orWhere('d.dependsOnTaskId IN (:...depIds)', { + depIds: tasks.length > 0 ? tasks.map(t => t.id) : [0], + }) + .getMany(); + + const data = []; + + for (const phase of phases) { + data.push({ + id: `p_${phase.id}`, + text: phase.name, + start_date: phase.startDate || '', + end_date: phase.endDate || '', + progress: phase.progress / 100, + type: 'project', + open: true, + sortOrder: phase.sortOrder, + status: phase.status, + phaseType: phase.type, + }); + } + + for (const task of tasks) { + let parent = ''; + if (task.parentId) { + parent = `t_${task.parentId}`; + } else if (task.phaseId) { + parent = `p_${task.phaseId}`; + } + + data.push({ + id: `t_${task.id}`, + text: task.name, + start_date: task.startDate || '', + end_date: task.endDate || '', + progress: task.progress / 100, + parent, + priority: task.priority, + status: task.status, + category: task.category, + assignee: task.assigneeName, + assigneeId: task.assigneeId, + estimatedHours: task.estimatedHours, + actualHours: task.actualHours, + color: task.color, + sortOrder: task.sortOrder, + }); + } + + const links = deps.map(d => ({ + id: d.id, + source: `t_${d.dependsOnTaskId}`, + target: `t_${d.taskId}`, + type: String(d.type), + })); + + return { data, links }; + } + + /** + * 甘特图拖拽批量更新 + */ + async ganttUpdate( + items: { id: string; start_date: string; end_date: string; sortOrder?: number; parent?: string }[] + ) { + for (const item of items) { + if (item.id.startsWith('p_')) { + const phaseId = parseInt(item.id.replace('p_', '')); + await this.projectPhaseEntity.update(phaseId, { + startDate: item.start_date, + endDate: item.end_date, + ...(item.sortOrder !== undefined ? { sortOrder: item.sortOrder } : {}), + }); + } else if (item.id.startsWith('t_')) { + const taskId = parseInt(item.id.replace('t_', '')); + const updateData: any = { + startDate: item.start_date, + endDate: item.end_date, + }; + if (item.sortOrder !== undefined) { + updateData.sortOrder = item.sortOrder; + } + if (item.parent) { + if (item.parent.startsWith('p_')) { + updateData.phaseId = parseInt(item.parent.replace('p_', '')); + updateData.parentId = null; + } else if (item.parent.startsWith('t_')) { + updateData.parentId = parseInt(item.parent.replace('t_', '')); + } + } + await this.projectTaskEntity.update(taskId, updateData); + } + } + } +} diff --git a/packages/backend/src/modules/project/service/info.ts b/packages/backend/src/modules/project/service/info.ts new file mode 100644 index 0000000..35a3567 --- /dev/null +++ b/packages/backend/src/modules/project/service/info.ts @@ -0,0 +1,92 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { ProjectInfoEntity } from '../entity/info'; +import { ProjectPhaseEntity } from '../entity/phase'; +import { ProjectTaskEntity } from '../entity/task'; +import { ProjectTaskDependencyEntity } from '../entity/task_dependency'; +import { ProjectTimeLogEntity } from '../entity/time_log'; + +/** + * 项目信息服务 + */ +@Provide() +export class ProjectInfoService extends BaseService { + @InjectEntityModel(ProjectInfoEntity) + projectInfoEntity: Repository; + + @InjectEntityModel(ProjectPhaseEntity) + projectPhaseEntity: Repository; + + @InjectEntityModel(ProjectTaskEntity) + projectTaskEntity: Repository; + + @InjectEntityModel(ProjectTaskDependencyEntity) + projectTaskDependencyEntity: Repository; + + @InjectEntityModel(ProjectTimeLogEntity) + projectTimeLogEntity: Repository; + + private normalizeIds(ids: any): number[] { + if (Array.isArray(ids)) { + return ids.map(id => Number(id)).filter(Boolean); + } + if (typeof ids === 'string') { + return ids + .split(',') + .map(id => Number(id)) + .filter(Boolean); + } + const id = Number(ids); + return id ? [id] : []; + } + + /** + * 重新计算项目进度(由各阶段加权平均) + */ + async recalcProgress(projectId: number) { + const phases = await this.projectPhaseEntity.find({ + where: { projectId }, + }); + if (phases.length === 0) { + await this.projectInfoEntity.update(projectId, { progress: 0 }); + return; + } + const total = phases.reduce((sum, p) => sum + p.progress, 0); + const progress = Math.round(total / phases.length); + await this.projectInfoEntity.update(projectId, { progress }); + } + + /** + * 删除项目时级联删除阶段和任务 + */ + async delete(ids: number[]) { + const projectIds = this.normalizeIds(ids); + const tasks = projectIds.length + ? await this.projectTaskEntity.find({ + where: { projectId: In(projectIds) }, + select: ['id'], + }) + : []; + const taskIds = tasks.map(task => task.id); + + await super.delete(ids); + + if (taskIds.length > 0) { + await this.projectTaskDependencyEntity + .createQueryBuilder() + .delete() + .where('taskId IN (:...taskIds) OR dependsOnTaskId IN (:...taskIds)', { + taskIds, + }) + .execute(); + await this.projectTimeLogEntity.delete({ taskId: In(taskIds) }); + } + + if (projectIds.length > 0) { + await this.projectPhaseEntity.delete({ projectId: In(projectIds) }); + await this.projectTaskEntity.delete({ projectId: In(projectIds) }); + } + } +} diff --git a/packages/backend/src/modules/project/service/phase.ts b/packages/backend/src/modules/project/service/phase.ts new file mode 100644 index 0000000..98d9f95 --- /dev/null +++ b/packages/backend/src/modules/project/service/phase.ts @@ -0,0 +1,73 @@ +import { Inject, Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { ProjectPhaseEntity } from '../entity/phase'; +import { ProjectTaskEntity } from '../entity/task'; +import { ProjectInfoService } from './info'; + +/** + * 项目阶段服务 + */ +@Provide() +export class ProjectPhaseService extends BaseService { + @InjectEntityModel(ProjectPhaseEntity) + projectPhaseEntity: Repository; + + @InjectEntityModel(ProjectTaskEntity) + projectTaskEntity: Repository; + + @Inject() + projectInfoService: ProjectInfoService; + + private affectedProjectIds = new Set(); + + private normalizeIds(ids: any): number[] { + if (Array.isArray(ids)) { + return ids.map(id => Number(id)).filter(Boolean); + } + if (typeof ids === 'string') { + return ids + .split(',') + .map(id => Number(id)) + .filter(Boolean); + } + const id = Number(ids); + return id ? [id] : []; + } + + async modifyBefore(data: any, type: 'delete' | 'update' | 'add') { + this.affectedProjectIds = new Set(); + + if (type === 'delete' || type === 'update') { + const ids = type === 'delete' ? this.normalizeIds(data) : this.normalizeIds(data?.id); + if (ids.length > 0) { + const phases = await this.projectPhaseEntity.findBy({ id: In(ids) }); + for (const phase of phases) { + this.affectedProjectIds.add(phase.projectId); + } + } + } + + if (type !== 'delete' && data.projectId) { + this.affectedProjectIds.add(Number(data.projectId)); + } + } + + async modifyAfter(data: any, type: 'delete' | 'update' | 'add') { + if (type === 'delete') { + const ids = this.normalizeIds(data); + if (ids.length > 0) { + await this.projectTaskEntity.update({ phaseId: In(ids) }, { phaseId: null }); + } + } + + if (type !== 'delete' && data.projectId) { + this.affectedProjectIds.add(Number(data.projectId)); + } + + for (const projectId of this.affectedProjectIds) { + await this.projectInfoService.recalcProgress(projectId); + } + } +} diff --git a/packages/backend/src/modules/project/service/task.ts b/packages/backend/src/modules/project/service/task.ts new file mode 100644 index 0000000..f431336 --- /dev/null +++ b/packages/backend/src/modules/project/service/task.ts @@ -0,0 +1,240 @@ +import { Inject, Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { ProjectTaskEntity } from '../entity/task'; +import { ProjectPhaseEntity } from '../entity/phase'; +import { ProjectTimeLogEntity } from '../entity/time_log'; +import { ProjectTaskDependencyEntity } from '../entity/task_dependency'; +import { ProjectInfoService } from './info'; + +/** + * 任务服务 + */ +@Provide() +export class ProjectTaskService extends BaseService { + @InjectEntityModel(ProjectTaskEntity) + projectTaskEntity: Repository; + + @InjectEntityModel(ProjectPhaseEntity) + projectPhaseEntity: Repository; + + @InjectEntityModel(ProjectTimeLogEntity) + projectTimeLogEntity: Repository; + + @InjectEntityModel(ProjectTaskDependencyEntity) + projectTaskDependencyEntity: Repository; + + @Inject() + projectInfoService: ProjectInfoService; + + private affectedPhaseIds = new Set(); + private affectedProjectIds = new Set(); + + private normalizeIds(ids: any): number[] { + if (Array.isArray(ids)) { + return ids.map(id => Number(id)).filter(Boolean); + } + if (typeof ids === 'string') { + return ids + .split(',') + .map(id => Number(id)) + .filter(Boolean); + } + const id = Number(ids); + return id ? [id] : []; + } + + private rememberTask(task: Partial) { + if (task.phaseId) { + this.affectedPhaseIds.add(Number(task.phaseId)); + } + if (task.projectId) { + this.affectedProjectIds.add(Number(task.projectId)); + } + } + + private toHours(value: unknown) { + return Number(value) || 0; + } + + /** + * 获取任务树(项目下所有任务,按阶段分组,含子任务层级) + */ + async tree(projectId: number) { + const tasks = await this.projectTaskEntity.find({ + where: { projectId }, + order: { sortOrder: 'ASC', createTime: 'ASC' }, + }); + return this.buildTree(tasks, null); + } + + /** + * 构建树形结构 + */ + private buildTree(tasks: ProjectTaskEntity[], parentId: number | null) { + return tasks + .filter(t => t.parentId === parentId) + .map(t => ({ + ...t, + children: this.buildTree(tasks, t.id), + })); + } + + /** + * 获取看板数据(按状态分组) + */ + async kanban(projectId: number) { + const tasks = await this.projectTaskEntity.find({ + where: { projectId }, + order: { sortOrder: 'ASC', createTime: 'ASC' }, + }); + return { + todo: tasks.filter(t => t.status === 0), + inProgress: tasks.filter(t => t.status === 1), + done: tasks.filter(t => t.status === 2), + closed: tasks.filter(t => t.status === 3), + }; + } + + /** + * 看板排序/状态变更 + */ + async kanbanSort(items: { id: number; status: number; sortOrder: number }[]) { + for (const item of items) { + await this.projectTaskEntity.update(item.id, { + status: item.status, + sortOrder: item.sortOrder, + }); + } + } + + /** + * 重新计算阶段进度(由下属任务加权平均) + */ + async recalcPhaseProgress(phaseId: number) { + const tasks = await this.projectTaskEntity.find({ + where: { phaseId, parentId: null }, + }); + if (tasks.length === 0) { + await this.projectPhaseEntity.update(phaseId, { progress: 0 }); + return; + } + + const hasEstimated = tasks.some(t => this.toHours(t.estimatedHours) > 0); + let progress: number; + + if (hasEstimated) { + const totalWeight = tasks.reduce( + (sum, t) => sum + (this.toHours(t.estimatedHours) || 1), + 0 + ); + const weightedSum = tasks.reduce( + (sum, t) => sum + t.progress * (this.toHours(t.estimatedHours) || 1), + 0 + ); + progress = Math.round(weightedSum / totalWeight); + } else { + progress = Math.round( + tasks.reduce((sum, t) => sum + t.progress, 0) / tasks.length + ); + } + + await this.projectPhaseEntity.update(phaseId, { progress }); + } + + /** + * 重新计算任务工时(汇总工时记录) + */ + async recalcTaskHours(taskId: number) { + const result = await this.projectTimeLogEntity + .createQueryBuilder('tl') + .select('SUM(tl.hours)', 'total') + .where('tl.taskId = :taskId', { taskId }) + .getRawOne(); + const actualHours = Number(result?.total) || 0; + await this.projectTaskEntity.update(taskId, { actualHours }); + } + + /** + * 任务新增/更新/删除后触发进度向上汇总 + */ + async modifyBefore(data: any, type: 'delete' | 'update' | 'add') { + this.affectedPhaseIds = new Set(); + this.affectedProjectIds = new Set(); + + if (type === 'delete' || type === 'update') { + const ids = type === 'delete' ? this.normalizeIds(data) : this.normalizeIds(data?.id); + if (ids.length > 0) { + const tasks = await this.projectTaskEntity.findBy({ id: In(ids) }); + for (const task of tasks) { + this.rememberTask(task); + } + } + } + } + + async modifyAfter(data: any, type: 'delete' | 'update' | 'add') { + if (type !== 'delete') { + if (Array.isArray(data)) { + data.forEach(item => this.rememberTask(item)); + } else { + this.rememberTask(data); + } + } + + for (const phaseId of this.affectedPhaseIds) { + await this.recalcPhaseProgress(phaseId); + const phase = await this.projectPhaseEntity.findOneBy({ id: phaseId }); + if (phase?.projectId) { + this.affectedProjectIds.add(phase.projectId); + } + } + + for (const projectId of this.affectedProjectIds) { + await this.projectInfoService.recalcProgress(projectId); + } + } + + /** + * 检查任务是否有子任务 + */ + async hasChildren(taskId: number): Promise { + const count = await this.projectTaskEntity.count({ where: { parentId: taskId } }); + return count > 0; + } + + /** + * 级联更新子任务字段(递归) + */ + async cascadeUpdateFields(taskId: number, fields: Record) { + const children = await this.projectTaskEntity.find({ where: { parentId: taskId } }); + for (const child of children) { + await this.projectTaskEntity.update(child.id, fields); + // 状态为已完成时同步进度 + if (fields.status === 2) { + await this.projectTaskEntity.update(child.id, { progress: 100 }); + } + await this.cascadeUpdateFields(child.id, fields); + } + } + + /** + * 删除任务时级联删除子任务、依赖、工时记录 + */ + async delete(ids: number[]) { + await super.delete(ids); + for (const id of ids) { + const children = await this.projectTaskEntity.find({ where: { parentId: id } }); + if (children.length > 0) { + await this.delete(children.map(c => c.id)); + } + await this.projectTaskDependencyEntity + .createQueryBuilder() + .delete() + .where('taskId = :id OR dependsOnTaskId = :id', { id }) + .execute(); + await this.projectTimeLogEntity.delete({ taskId: id }); + } + } +} diff --git a/packages/backend/src/modules/project/service/time_log.ts b/packages/backend/src/modules/project/service/time_log.ts new file mode 100644 index 0000000..dbc0a23 --- /dev/null +++ b/packages/backend/src/modules/project/service/time_log.ts @@ -0,0 +1,78 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { ProjectTimeLogEntity } from '../entity/time_log'; +import { ProjectTaskEntity } from '../entity/task'; + +/** + * 工时记录服务 + */ +@Provide() +export class ProjectTimeLogService extends BaseService { + @InjectEntityModel(ProjectTimeLogEntity) + projectTimeLogEntity: Repository; + + @InjectEntityModel(ProjectTaskEntity) + projectTaskEntity: Repository; + + private affectedTaskIds = new Set(); + + private normalizeIds(ids: any): number[] { + if (Array.isArray(ids)) { + return ids.map(id => Number(id)).filter(Boolean); + } + if (typeof ids === 'string') { + return ids + .split(',') + .map(id => Number(id)) + .filter(Boolean); + } + const id = Number(ids); + return id ? [id] : []; + } + + async modifyBefore(data: any, type: 'delete' | 'update' | 'add') { + this.affectedTaskIds = new Set(); + + if (type === 'delete') { + const ids = this.normalizeIds(data); + if (ids.length === 0) return; + + const logs = await this.projectTimeLogEntity.findBy({ id: In(ids) }); + for (const log of logs) { + this.affectedTaskIds.add(log.taskId); + } + return; + } + + if (data.taskId) { + this.affectedTaskIds.add(Number(data.taskId)); + } + + if (type === 'add') { + data.userId = data.userId || this.baseCtx?.admin?.userId; + data.userName = data.userName || this.baseCtx?.admin?.username || '当前用户'; + } + } + + async modifyAfter(data: any, type: 'delete' | 'update' | 'add') { + if (type !== 'delete' && data.taskId) { + this.affectedTaskIds.add(Number(data.taskId)); + } + + for (const taskId of this.affectedTaskIds) { + await this.recalcTaskHours(taskId); + } + } + + private async recalcTaskHours(taskId: number) { + const result = await this.projectTimeLogEntity + .createQueryBuilder('tl') + .select('SUM(tl.hours)', 'total') + .where('tl.taskId = :taskId', { taskId }) + .getRawOne(); + const actualHours = Number(result?.total) || 0; + await this.projectTaskEntity.update(taskId, { actualHours }); + } +} diff --git a/packages/frontend/src/modules/geo/config.ts b/packages/frontend/src/modules/geo/config.ts new file mode 100644 index 0000000..1b23e9b --- /dev/null +++ b/packages/frontend/src/modules/geo/config.ts @@ -0,0 +1,26 @@ +import { type ModuleConfig } from '/@/cool'; + +export default (): ModuleConfig => { + return { + name: 'geo', + label: '账号环境管理', + order: 60, + views: [ + { + path: '/geo/dashboard', + meta: { label: '环境总览' }, + component: () => import('./views/dashboard.vue'), + }, + { + path: '/geo/accounts', + meta: { label: '账号矩阵' }, + component: () => import('./views/accounts.vue'), + }, + { + path: '/geo/proxies', + meta: { label: 'IP 池' }, + component: () => import('./views/proxies.vue'), + }, + ], + }; +}; diff --git a/packages/frontend/src/modules/geo/views/accounts.vue b/packages/frontend/src/modules/geo/views/accounts.vue new file mode 100644 index 0000000..eea9efe --- /dev/null +++ b/packages/frontend/src/modules/geo/views/accounts.vue @@ -0,0 +1,589 @@ + + + + + diff --git a/packages/frontend/src/modules/geo/views/dashboard.vue b/packages/frontend/src/modules/geo/views/dashboard.vue new file mode 100644 index 0000000..1d54666 --- /dev/null +++ b/packages/frontend/src/modules/geo/views/dashboard.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/packages/frontend/src/modules/geo/views/proxies.vue b/packages/frontend/src/modules/geo/views/proxies.vue new file mode 100644 index 0000000..e939bc6 --- /dev/null +++ b/packages/frontend/src/modules/geo/views/proxies.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/packages/frontend/src/modules/project/config.ts b/packages/frontend/src/modules/project/config.ts new file mode 100644 index 0000000..aa35b99 --- /dev/null +++ b/packages/frontend/src/modules/project/config.ts @@ -0,0 +1,21 @@ +import { type ModuleConfig } from '/@/cool'; + +export default (): ModuleConfig => { + return { + name: 'project', + label: '项目管理', + order: 80, + views: [ + { + path: '/project/list', + meta: { label: '项目列表' }, + component: () => import('./views/list.vue') + }, + { + path: '/project/detail', + meta: { label: '项目详情' }, + component: () => import('./views/detail.vue') + } + ] + }; +}; diff --git a/packages/frontend/src/modules/project/store/project.ts b/packages/frontend/src/modules/project/store/project.ts new file mode 100644 index 0000000..533fac4 --- /dev/null +++ b/packages/frontend/src/modules/project/store/project.ts @@ -0,0 +1,114 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import { useCool } from '/@/cool'; + +export const useProjectStore = defineStore('project', () => { + const { service } = useCool(); + + // 当前项目信息 + const currentProject = ref(null); + + // 甘特图数据 + const ganttData = ref<{ data: any[]; links: any[] }>({ data: [], links: [] }); + + // 任务列表(平铺) + const tasks = computed(() => + ganttData.value.data.filter((d: any) => String(d.id).startsWith('t_')) + ); + + // 阶段列表 + const phases = computed(() => + ganttData.value.data.filter((d: any) => String(d.id).startsWith('p_')) + ); + + /** + * 加载项目详情 + */ + async function loadProject(projectId: number) { + const res = await service.project.info.info({ id: projectId }); + currentProject.value = res; + return res; + } + + /** + * 加载甘特图数据(所有视图共享) + */ + async function loadGanttData(projectId: number) { + const res = await service.project.task.ganttData({ projectId }); + ganttData.value = res; + return res; + } + + /** + * 刷新数据 + */ + async function refresh() { + if (currentProject.value?.id) { + await loadGanttData(currentProject.value.id); + } + } + + /** + * 甘特图拖拽更新 + */ + async function ganttUpdate(items: any[]) { + await service.project.task.ganttUpdate({ items }); + await refresh(); + } + + /** + * 看板排序 + */ + async function kanbanSort(items: any[]) { + await service.project.task.kanbanSort({ items }); + await refresh(); + } + + /** + * 添加任务 + */ + async function addTask(data: any) { + await service.project.task.add(data); + await refresh(); + } + + /** + * 更新任务 + */ + async function updateTask(data: any) { + await service.project.task.update(data); + await refresh(); + } + + /** + * 删除任务 + */ + async function deleteTask(ids: number[]) { + await service.project.task.delete({ ids }); + await refresh(); + } + + /** + * 重置 + */ + function reset() { + currentProject.value = null; + ganttData.value = { data: [], links: [] }; + } + + return { + currentProject, + ganttData, + tasks, + phases, + loadProject, + loadGanttData, + refresh, + ganttUpdate, + kanbanSort, + addTask, + updateTask, + deleteTask, + reset, + }; +}); diff --git a/packages/frontend/src/modules/project/views/components/calendar.vue b/packages/frontend/src/modules/project/views/components/calendar.vue new file mode 100644 index 0000000..d02b430 --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/calendar.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/packages/frontend/src/modules/project/views/components/gantt.vue b/packages/frontend/src/modules/project/views/components/gantt.vue new file mode 100644 index 0000000..61252e5 --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/gantt.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/frontend/src/modules/project/views/components/kanban.vue b/packages/frontend/src/modules/project/views/components/kanban.vue new file mode 100644 index 0000000..6462d60 --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/kanban.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/packages/frontend/src/modules/project/views/components/phase-manager.vue b/packages/frontend/src/modules/project/views/components/phase-manager.vue new file mode 100644 index 0000000..24df967 --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/phase-manager.vue @@ -0,0 +1,143 @@ + + + diff --git a/packages/frontend/src/modules/project/views/components/table.vue b/packages/frontend/src/modules/project/views/components/table.vue new file mode 100644 index 0000000..ad54856 --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/table.vue @@ -0,0 +1,454 @@ + + + + + diff --git a/packages/frontend/src/modules/project/views/components/task-drawer.vue b/packages/frontend/src/modules/project/views/components/task-drawer.vue new file mode 100644 index 0000000..afc8c04 --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/task-drawer.vue @@ -0,0 +1,281 @@ + + + diff --git a/packages/frontend/src/modules/project/views/components/time-log-dialog.vue b/packages/frontend/src/modules/project/views/components/time-log-dialog.vue new file mode 100644 index 0000000..dbe426c --- /dev/null +++ b/packages/frontend/src/modules/project/views/components/time-log-dialog.vue @@ -0,0 +1,71 @@ + + + diff --git a/packages/frontend/src/modules/project/views/detail.vue b/packages/frontend/src/modules/project/views/detail.vue new file mode 100644 index 0000000..0c1545a --- /dev/null +++ b/packages/frontend/src/modules/project/views/detail.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/packages/frontend/src/modules/project/views/list.vue b/packages/frontend/src/modules/project/views/list.vue new file mode 100644 index 0000000..b938b7a --- /dev/null +++ b/packages/frontend/src/modules/project/views/list.vue @@ -0,0 +1,296 @@ + + + + +