初始化提交,现在这个项目的基础已经完成,等待后续的业务实现
This commit is contained in:
parent
aaa88b2e63
commit
465945db55
@ -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
|
||||
-- ============================================================
|
||||
|
||||
@ -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`
|
||||
);
|
||||
@ -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),
|
||||
];
|
||||
|
||||
@ -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": []
|
||||
}
|
||||
]
|
||||
|
||||
19
packages/backend/src/modules/geo/config.ts
Normal file
19
packages/backend/src/modules/geo/config.ts
Normal file
@ -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;
|
||||
};
|
||||
65
packages/backend/src/modules/geo/controller/admin/account.ts
Normal file
65
packages/backend/src/modules/geo/controller/admin/account.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
48
packages/backend/src/modules/geo/entity/account.ts
Normal file
48
packages/backend/src/modules/geo/entity/account.ts
Normal file
@ -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;
|
||||
}
|
||||
66
packages/backend/src/modules/geo/entity/proxy_ip.ts
Normal file
66
packages/backend/src/modules/geo/entity/proxy_ip.ts
Normal file
@ -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;
|
||||
}
|
||||
32
packages/backend/src/modules/geo/provider/proxy/interface.ts
Normal file
32
packages/backend/src/modules/geo/provider/proxy/interface.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export interface AcquireOpts {
|
||||
region?: string;
|
||||
isp?: string;
|
||||
duration?: 'fixed' | 'rotating';
|
||||
extra?: Record<string, any>;
|
||||
}
|
||||
|
||||
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<ProxyInfo>;
|
||||
release(externalId: string): Promise<void>;
|
||||
healthCheck(p: ProxyInfo): Promise<HealthCheckResult>;
|
||||
list?(): Promise<ProxyInfo[]>;
|
||||
}
|
||||
34
packages/backend/src/modules/geo/provider/proxy/local.ts
Normal file
34
packages/backend/src/modules/geo/provider/proxy/local.ts
Normal file
@ -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<ProxyInfo> {
|
||||
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<void> {}
|
||||
|
||||
async healthCheck(_p: ProxyInfo): Promise<HealthCheckResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/backend/src/modules/geo/provider/proxy/tianqi.ts
Normal file
17
packages/backend/src/modules/geo/provider/proxy/tianqi.ts
Normal file
@ -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<ProxyInfo> {
|
||||
throw new Error('NotImplemented: Tianqi 占位');
|
||||
}
|
||||
|
||||
async release(externalId: string): Promise<void> {
|
||||
throw new Error('NotImplemented: Tianqi 占位');
|
||||
}
|
||||
|
||||
async healthCheck(p: ProxyInfo): Promise<HealthCheckResult> {
|
||||
throw new Error('NotImplemented: Tianqi 占位');
|
||||
}
|
||||
}
|
||||
479
packages/backend/src/modules/geo/service/account.ts
Normal file
479
packages/backend/src/modules/geo/service/account.ts
Normal file
@ -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<GeoAccountEntity>;
|
||||
|
||||
@InjectEntityModel(GeoProxyIpEntity)
|
||||
proxyIpEntity: Repository<GeoProxyIpEntity>;
|
||||
|
||||
@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<GeoAccountEntity> {
|
||||
// 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<string> {
|
||||
const path = await import('node:path');
|
||||
return path.resolve(process.cwd(), '..', '..', '.netabrowser-data', 'profiles', sessionName);
|
||||
}
|
||||
|
||||
/** 判断 profile 目录是否有 Cookies 文件(首次启动后才会创建) */
|
||||
private async profileHasCookies(profileDir: string): Promise<boolean> {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string | undefined> {
|
||||
const cookieMap = new Map<string, string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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/<sessionName> 保留) */
|
||||
async deleteAccount(id: number): Promise<void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
51
packages/backend/src/modules/geo/service/encrypt.ts
Normal file
51
packages/backend/src/modules/geo/service/encrypt.ts
Normal file
@ -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');
|
||||
}
|
||||
}
|
||||
188
packages/backend/src/modules/geo/service/proxy_ip.ts
Normal file
188
packages/backend/src/modules/geo/service/proxy_ip.ts
Normal file
@ -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<GeoProxyIpEntity>;
|
||||
|
||||
@Logger()
|
||||
logger: ILogger;
|
||||
|
||||
private readonly providers = new Map<string, IProxyProvider>([
|
||||
['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<ProxyInfo> {
|
||||
return this.getProvider(dto.provider).acquire(dto);
|
||||
}
|
||||
|
||||
/** 将 ProxyInfo 持久化到数据库(明文存储——后台内部管理工具,无需加密) */
|
||||
async persist(
|
||||
info: ProxyInfo & { name?: string; provider?: string },
|
||||
manager?: any,
|
||||
): Promise<GeoProxyIpEntity> {
|
||||
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<string> {
|
||||
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<any>((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<string>((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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/backend/src/modules/project/config.ts
Normal file
19
packages/backend/src/modules/project/config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ModuleConfig } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* 模块配置
|
||||
*/
|
||||
export default () => {
|
||||
return {
|
||||
// 模块名称
|
||||
name: '项目管理',
|
||||
// 模块描述
|
||||
description: '项目进度管理,支持甘特图、日历、看板等视图',
|
||||
// 中间件
|
||||
middlewares: [],
|
||||
// 全局中间件
|
||||
globalMiddlewares: [],
|
||||
// 模块加载顺序
|
||||
order: 0,
|
||||
} as ModuleConfig;
|
||||
};
|
||||
@ -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 {}
|
||||
@ -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 {}
|
||||
@ -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<string, any>) {
|
||||
// 只允许级联特定字段,防止越权修改
|
||||
const allowed = ['status', 'priority', 'category', 'assigneeName', 'startDate', 'endDate', 'estimatedHours'];
|
||||
const safeFields: Record<string, any> = {};
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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 {}
|
||||
36
packages/backend/src/modules/project/entity/info.ts
Normal file
36
packages/backend/src/modules/project/entity/info.ts
Normal file
@ -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;
|
||||
}
|
||||
33
packages/backend/src/modules/project/entity/phase.ts
Normal file
33
packages/backend/src/modules/project/entity/phase.ts
Normal file
@ -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;
|
||||
}
|
||||
63
packages/backend/src/modules/project/entity/task.ts
Normal file
63
packages/backend/src/modules/project/entity/task.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
28
packages/backend/src/modules/project/entity/time_log.ts
Normal file
28
packages/backend/src/modules/project/entity/time_log.ts
Normal file
@ -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;
|
||||
}
|
||||
134
packages/backend/src/modules/project/service/gantt.ts
Normal file
134
packages/backend/src/modules/project/service/gantt.ts
Normal file
@ -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<ProjectTaskEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectPhaseEntity)
|
||||
projectPhaseEntity: Repository<ProjectPhaseEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTaskDependencyEntity)
|
||||
projectTaskDependencyEntity: Repository<ProjectTaskDependencyEntity>;
|
||||
|
||||
/**
|
||||
* 获取甘特图数据(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
packages/backend/src/modules/project/service/info.ts
Normal file
92
packages/backend/src/modules/project/service/info.ts
Normal file
@ -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<ProjectInfoEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectPhaseEntity)
|
||||
projectPhaseEntity: Repository<ProjectPhaseEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTaskEntity)
|
||||
projectTaskEntity: Repository<ProjectTaskEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTaskDependencyEntity)
|
||||
projectTaskDependencyEntity: Repository<ProjectTaskDependencyEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTimeLogEntity)
|
||||
projectTimeLogEntity: Repository<ProjectTimeLogEntity>;
|
||||
|
||||
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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
73
packages/backend/src/modules/project/service/phase.ts
Normal file
73
packages/backend/src/modules/project/service/phase.ts
Normal file
@ -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<ProjectPhaseEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTaskEntity)
|
||||
projectTaskEntity: Repository<ProjectTaskEntity>;
|
||||
|
||||
@Inject()
|
||||
projectInfoService: ProjectInfoService;
|
||||
|
||||
private affectedProjectIds = new Set<number>();
|
||||
|
||||
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<number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
packages/backend/src/modules/project/service/task.ts
Normal file
240
packages/backend/src/modules/project/service/task.ts
Normal file
@ -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<ProjectTaskEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectPhaseEntity)
|
||||
projectPhaseEntity: Repository<ProjectPhaseEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTimeLogEntity)
|
||||
projectTimeLogEntity: Repository<ProjectTimeLogEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTaskDependencyEntity)
|
||||
projectTaskDependencyEntity: Repository<ProjectTaskDependencyEntity>;
|
||||
|
||||
@Inject()
|
||||
projectInfoService: ProjectInfoService;
|
||||
|
||||
private affectedPhaseIds = new Set<number>();
|
||||
private affectedProjectIds = new Set<number>();
|
||||
|
||||
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<ProjectTaskEntity>) {
|
||||
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<number>();
|
||||
this.affectedProjectIds = new Set<number>();
|
||||
|
||||
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<boolean> {
|
||||
const count = await this.projectTaskEntity.count({ where: { parentId: taskId } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联更新子任务字段(递归)
|
||||
*/
|
||||
async cascadeUpdateFields(taskId: number, fields: Record<string, any>) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
78
packages/backend/src/modules/project/service/time_log.ts
Normal file
78
packages/backend/src/modules/project/service/time_log.ts
Normal file
@ -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<ProjectTimeLogEntity>;
|
||||
|
||||
@InjectEntityModel(ProjectTaskEntity)
|
||||
projectTaskEntity: Repository<ProjectTaskEntity>;
|
||||
|
||||
private affectedTaskIds = new Set<number>();
|
||||
|
||||
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<number>();
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
26
packages/frontend/src/modules/geo/config.ts
Normal file
26
packages/frontend/src/modules/geo/config.ts
Normal file
@ -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'),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
589
packages/frontend/src/modules/geo/views/accounts.vue
Normal file
589
packages/frontend/src/modules/geo/views/accounts.vue
Normal file
@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<cl-crud ref="Crud">
|
||||
<cl-row>
|
||||
<cl-refresh-btn />
|
||||
<el-button type="primary" @click="openAddDialog">{{ t('新增账号') }}</el-button>
|
||||
<cl-multi-delete-btn />
|
||||
<cl-flex1 />
|
||||
<cl-search-key :placeholder="t('搜索昵称、登录账号、sessionName')" />
|
||||
</cl-row>
|
||||
|
||||
<cl-row>
|
||||
<cl-table ref="Table" />
|
||||
</cl-row>
|
||||
|
||||
<cl-row>
|
||||
<cl-flex1 />
|
||||
<cl-pagination />
|
||||
</cl-row>
|
||||
|
||||
<cl-upsert ref="Upsert" />
|
||||
|
||||
<!-- 编辑 Dialog(自定义,支持 IP 模式切换) -->
|
||||
<el-dialog v-model="editDialogVisible" :title="t('编辑账号')" width="560px" destroy-on-close>
|
||||
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="120px">
|
||||
<el-form-item :label="t('账号昵称')" prop="name">
|
||||
<el-input v-model="editForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('平台')" prop="platform">
|
||||
<el-select v-model="editForm.platform" style="width: 100%">
|
||||
<el-option v-for="(label, value) in PLATFORM_LABELS" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('登录账号')" prop="loginAccount">
|
||||
<el-input v-model="editForm.loginAccount" :placeholder="t('登录后自动获取')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('执行 Agent')" prop="agentId">
|
||||
<el-select v-model="editForm.agentId" style="width: 100%" filterable clearable>
|
||||
<el-option v-for="a in agentList" :key="a.id" :label="a.label || a.name" :value="a.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('IP 模式')" prop="ipMode">
|
||||
<el-radio-group v-model="editForm.ipMode">
|
||||
<el-radio value="local">{{ t('本地(直连)') }}</el-radio>
|
||||
<el-radio value="third_party">{{ t('第三方独立 IP') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.ipMode === 'third_party'" :label="t('选择 IP')" prop="proxyId">
|
||||
<el-select v-model="editForm.proxyId" style="width: 100%" filterable :loading="ipListLoading">
|
||||
<el-option
|
||||
v-for="ip in availableIpList"
|
||||
:key="ip.id"
|
||||
:label="`${ip.name} - ${ip.host}:${ip.port}${ip.city ? ' (' + ip.city + ')' : ''}`"
|
||||
:value="ip.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="SessionName">
|
||||
<el-input v-model="editForm.sessionName" disabled />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">{{ t('取消') }}</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="submitEditForm">{{ t('保存') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增账号 Dialog -->
|
||||
<el-dialog v-model="addDialogVisible" :title="t('新增账号')" width="540px" destroy-on-close>
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="120px">
|
||||
<el-form-item :label="t('账号昵称')" prop="name">
|
||||
<el-input v-model="addForm.name" :placeholder="t('用于自己识别的备注名')" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('平台')" prop="platform">
|
||||
<el-select v-model="addForm.platform" :placeholder="t('请选择平台')" style="width: 100%">
|
||||
<el-option v-for="(label, value) in PLATFORM_LABELS" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('IP 模式')" prop="ipMode">
|
||||
<el-radio-group v-model="addForm.ipMode">
|
||||
<el-radio value="local">{{ t('本地(直连)') }}</el-radio>
|
||||
<el-radio value="third_party">{{ t('第三方独立 IP') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="addForm.ipMode === 'third_party'" :label="t('选择 IP')" prop="proxyId">
|
||||
<el-select
|
||||
v-model="addForm.proxyId"
|
||||
:placeholder="t('请选择未绑定的 IP')"
|
||||
style="width: 100%"
|
||||
:loading="ipListLoading"
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="ip in availableIpList"
|
||||
:key="ip.id"
|
||||
:label="`${ip.name} - ${ip.host}:${ip.port}${ip.city ? ' (' + ip.city + ')' : ''}`"
|
||||
:value="ip.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('执行 Agent')" prop="agentId">
|
||||
<el-select
|
||||
v-model="addForm.agentId"
|
||||
:placeholder="t('选择哪个 Agent 执行账号管理(登录、运营等)')"
|
||||
style="width: 100%"
|
||||
:loading="agentListLoading"
|
||||
filterable
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="a in agentList"
|
||||
:key="a.id"
|
||||
:label="a.label || a.name"
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('SessionName')" prop="sessionName">
|
||||
<el-input
|
||||
v-model="addForm.sessionName"
|
||||
:placeholder="t('留空自动生成 platform-id(推荐)')"
|
||||
/>
|
||||
<div class="form-tip">{{ t('netabrowser-cli 用它定位 profile 目录,相同名字会自动恢复登录态') }}</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">{{ t('取消') }}</el-button>
|
||||
<el-button type="primary" :loading="addSubmitting" @click="submitAddForm">{{ t('确认') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</cl-crud>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'geo-accounts' });
|
||||
|
||||
import { reactive, ref, onMounted, computed } from 'vue';
|
||||
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
|
||||
import { useCool } from '/@/cool';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
const { service } = useCool();
|
||||
const { t } = useI18n();
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
taobao: '淘宝',
|
||||
xiaohongshu: '小红书',
|
||||
douyin: '抖音',
|
||||
weibo: '微博',
|
||||
zhihu: '知乎',
|
||||
wechat: '微信',
|
||||
};
|
||||
|
||||
// =================== 新增账号 Dialog ===================
|
||||
const addDialogVisible = ref(false);
|
||||
const addSubmitting = ref(false);
|
||||
const addFormRef = ref<FormInstance>();
|
||||
|
||||
const availableIpList = ref<any[]>([]);
|
||||
const ipListLoading = ref(false);
|
||||
const agentList = ref<any[]>([]);
|
||||
const agentListLoading = ref(false);
|
||||
|
||||
// id → 显示名 映射(label 优先,否则 name)
|
||||
const agentNameMap = computed<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {};
|
||||
for (const a of agentList.value) {
|
||||
m[a.id] = a.label || a.name || `#${a.id}`;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
// 格式化 cookie 过期时间:显示日期 + 剩余天数
|
||||
function formatCookieExpires(row: any): string {
|
||||
if (!row.cookieExpiresAt) return '-';
|
||||
const ts = new Date(row.cookieExpiresAt).getTime();
|
||||
if (Number.isNaN(ts)) return '-';
|
||||
const days = Math.ceil((ts - Date.now()) / 86400000);
|
||||
const dateStr = new Date(ts).toLocaleString('zh-CN', { hour12: false });
|
||||
if (days <= 0) return `${dateStr} ⚠️ 已过期`;
|
||||
if (days <= 7) return `${dateStr} (剩 ${days}天)`;
|
||||
return `${dateStr} (剩 ${days}天)`;
|
||||
}
|
||||
|
||||
interface AddForm {
|
||||
name: string;
|
||||
platform: string;
|
||||
ipMode: 'local' | 'third_party';
|
||||
proxyId: number | null;
|
||||
agentId: number | null;
|
||||
sessionName: string;
|
||||
}
|
||||
|
||||
const addForm = reactive<AddForm>({
|
||||
name: '',
|
||||
platform: '',
|
||||
ipMode: 'local',
|
||||
proxyId: null,
|
||||
agentId: null,
|
||||
sessionName: '',
|
||||
});
|
||||
|
||||
const addFormRules: FormRules = {
|
||||
name: [{ required: true, message: t('请输入账号昵称'), trigger: 'blur' }],
|
||||
platform: [{ required: true, message: t('请选择平台'), trigger: 'change' }],
|
||||
ipMode: [{ required: true, message: t('请选择 IP 模式'), trigger: 'change' }],
|
||||
proxyId: [
|
||||
{
|
||||
validator: (_rule, value, cb) => {
|
||||
if (addForm.ipMode === 'third_party' && !value) {
|
||||
cb(new Error(t('选择第三方 IP 时必须指定一个 IP')));
|
||||
} else cb();
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 全部 IP 列表(用于表格显示 id → name 映射,含已绑定的)
|
||||
const allIpList = ref<any[]>([]);
|
||||
const ipNameMap = computed<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {};
|
||||
for (const ip of allIpList.value) {
|
||||
// 显示格式:"name | host:port (city)"
|
||||
const cityPart = ip.city ? ` (${ip.city})` : '';
|
||||
m[ip.id] = `${ip.name || ip.host} | ${ip.host}:${ip.port}${cityPart}`;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
async function loadAllIps() {
|
||||
try {
|
||||
const res: any = await service.geo.proxy_ip.list({});
|
||||
allIpList.value = Array.isArray(res) ? res : (res?.list ?? []);
|
||||
} catch (e: any) {
|
||||
console.warn('[geo-accounts] load all ips failed:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableIps(includeIpId?: number | null) {
|
||||
ipListLoading.value = true;
|
||||
try {
|
||||
const res: any = await service.geo.proxy_ip.list({});
|
||||
const all: any[] = Array.isArray(res) ? res : (res?.list ?? []);
|
||||
availableIpList.value = all.filter(ip =>
|
||||
ip.status !== 'deleted' && (!ip.bindAccountId || ip.id === includeIpId)
|
||||
);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('加载 IP 列表失败'));
|
||||
} finally {
|
||||
ipListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
agentListLoading.value = true;
|
||||
try {
|
||||
// 用 options 接口拿已发布的 agent 列表(专为下拉框设计)
|
||||
const res: any = await service.request({
|
||||
url: '/admin/netaclaw/agent/options',
|
||||
method: 'POST',
|
||||
});
|
||||
agentList.value = Array.isArray(res) ? res : (res?.list ?? res ?? []);
|
||||
} catch (e: any) {
|
||||
// 拉不到也不阻塞,agent 是可选项
|
||||
console.warn('[geo-accounts] load agents failed:', e?.message);
|
||||
agentList.value = [];
|
||||
} finally {
|
||||
agentListLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
Object.assign(addForm, {
|
||||
name: '',
|
||||
platform: '',
|
||||
ipMode: 'local',
|
||||
proxyId: null,
|
||||
agentId: null,
|
||||
sessionName: '',
|
||||
});
|
||||
addDialogVisible.value = true;
|
||||
loadAvailableIps();
|
||||
loadAgents();
|
||||
}
|
||||
|
||||
async function submitAddForm() {
|
||||
if (!addFormRef.value) return;
|
||||
await addFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
addSubmitting.value = true;
|
||||
try {
|
||||
await service.request({
|
||||
url: '/admin/geo/account/add',
|
||||
method: 'POST',
|
||||
data: {
|
||||
name: addForm.name,
|
||||
platform: addForm.platform,
|
||||
proxyId: addForm.ipMode === 'third_party' ? addForm.proxyId : undefined,
|
||||
agentId: addForm.agentId || undefined,
|
||||
sessionName: addForm.sessionName?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
ElMessage.success(t('账号创建成功'));
|
||||
addDialogVisible.value = false;
|
||||
Crud.value?.refresh();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('创建失败'));
|
||||
} finally {
|
||||
addSubmitting.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =================== 启动登录 ===================
|
||||
async function launchAccount(row: any) {
|
||||
try {
|
||||
await service.request({
|
||||
url: '/admin/geo/account/launch',
|
||||
method: 'POST',
|
||||
data: { id: row.id },
|
||||
});
|
||||
ElMessageBox.alert(
|
||||
t('浏览器已打开(sessionName: {s}),请在窗口中完成登录后点「抓 Cookie」按钮保存登录态。', { s: row.sessionName }),
|
||||
t('请在浏览器中登录'),
|
||||
{ type: 'info', confirmButtonText: t('知道了') },
|
||||
);
|
||||
Crud.value?.refresh();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('启动失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function captureCookies(row: any) {
|
||||
try {
|
||||
const res: any = await service.request({
|
||||
url: '/admin/geo/account/captureCookies',
|
||||
method: 'POST',
|
||||
data: { id: row.id },
|
||||
});
|
||||
const captured: number = res?.captured ?? 0;
|
||||
if (captured === 0) {
|
||||
ElMessage.warning(t('未捕获到 Cookie,请先在浏览器中完成登录'));
|
||||
} else {
|
||||
ElMessage.success(`成功抓取 ${captured} 条 Cookie`);
|
||||
Crud.value?.refresh();
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('抓取失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAccount(row: any) {
|
||||
try {
|
||||
await service.request({
|
||||
url: '/admin/geo/account/close',
|
||||
method: 'POST',
|
||||
data: { id: row.id },
|
||||
});
|
||||
ElMessage.success(t('已关闭浏览器'));
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('关闭失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAccount(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('重置会清空 cookie + 生成新 sessionName + 新指纹 + 删除浏览器 profile 目录。下次启动等于全新设备。IP 绑定不变。确认重置?'),
|
||||
t('重置确认'),
|
||||
{ type: 'warning', confirmButtonText: t('确认重置'), cancelButtonText: t('取消') },
|
||||
);
|
||||
await service.request({
|
||||
url: '/admin/geo/account/resetSession',
|
||||
method: 'POST',
|
||||
data: { id: row.id },
|
||||
});
|
||||
ElMessage.success(t('已重置,下次启动是全新设备'));
|
||||
Crud.value?.refresh();
|
||||
} catch (e: any) {
|
||||
if (e === 'cancel' || e === 'close') return;
|
||||
ElMessage.error(e?.message || t('重置失败'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('确定要删除账号「{name}」吗?登录态会丢失。', { name: row.name }),
|
||||
t('删除确认'),
|
||||
{ type: 'warning', confirmButtonText: t('删除'), cancelButtonText: t('取消') },
|
||||
);
|
||||
await service.request({
|
||||
url: '/admin/geo/account/deleteAccount',
|
||||
method: 'POST',
|
||||
data: { id: row.id },
|
||||
});
|
||||
ElMessage.success(t('删除成功'));
|
||||
Crud.value?.refresh();
|
||||
} catch (e: any) {
|
||||
if (e === 'cancel' || e === 'close') return;
|
||||
ElMessage.error(e?.message || t('删除失败'));
|
||||
}
|
||||
}
|
||||
|
||||
// =================== 编辑 Dialog ===================
|
||||
const editDialogVisible = ref(false);
|
||||
const editSubmitting = ref(false);
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const editForm = reactive<{
|
||||
id: number | null;
|
||||
name: string;
|
||||
platform: string;
|
||||
loginAccount: string;
|
||||
agentId: number | null;
|
||||
ipMode: 'local' | 'third_party';
|
||||
proxyId: number | null;
|
||||
sessionName: string;
|
||||
}>({
|
||||
id: null,
|
||||
name: '',
|
||||
platform: '',
|
||||
loginAccount: '',
|
||||
agentId: null,
|
||||
ipMode: 'local',
|
||||
proxyId: null,
|
||||
sessionName: '',
|
||||
});
|
||||
|
||||
const editFormRules: FormRules = {
|
||||
name: [{ required: true, message: t('请输入账号昵称'), trigger: 'blur' }],
|
||||
platform: [{ required: true, message: t('请选择平台'), trigger: 'change' }],
|
||||
proxyId: [
|
||||
{
|
||||
validator: (_r, v, cb) => {
|
||||
if (editForm.ipMode === 'third_party' && !v) cb(new Error(t('请选择 IP')));
|
||||
else cb();
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function openEditDialog(row: any) {
|
||||
Object.assign(editForm, {
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
platform: row.platform || '',
|
||||
loginAccount: row.loginAccount || '',
|
||||
agentId: row.agentId || null,
|
||||
ipMode: row.proxyId ? 'third_party' : 'local',
|
||||
proxyId: row.proxyId || null,
|
||||
sessionName: row.sessionName || '',
|
||||
});
|
||||
(editForm as any).__originalProxyId = row.proxyId || null;
|
||||
editDialogVisible.value = true;
|
||||
loadAvailableIps(row.proxyId); // 包含当前账号已绑定的 IP
|
||||
}
|
||||
|
||||
async function submitEditForm() {
|
||||
if (!editFormRef.value || !editForm.id) return;
|
||||
await editFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
// 检测 IP 是否变更
|
||||
const newProxyId = editForm.ipMode === 'third_party' ? editForm.proxyId : null;
|
||||
const oldProxyId = (editForm as any).__originalProxyId ?? null;
|
||||
const ipChanged = (newProxyId ?? null) !== (oldProxyId ?? null);
|
||||
|
||||
// IP 变了:要求确认(会清登录态)
|
||||
if (ipChanged) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('IP 变更会重置 sessionName + 清空 cookie + 关闭浏览器。这是养号必须的安全操作(同一账号变 IP 不变 cookie 会被风控判异常)。确认继续?'),
|
||||
t('IP 变更确认'),
|
||||
{ type: 'warning', confirmButtonText: t('确认变更'), cancelButtonText: t('取消') },
|
||||
);
|
||||
} catch {
|
||||
return; // 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
editSubmitting.value = true;
|
||||
try {
|
||||
// 1. 更新基础字段
|
||||
await service.geo.account.update({
|
||||
id: editForm.id,
|
||||
name: editForm.name,
|
||||
platform: editForm.platform,
|
||||
loginAccount: editForm.loginAccount || null,
|
||||
agentId: editForm.agentId || null,
|
||||
});
|
||||
// 2. 切换 IP 模式(IP 变了才调)
|
||||
if (ipChanged) {
|
||||
await service.request({
|
||||
url: '/admin/geo/account/setProxy',
|
||||
method: 'POST',
|
||||
data: { id: editForm.id, proxyId: newProxyId },
|
||||
});
|
||||
ElMessage.success(t('保存成功,IP 已更换,需要重新登录'));
|
||||
} else {
|
||||
ElMessage.success(t('保存成功'));
|
||||
}
|
||||
editDialogVisible.value = false;
|
||||
Crud.value?.refresh();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || t('保存失败'));
|
||||
} finally {
|
||||
editSubmitting.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const Table = useTable({
|
||||
contextMenu: ['refresh', 'edit', 'delete'],
|
||||
columns: [
|
||||
{ type: 'selection' },
|
||||
{ label: 'ID', prop: 'id', width: 60 },
|
||||
{ label: t('昵称'), prop: 'name', minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
label: t('平台'),
|
||||
prop: 'platform',
|
||||
minWidth: 90,
|
||||
formatter: (row) => PLATFORM_LABELS[row.platform] || row.platform || '-',
|
||||
},
|
||||
{ label: t('登录账号'), prop: 'loginAccount', minWidth: 130, showOverflowTooltip: true },
|
||||
{ label: 'SessionName', prop: 'sessionName', minWidth: 150, showOverflowTooltip: true },
|
||||
{
|
||||
label: t('登录状态'),
|
||||
prop: 'loginStatus',
|
||||
minWidth: 100,
|
||||
formatter(row) {
|
||||
const map: Record<string, string> = {
|
||||
never: '未登录',
|
||||
logged_in: '已登录',
|
||||
expired: '已过期',
|
||||
banned: '已封禁',
|
||||
deleted: '已删除',
|
||||
};
|
||||
return map[row.loginStatus] || row.loginStatus || '-';
|
||||
},
|
||||
},
|
||||
{ label: t('IP'), prop: 'proxyId', minWidth: 220, showOverflowTooltip: true, formatter: (row) => row.proxyId ? (ipNameMap.value[row.proxyId] || `#${row.proxyId}`) : t('本地') },
|
||||
{ label: 'Agent', prop: 'agentId', minWidth: 110, formatter: (row) => row.agentId ? (agentNameMap.value[row.agentId] || `#${row.agentId}`) : '-' },
|
||||
{ label: t('Cookie 过期'), prop: 'cookieExpiresAt', minWidth: 160, sortable: 'custom', formatter: formatCookieExpires },
|
||||
{
|
||||
type: 'op',
|
||||
width: 280,
|
||||
buttons: [
|
||||
{ label: t('启动登录'), type: 'primary', onClick: ({ scope }) => launchAccount(scope.row) },
|
||||
{ label: t('抓 Cookie'), type: 'success', onClick: ({ scope }) => captureCookies(scope.row) },
|
||||
{ label: t('编辑'), onClick: ({ scope }) => openEditDialog(scope.row) },
|
||||
{ label: t('关闭'), onClick: ({ scope }) => closeAccount(scope.row) },
|
||||
{ label: t('重置'), type: 'warning', onClick: ({ scope }) => resetAccount(scope.row) },
|
||||
{ label: t('删除'), type: 'danger', onClick: ({ scope }) => deleteAccount(scope.row) },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const Crud = useCrud(
|
||||
{ service: service.geo.account },
|
||||
(app) => {
|
||||
app.refresh();
|
||||
}
|
||||
);
|
||||
|
||||
// 进入页面时预加载 agent 列表 + IP 列表,用于表格显示名字
|
||||
onMounted(() => {
|
||||
loadAgents();
|
||||
loadAllIps();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
17
packages/frontend/src/modules/geo/views/dashboard.vue
Normal file
17
packages/frontend/src/modules/geo/views/dashboard.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="geo-dashboard">
|
||||
<el-card>
|
||||
<el-empty description="账号环境总览(账号矩阵、IP 池、登录态与运行趋势)" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'geo-dashboard' });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.geo-dashboard {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
304
packages/frontend/src/modules/geo/views/proxies.vue
Normal file
304
packages/frontend/src/modules/geo/views/proxies.vue
Normal file
@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<cl-crud ref="Crud">
|
||||
<cl-row>
|
||||
<cl-refresh-btn />
|
||||
<cl-add-btn />
|
||||
<cl-multi-delete-btn />
|
||||
<el-button :icon="unmaskAll ? 'Hide' : 'View'" @click="unmaskAll = !unmaskAll">
|
||||
{{ unmaskAll ? t('隐藏全部密码') : t('显示全部密码') }}
|
||||
</el-button>
|
||||
<cl-flex1 />
|
||||
<cl-search-key :placeholder="t('搜索名称、IP、城市')" />
|
||||
</cl-row>
|
||||
|
||||
<cl-row>
|
||||
<cl-table ref="Table">
|
||||
<!-- IP:端口号 + 出口 IP -->
|
||||
<template #column-ipPort="{ scope }">
|
||||
<div class="ip-cell">
|
||||
<div class="ip-main">{{ scope.row.host }}:{{ scope.row.port }}</div>
|
||||
<div v-if="scope.row.exitIp" class="ip-exit">出: {{ scope.row.exitIp }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 账号 | 密码 -->
|
||||
<template #column-credential="{ scope }">
|
||||
<div class="cred-cell">
|
||||
<span>{{ scope.row.username || '-' }}</span>
|
||||
<span class="cred-sep">|</span>
|
||||
<span>{{ isUnmasked(scope.row.id) ? scope.row.password : maskPwd(scope.row.password) }}</span>
|
||||
<el-icon class="cred-toggle" @click.stop="toggleUnmask(scope.row.id)">
|
||||
<component :is="isUnmasked(scope.row.id) ? 'Hide' : 'View'" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 剩余时长 -->
|
||||
<template #column-remainDays="{ scope }">
|
||||
<el-tag v-if="scope.row.expiresAt" :type="remainTagType(scope.row.expiresAt)" size="small">
|
||||
{{ remainText(scope.row.expiresAt) }}
|
||||
</el-tag>
|
||||
<span v-else class="text-gray">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 状态 -->
|
||||
<template #column-status="{ scope }">
|
||||
<el-tag :type="statusTagType(scope.row.status)" size="small">
|
||||
{{ statusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</cl-table>
|
||||
</cl-row>
|
||||
|
||||
<cl-row>
|
||||
<cl-flex1 />
|
||||
<cl-pagination />
|
||||
</cl-row>
|
||||
|
||||
<cl-upsert ref="Upsert" />
|
||||
</cl-crud>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'geo-proxies' });
|
||||
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useCrud, useTable, useUpsert } from '@cool-vue/crud';
|
||||
import { useCool } from '/@/cool';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const { service } = useCool();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 密码遮罩状态
|
||||
const unmaskAll = ref(false);
|
||||
const unmaskRows = reactive<Record<number, boolean>>({});
|
||||
|
||||
function isUnmasked(id: number): boolean {
|
||||
return unmaskAll.value || !!unmaskRows[id];
|
||||
}
|
||||
function toggleUnmask(id: number) {
|
||||
unmaskRows[id] = !unmaskRows[id];
|
||||
}
|
||||
function maskPwd(pwd: string | undefined | null): string {
|
||||
if (!pwd) return '-';
|
||||
return '••••••';
|
||||
}
|
||||
|
||||
// 剩余天数
|
||||
function remainDays(expiresAt: string | Date): number {
|
||||
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86400000);
|
||||
}
|
||||
function remainText(expiresAt: string | Date): string {
|
||||
const d = remainDays(expiresAt);
|
||||
if (d <= 0) return t('已过期');
|
||||
return d + t('天');
|
||||
}
|
||||
function remainTagType(expiresAt: string | Date): 'success' | 'warning' | 'danger' {
|
||||
const d = remainDays(expiresAt);
|
||||
if (d <= 0) return 'danger';
|
||||
if (d <= 7) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// 状态
|
||||
function statusText(s: string): string {
|
||||
const map: Record<string, string> = {
|
||||
active: t('正常'),
|
||||
expired: t('已过期'),
|
||||
error: t('异常'),
|
||||
unbound: t('未绑定'),
|
||||
};
|
||||
return map[s] || s || '-';
|
||||
}
|
||||
function statusTagType(s: string): 'success' | 'danger' | 'warning' | 'info' {
|
||||
if (s === 'active') return 'success';
|
||||
if (s === 'expired' || s === 'error') return 'danger';
|
||||
if (s === 'unbound') return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
// 编辑表单
|
||||
const Upsert = useUpsert({
|
||||
dialog: { width: '600px' },
|
||||
props: { labelWidth: '100px' },
|
||||
items: [
|
||||
{ label: t('名称'), prop: 'name', required: true, component: { name: 'el-input' } },
|
||||
{
|
||||
label: t('供应商'),
|
||||
prop: 'provider',
|
||||
required: true,
|
||||
value: 'local',
|
||||
component: {
|
||||
name: 'el-select',
|
||||
options: [
|
||||
{ label: 'Local', value: 'local' },
|
||||
{ label: '天启', value: 'tianqi' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('模式'),
|
||||
prop: 'mode',
|
||||
required: true,
|
||||
value: 'local',
|
||||
component: {
|
||||
name: 'el-select',
|
||||
options: [
|
||||
{ label: '本地', value: 'local' },
|
||||
{ label: '第三方', value: 'third_party' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('协议'),
|
||||
prop: 'protocol',
|
||||
value: 'http',
|
||||
component: {
|
||||
name: 'el-select',
|
||||
options: [
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
{ label: 'SOCKS5', value: 'socks5' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ label: t('主机'), prop: 'host', component: { name: 'el-input', props: { placeholder: '210.51.27.112' } } },
|
||||
{
|
||||
label: t('端口'),
|
||||
prop: 'port',
|
||||
component: { name: 'el-input-number', props: { min: 1, max: 65535, controlsPosition: 'right' } },
|
||||
},
|
||||
{ label: t('用户名'), prop: 'username', component: { name: 'el-input' } },
|
||||
{ label: t('密码'), prop: 'password', component: { name: 'el-input', props: { showPassword: true } } },
|
||||
{ label: t('出口IP'), prop: 'exitIp', component: { name: 'el-input', props: { placeholder: '可选,测试后自动填' } } },
|
||||
{ label: t('开通城市'), prop: 'city', component: { name: 'el-input', props: { placeholder: '上海' } } },
|
||||
{ label: t('套餐ID'), prop: 'packageId', component: { name: 'el-input' } },
|
||||
{ label: t('地区'), prop: 'region', component: { name: 'el-input' } },
|
||||
{ label: t('ISP'), prop: 'isp', component: { name: 'el-input' } },
|
||||
{
|
||||
label: t('过期时间'),
|
||||
prop: 'expiresAt',
|
||||
component: {
|
||||
name: 'el-date-picker',
|
||||
props: { type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss', placeholder: '选择过期时间' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 表格
|
||||
const Table = useTable({
|
||||
contextMenu: ['refresh', 'edit', 'delete'],
|
||||
columns: [
|
||||
{ type: 'selection' },
|
||||
{ label: 'ID', prop: 'id', width: 60 },
|
||||
{ label: t('名称'), prop: 'name', minWidth: 120, showOverflowTooltip: true },
|
||||
{ label: t('IP地址:端口号'), prop: 'ipPort', minWidth: 180 },
|
||||
{ label: t('账号|密码'), prop: 'credential', minWidth: 220 },
|
||||
{ label: t('开通城市'), prop: 'city', minWidth: 100 },
|
||||
{ label: t('剩余时长'), prop: 'remainDays', minWidth: 110 },
|
||||
{ label: t('所属套餐'), prop: 'packageId', minWidth: 180, showOverflowTooltip: true },
|
||||
{ label: t('协议'), prop: 'protocol', minWidth: 80 },
|
||||
{ label: t('状态'), prop: 'status', minWidth: 100 },
|
||||
{ label: t('延迟(ms)'), prop: 'latencyMs', minWidth: 90 },
|
||||
{ label: t('最后检测'), prop: 'lastCheckAt', minWidth: 160, sortable: 'custom' },
|
||||
{
|
||||
type: 'op',
|
||||
width: 200,
|
||||
buttons: [
|
||||
{
|
||||
label: t('测试'),
|
||||
type: 'primary',
|
||||
onClick({ scope }) {
|
||||
testProxy(scope.row.id);
|
||||
},
|
||||
},
|
||||
'edit',
|
||||
'delete',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// CRUD
|
||||
const Crud = useCrud(
|
||||
{ service: service.geo.proxy_ip },
|
||||
(app) => {
|
||||
app.refresh();
|
||||
}
|
||||
);
|
||||
|
||||
// 测试 IP(入站+出站)
|
||||
async function testProxy(id: number) {
|
||||
const loading = ElMessage({ message: t('正在测试 IP...'), type: 'info', duration: 0 });
|
||||
try {
|
||||
const r: any = await service.request({
|
||||
url: '/admin/geo/proxy_ip/test',
|
||||
method: 'POST',
|
||||
data: { id },
|
||||
});
|
||||
loading.close();
|
||||
if (r?.ok) {
|
||||
const lines = [
|
||||
`✅ ${t('代理可用')}`,
|
||||
`${t('延迟')}: ${r.latencyMs}ms`,
|
||||
`${t('出口IP')}: ${r.exitIp}`,
|
||||
`${t('入口IP')}: ${r.expectedIp}`,
|
||||
r.ipMatch === false
|
||||
? `⚠️ ${t('出口IP与入口IP不一致(住宅代理常见,正常)')}`
|
||||
: `✓ ${t('出口IP匹配')}`,
|
||||
];
|
||||
ElMessageBox.alert(lines.join('\n'), t('测试结果'), { confirmButtonText: t('确定'), customStyle: { whiteSpace: 'pre-line' } });
|
||||
} else {
|
||||
ElMessageBox.alert(`❌ ${t('代理不可用')}\n${t('延迟')}: ${r?.latencyMs ?? '-'}ms\n${t('错误')}: ${r?.error || t('未知')}`, t('测试结果'), {
|
||||
type: 'error',
|
||||
confirmButtonText: t('确定'),
|
||||
customStyle: { whiteSpace: 'pre-line' },
|
||||
});
|
||||
}
|
||||
Crud.value?.refresh();
|
||||
} catch (e: any) {
|
||||
loading.close();
|
||||
ElMessage.error(e?.message || t('测试失败'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ip-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.4;
|
||||
.ip-main {
|
||||
font-weight: 500;
|
||||
}
|
||||
.ip-exit {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.cred-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
.cred-sep {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.cred-toggle {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
margin-left: 4px;
|
||||
&:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-gray {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
21
packages/frontend/src/modules/project/config.ts
Normal file
21
packages/frontend/src/modules/project/config.ts
Normal file
@ -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')
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
114
packages/frontend/src/modules/project/store/project.ts
Normal file
114
packages/frontend/src/modules/project/store/project.ts
Normal file
@ -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<any>(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,
|
||||
};
|
||||
});
|
||||
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { useProjectStore } from '../../store/project';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-task', taskId: number): void;
|
||||
}>();
|
||||
|
||||
const store = useProjectStore();
|
||||
const calendarRef = ref();
|
||||
|
||||
// Tab 切换后首次渲染时容器尺寸未稳定,需要延迟刷新布局
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
calendarRef.value?.getApi()?.updateSize();
|
||||
}, 150);
|
||||
});
|
||||
});
|
||||
|
||||
const events = computed(() => {
|
||||
return store.tasks.map((t: any) => {
|
||||
const taskId = parseInt(String(t.id).replace('t_', ''));
|
||||
const priorityColor: Record<number, string> = {
|
||||
0: '#F56C6C',
|
||||
1: '#E6A23C',
|
||||
2: '#409EFF',
|
||||
3: '#909399',
|
||||
};
|
||||
return {
|
||||
id: String(taskId),
|
||||
title: t.text,
|
||||
start: t.start_date,
|
||||
end: t.end_date,
|
||||
color: t.color || priorityColor[t.priority] || '#409EFF',
|
||||
extendedProps: { taskId },
|
||||
};
|
||||
}).filter((e: any) => e.start);
|
||||
});
|
||||
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
locale: 'zh-cn',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek',
|
||||
},
|
||||
events: events.value,
|
||||
editable: false,
|
||||
eventClick: (info: any) => {
|
||||
const taskId = info.event.extendedProps.taskId;
|
||||
if (taskId) {
|
||||
emit('open-task', taskId);
|
||||
}
|
||||
},
|
||||
height: 'calc(100vh - 260px)',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar-view {
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
183
packages/frontend/src/modules/project/views/components/gantt.vue
Normal file
183
packages/frontend/src/modules/project/views/components/gantt.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="gantt-view">
|
||||
<div ref="ganttContainer" class="gantt-view__chart" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { gantt } from 'dhtmlx-gantt';
|
||||
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css';
|
||||
import { useProjectStore } from '../../store/project';
|
||||
import { useCool } from '/@/cool';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-task', taskId: number): void;
|
||||
}>();
|
||||
|
||||
const store = useProjectStore();
|
||||
const { service } = useCool();
|
||||
const ganttContainer = ref<HTMLElement>();
|
||||
|
||||
function initGantt() {
|
||||
if (!ganttContainer.value) return;
|
||||
|
||||
// 中文 locale
|
||||
(gantt as any).locale = {
|
||||
date: {
|
||||
month_full: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'],
|
||||
month_short: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||||
day_full: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'],
|
||||
day_short: ['日', '一', '二', '三', '四', '五', '六'],
|
||||
},
|
||||
labels: {
|
||||
new_task: '新任务',
|
||||
dhx_cal_today_button: '今天',
|
||||
day_tab: '日',
|
||||
week_tab: '周',
|
||||
month_tab: '月',
|
||||
new_event: '新建事件',
|
||||
icon_save: '保存',
|
||||
icon_cancel: '取消',
|
||||
icon_details: '详情',
|
||||
icon_edit: '编辑',
|
||||
icon_delete: '删除',
|
||||
confirm_closing: '',
|
||||
confirm_deleting: '确定删除吗?',
|
||||
section_description: '描述',
|
||||
section_time: '时间',
|
||||
section_type: '类型',
|
||||
column_wbs: 'WBS',
|
||||
column_text: '任务名称',
|
||||
column_start_date: '开始时间',
|
||||
column_duration: '工期',
|
||||
column_add: '',
|
||||
link: '关联',
|
||||
confirm_link_deleting: '将被删除',
|
||||
link_start: '(开始)',
|
||||
link_end: '(结束)',
|
||||
type_task: '任务',
|
||||
type_project: '项目',
|
||||
type_milestone: '里程碑',
|
||||
minutes: '分钟',
|
||||
hours: '小时',
|
||||
days: '天',
|
||||
weeks: '周',
|
||||
months: '月',
|
||||
years: '年',
|
||||
message_ok: '确定',
|
||||
message_cancel: '取消',
|
||||
},
|
||||
};
|
||||
|
||||
gantt.config.date_format = '%Y-%m-%d';
|
||||
gantt.config.min_column_width = 70;
|
||||
gantt.config.row_height = 36;
|
||||
gantt.config.drag_resize = true;
|
||||
gantt.config.drag_move = true;
|
||||
gantt.config.drag_links = true;
|
||||
gantt.config.auto_scheduling = false;
|
||||
gantt.config.open_tree_initially = true;
|
||||
gantt.config.fit_tasks = true;
|
||||
|
||||
// 双行时间轴:上面显示"年-月",下面显示"日 周几"
|
||||
gantt.config.scales = [
|
||||
{ unit: 'month', step: 1, format: '%Y年%m月' },
|
||||
{ unit: 'day', step: 1, format: '%d日 %D' },
|
||||
];
|
||||
|
||||
gantt.config.columns = [
|
||||
{ name: 'text', label: '任务名称', width: 200, tree: true },
|
||||
{ name: 'start_date', label: '开始', align: 'center', width: 100 },
|
||||
{ name: 'end_date', label: '结束', align: 'center', width: 100 },
|
||||
{ name: 'assignee', label: '负责人', align: 'center', width: 70 },
|
||||
];
|
||||
|
||||
gantt.attachEvent('onTaskDblClick', (id: string) => {
|
||||
if (id.startsWith('t_')) {
|
||||
const taskId = parseInt(id.replace('t_', ''));
|
||||
emit('open-task', taskId);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
gantt.attachEvent('onAfterTaskDrag', (id: string) => {
|
||||
const task = gantt.getTask(id);
|
||||
const formatTaskDate = (date?: Date) => date ? gantt.templates.format_date(date) : '';
|
||||
// 使用 setTimeout 避免在 gantt 事件回调中同步触发 store 更新导致递归
|
||||
setTimeout(() => {
|
||||
store.ganttUpdate([
|
||||
{
|
||||
id,
|
||||
start_date: formatTaskDate(task.start_date),
|
||||
end_date: formatTaskDate(task.end_date),
|
||||
parent: task.parent,
|
||||
},
|
||||
]);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
gantt.attachEvent('onAfterLinkAdd', (id: any, link: any) => {
|
||||
const taskId = parseInt(String(link.target).replace('t_', ''));
|
||||
const dependsOnTaskId = parseInt(String(link.source).replace('t_', ''));
|
||||
if (taskId && dependsOnTaskId) {
|
||||
void service.project.task_dependency.add({
|
||||
taskId,
|
||||
dependsOnTaskId,
|
||||
type: parseInt(link.type) || 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gantt.init(ganttContainer.value);
|
||||
}
|
||||
|
||||
function renderData() {
|
||||
const { data, links } = store.ganttData;
|
||||
gantt.clearAll();
|
||||
if (data.length > 0) {
|
||||
// 没有日期的任务给默认日期(今天~明天),让用户可以在甘特图上看到并拖拽
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const tomorrowStr = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
const fixedData = data.map((d: any) => {
|
||||
const start = d.start_date || todayStr;
|
||||
let end = d.end_date || tomorrowStr;
|
||||
// dhtmlx-gantt 的 end_date 是"不包含"边界,start===end 时持续时间为0看不到
|
||||
// 同一天的任务至少显示为1天的条形
|
||||
if (end <= start) {
|
||||
const next = new Date(start);
|
||||
next.setDate(next.getDate() + 1);
|
||||
end = next.toISOString().slice(0, 10);
|
||||
}
|
||||
return { ...d, start_date: start, end_date: end };
|
||||
});
|
||||
gantt.parse({ data: fixedData, links });
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => store.ganttData, () => {
|
||||
renderData();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
initGantt();
|
||||
renderData();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
gantt.clearAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gantt-view {
|
||||
&__chart {
|
||||
width: 100%;
|
||||
height: calc(100vh - 260px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="kanban-view">
|
||||
<div v-for="col in columns" :key="col.status" class="kanban-column">
|
||||
<div class="kanban-column__header">
|
||||
<span>{{ col.label }}</span>
|
||||
<el-tag size="small" round>{{ col.items.length }}</el-tag>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="col.items"
|
||||
group="kanban"
|
||||
item-key="id"
|
||||
class="kanban-column__body"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="kanban-card" @click="openTask(element)">
|
||||
<div class="kanban-card__title">{{ element.text }}</div>
|
||||
<div class="kanban-card__meta">
|
||||
<el-tag
|
||||
:type="priorityTagType(element.priority)"
|
||||
size="small"
|
||||
>
|
||||
{{ priorityLabel(element.priority) }}
|
||||
</el-tag>
|
||||
<span v-if="element.assignee" class="kanban-card__assignee">
|
||||
{{ element.assignee }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="element.end_date" class="kanban-card__date">
|
||||
截止: {{ element.end_date }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import { useProjectStore } from '../../store/project';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-task', taskId: number): void;
|
||||
}>();
|
||||
|
||||
const store = useProjectStore();
|
||||
|
||||
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
|
||||
const priorityMap: Record<number, string> = { 0: 'P0 紧急', 1: 'P1 高', 2: 'P2 中', 3: 'P3 低' };
|
||||
const priorityTagMap: Record<number, TagType> = { 0: 'danger', 1: 'warning', 2: 'primary', 3: 'info' };
|
||||
function priorityLabel(p: number) { return priorityMap[p] || ''; }
|
||||
function priorityTagType(p: number) { return priorityTagMap[p] || 'info'; }
|
||||
|
||||
const columns = ref([
|
||||
{ status: 0, label: '待办', items: [] as any[] },
|
||||
{ status: 1, label: '进行中', items: [] as any[] },
|
||||
{ status: 2, label: '已完成', items: [] as any[] },
|
||||
{ status: 3, label: '已关闭', items: [] as any[] },
|
||||
]);
|
||||
|
||||
function syncFromStore() {
|
||||
const allTasks = store.tasks;
|
||||
columns.value[0].items = allTasks.filter((t: any) => t.status === 0);
|
||||
columns.value[1].items = allTasks.filter((t: any) => t.status === 1);
|
||||
columns.value[2].items = allTasks.filter((t: any) => t.status === 2);
|
||||
columns.value[3].items = allTasks.filter((t: any) => t.status === 3);
|
||||
}
|
||||
|
||||
watch(() => store.ganttData, () => { syncFromStore(); }, { deep: true, immediate: true });
|
||||
|
||||
function openTask(element: any) {
|
||||
const taskId = parseInt(String(element.id).replace('t_', ''));
|
||||
emit('open-task', taskId);
|
||||
}
|
||||
|
||||
async function onDragEnd() {
|
||||
const items: any[] = [];
|
||||
for (const col of columns.value) {
|
||||
col.items.forEach((item: any, index: number) => {
|
||||
const taskId = parseInt(String(item.id).replace('t_', ''));
|
||||
items.push({ id: taskId, status: col.status, sortOrder: index });
|
||||
});
|
||||
}
|
||||
await store.kanbanSort(items);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kanban-view {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
min-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 0 0 280px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
min-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e4e7ed;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__assignee {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="阶段管理" width="820px">
|
||||
<div style="margin-bottom: 12px">
|
||||
<el-button type="primary" size="small" @click="addPhase">添加阶段</el-button>
|
||||
</div>
|
||||
<el-table :data="phases" size="small">
|
||||
<el-table-column prop="name" label="阶段名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-if="row.editing" v-model="row.name" size="small" />
|
||||
<span v-else>{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="分类" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input v-if="row.editing" v-model="row.type" size="small" />
|
||||
<span v-else>{{ row.type || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startDate" label="开始时间" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker
|
||||
v-if="row.editing"
|
||||
v-model="row.startDate"
|
||||
type="date"
|
||||
size="small"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="开始"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<span v-else>{{ row.startDate || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="endDate" label="结束时间" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker
|
||||
v-if="row.editing"
|
||||
v-model="row.endDate"
|
||||
type="date"
|
||||
size="small"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="结束"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<span v-else>{{ row.endDate || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-if="row.editing" v-model="row.sortOrder" size="small" :min="0" controls-position="right" style="width: 60px" />
|
||||
<span v-else>{{ row.sortOrder }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.editing">
|
||||
<el-button link type="primary" size="small" @click="savePhase(row)">保存</el-button>
|
||||
<el-button link size="small" @click="cancelEdit(row)">取消</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button link type="primary" size="small" @click="row.editing = true">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deletePhase(row)">删除</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useCool } from '/@/cool';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
projectId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void;
|
||||
(e: 'saved'): void;
|
||||
}>();
|
||||
|
||||
const { service } = useCool();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
const phases = ref<any[]>([]);
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) loadPhases();
|
||||
});
|
||||
|
||||
async function loadPhases() {
|
||||
const res = await service.project.phase.list({ projectId: props.projectId });
|
||||
phases.value = (res || []).map((p: any) => ({ ...p, editing: false }));
|
||||
}
|
||||
|
||||
function addPhase() {
|
||||
phases.value.push({
|
||||
id: undefined,
|
||||
projectId: props.projectId,
|
||||
name: '',
|
||||
type: '',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
sortOrder: phases.value.length,
|
||||
editing: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function savePhase(row: any) {
|
||||
if (!row.name) { ElMessage.warning('请输入阶段名称'); return; }
|
||||
if (row.id) {
|
||||
await service.project.phase.update(row);
|
||||
} else {
|
||||
await service.project.phase.add(row);
|
||||
}
|
||||
ElMessage.success('保存成功');
|
||||
row.editing = false;
|
||||
emit('saved');
|
||||
await loadPhases();
|
||||
}
|
||||
|
||||
function cancelEdit(row: any) {
|
||||
if (!row.id) {
|
||||
phases.value = phases.value.filter(p => p !== row);
|
||||
} else {
|
||||
row.editing = false;
|
||||
loadPhases();
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePhase(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除阶段「${row.name}」?`, '提示', { type: 'warning' });
|
||||
await service.project.phase.delete({ ids: [row.id] });
|
||||
ElMessage.success('删除成功');
|
||||
emit('saved');
|
||||
await loadPhases();
|
||||
}
|
||||
</script>
|
||||
454
packages/frontend/src/modules/project/views/components/table.vue
Normal file
454
packages/frontend/src/modules/project/views/components/table.vue
Normal file
@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="table-view">
|
||||
<div class="table-view__filter">
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 100px">
|
||||
<el-option label="待办" :value="0" />
|
||||
<el-option label="进行中" :value="1" />
|
||||
<el-option label="已完成" :value="2" />
|
||||
<el-option label="已关闭" :value="3" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.priority" placeholder="优先级" clearable style="width: 100px">
|
||||
<el-option label="P0 紧急" :value="0" />
|
||||
<el-option label="P1 高" :value="1" />
|
||||
<el-option label="P2 中" :value="2" />
|
||||
<el-option label="P3 低" :value="3" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="filters.keyWord"
|
||||
placeholder="搜索任务"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
<el-button type="primary" @click="emit('open-task', 0)">新建任务</el-button>
|
||||
<el-button type="success" :icon="Download" :loading="exporting" @click="exportGanttExcel">
|
||||
导出Excel
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="treeData"
|
||||
style="width: 100%"
|
||||
height="100%"
|
||||
row-key="id"
|
||||
default-expand-all
|
||||
:tree-props="{ children: 'children' }"
|
||||
>
|
||||
<el-table-column prop="name" label="名称" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'phase-name': row._isPhase }">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row._isPhase ? phaseStatusTagType(row.status) : statusTagType(row.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ row._isPhase ? phaseStatusLabel(row.status) : statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" label="优先级" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="!row._isPhase && row.priority !== undefined">
|
||||
<el-tag :type="priorityTagType(row.priority)" size="small">
|
||||
{{ priorityLabel(row.priority) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-placeholder">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.category || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignee" label="负责人" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.assignee || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.startDate || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.endDate || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="进度" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress" :stroke-width="6" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="!row._isPhase">
|
||||
<el-button link type="primary" size="small" @click="emit('open-task', row._taskId)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { Download } from '@element-plus/icons-vue';
|
||||
import { useProjectStore } from '../../store/project';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-task', taskId: number): void;
|
||||
}>();
|
||||
|
||||
const store = useProjectStore();
|
||||
const filters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
priority: undefined as number | undefined,
|
||||
keyWord: '',
|
||||
});
|
||||
|
||||
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
type ExcelStyle = any;
|
||||
|
||||
// 阶段状态映射
|
||||
const phaseStatusMap: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已完成' };
|
||||
const phaseStatusTagMap: Record<number, TagType> = { 0: 'info', 1: 'primary', 2: 'success' };
|
||||
// 任务状态映射
|
||||
const statusMap: Record<number, string> = { 0: '待办', 1: '进行中', 2: '已完成', 3: '已关闭' };
|
||||
const statusTagMap: Record<number, TagType> = { 0: 'info', 1: 'primary', 2: 'success', 3: 'warning' };
|
||||
const priorityMap: Record<number, string> = { 0: 'P0 紧急', 1: 'P1 高', 2: 'P2 中', 3: 'P3 低' };
|
||||
const priorityTagMap: Record<number, TagType> = { 0: 'danger', 1: 'warning', 2: 'primary', 3: 'info' };
|
||||
|
||||
function phaseStatusLabel(s: number) { return phaseStatusMap[s] || '未知'; }
|
||||
function phaseStatusTagType(s: number) { return phaseStatusTagMap[s] || 'info'; }
|
||||
function statusLabel(s: number) { return statusMap[s] || '未知'; }
|
||||
function statusTagType(s: number) { return statusTagMap[s] || 'info'; }
|
||||
function priorityLabel(p: number) { return priorityMap[p] || ''; }
|
||||
function priorityTagType(p: number) { return priorityTagMap[p] || 'info'; }
|
||||
|
||||
/**
|
||||
* 从 ganttData 构建树形表格数据
|
||||
* 层级: 阶段(phase) → 任务(task) → 子任务(subtask)
|
||||
*/
|
||||
const treeData = computed(() => {
|
||||
const { data } = store.ganttData;
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// 分离阶段和任务
|
||||
const phases = data.filter((d: any) => String(d.id).startsWith('p_'));
|
||||
const tasks = data.filter((d: any) => String(d.id).startsWith('t_'));
|
||||
|
||||
// 筛选任务
|
||||
const filtered = tasks.filter((t: any) => {
|
||||
if (filters.status !== undefined && t.status !== filters.status) return false;
|
||||
if (filters.priority !== undefined && t.priority !== filters.priority) return false;
|
||||
if (filters.keyWord && !t.text.includes(filters.keyWord)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 构建任务 map(用 gantt id 做 key)
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of filtered) {
|
||||
taskMap.set(t.id, {
|
||||
id: t.id,
|
||||
_taskId: parseInt(String(t.id).replace('t_', '')),
|
||||
_isPhase: false,
|
||||
name: t.text,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
category: t.category,
|
||||
assignee: t.assignee,
|
||||
startDate: t.start_date,
|
||||
endDate: t.end_date,
|
||||
progress: Math.round((t.progress || 0) * 100),
|
||||
sortOrder: t.sortOrder,
|
||||
children: [] as any[],
|
||||
});
|
||||
}
|
||||
|
||||
// 组装子任务到父任务
|
||||
const rootTasks: any[] = []; // 没有 parent 的任务
|
||||
const phaseChildren = new Map<string, any[]>(); // phaseId → tasks
|
||||
|
||||
for (const t of filtered) {
|
||||
const node = taskMap.get(t.id);
|
||||
if (!node) continue;
|
||||
|
||||
if (t.parent && t.parent.startsWith('t_') && taskMap.has(t.parent)) {
|
||||
// 子任务挂到父任务
|
||||
taskMap.get(t.parent).children.push(node);
|
||||
} else if (t.parent && t.parent.startsWith('p_')) {
|
||||
// 任务挂到阶段
|
||||
const arr = phaseChildren.get(t.parent) || [];
|
||||
arr.push(node);
|
||||
phaseChildren.set(t.parent, arr);
|
||||
} else {
|
||||
rootTasks.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建阶段节点
|
||||
const tree: any[] = [];
|
||||
for (const p of phases) {
|
||||
const children = phaseChildren.get(p.id) || [];
|
||||
// 如果有筛选条件且该阶段下没有匹配的任务,跳过
|
||||
const hasFilter = filters.status !== undefined || filters.priority !== undefined || filters.keyWord;
|
||||
if (hasFilter && children.length === 0) continue;
|
||||
|
||||
tree.push({
|
||||
id: p.id,
|
||||
_isPhase: true,
|
||||
name: p.text,
|
||||
status: p.status,
|
||||
startDate: p.start_date || p.end_date,
|
||||
endDate: p.end_date || p.start_date,
|
||||
progress: Math.round((p.progress || 0) * 100),
|
||||
sortOrder: p.sortOrder,
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
// 无阶段的根任务追加到末尾
|
||||
tree.push(...rootTasks);
|
||||
|
||||
return tree;
|
||||
});
|
||||
|
||||
const exporting = ref(false);
|
||||
|
||||
/**
|
||||
* 导出甘特图样式 Excel
|
||||
* 左侧: 层级名称 | 状态 | 负责人 | 开始 | 结束
|
||||
* 右侧: 日期列,用彩色填充表示工期条
|
||||
*/
|
||||
async function exportGanttExcel() {
|
||||
const rows = treeData.value;
|
||||
if (!rows || rows.length === 0) {
|
||||
ElMessage.warning('暂无数据可导出');
|
||||
return;
|
||||
}
|
||||
|
||||
exporting.value = true;
|
||||
try {
|
||||
const ExcelJS = await import('exceljs');
|
||||
const { saveAs } = await import('file-saver');
|
||||
|
||||
// 展平树形数据,记录层级
|
||||
const flatRows: { row: any; level: number }[] = [];
|
||||
function flatten(items: any[], level: number) {
|
||||
for (const item of items) {
|
||||
flatRows.push({ row: item, level });
|
||||
if (item.children?.length) flatten(item.children, level + 1);
|
||||
}
|
||||
}
|
||||
flatten(rows, 0);
|
||||
|
||||
// 计算日期范围
|
||||
let minDate = '', maxDate = '';
|
||||
for (const { row } of flatRows) {
|
||||
if (row.startDate && (!minDate || row.startDate < minDate)) minDate = row.startDate;
|
||||
if (row.endDate && (!maxDate || row.endDate > maxDate)) maxDate = row.endDate;
|
||||
}
|
||||
if (!minDate || !maxDate) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
minDate = minDate || today;
|
||||
maxDate = maxDate || today;
|
||||
}
|
||||
// 前后各留 3 天
|
||||
const rangeStart = new Date(minDate);
|
||||
rangeStart.setDate(rangeStart.getDate() - 3);
|
||||
const rangeEnd = new Date(maxDate);
|
||||
rangeEnd.setDate(rangeEnd.getDate() + 3);
|
||||
|
||||
// 生成日期列表
|
||||
const dates: string[] = [];
|
||||
const d = new Date(rangeStart);
|
||||
while (d <= rangeEnd) {
|
||||
dates.push(d.toISOString().slice(0, 10));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet('甘特图');
|
||||
|
||||
// 固定列定义
|
||||
const fixedCols = ['名称', '状态', '负责人', '开始', '结束'];
|
||||
const fixedColCount = fixedCols.length;
|
||||
|
||||
// 表头行1: 固定列 + 月份合并
|
||||
const headerRow1Data: string[] = [...fixedCols];
|
||||
// 表头行2: 固定列空 + 日期(日)
|
||||
const headerRow2Data: string[] = fixedCols.map(() => '');
|
||||
|
||||
// 按月分组日期,用于合并单元格
|
||||
const monthGroups: { label: string; start: number; count: number }[] = [];
|
||||
let lastMonth = '';
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const dt = new Date(dates[i]);
|
||||
const monthLabel = `${dt.getFullYear()}年${dt.getMonth() + 1}月`;
|
||||
const dayLabel = `${dt.getDate()}日`;
|
||||
headerRow1Data.push(monthLabel);
|
||||
headerRow2Data.push(dayLabel);
|
||||
if (monthLabel !== lastMonth) {
|
||||
monthGroups.push({ label: monthLabel, start: fixedColCount + i + 1, count: 1 });
|
||||
lastMonth = monthLabel;
|
||||
} else {
|
||||
monthGroups[monthGroups.length - 1].count++;
|
||||
}
|
||||
}
|
||||
|
||||
const row1 = ws.addRow(headerRow1Data);
|
||||
const row2 = ws.addRow(headerRow2Data);
|
||||
|
||||
// 合并月份单元格
|
||||
for (const mg of monthGroups) {
|
||||
if (mg.count > 1) {
|
||||
ws.mergeCells(1, mg.start, 1, mg.start + mg.count - 1);
|
||||
}
|
||||
}
|
||||
// 合并固定列表头(行1和行2)
|
||||
for (let c = 1; c <= fixedColCount; c++) {
|
||||
ws.mergeCells(1, c, 2, c);
|
||||
}
|
||||
|
||||
// 表头样式
|
||||
const headerFill: ExcelStyle = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } };
|
||||
const headerFont: ExcelStyle = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 };
|
||||
const headerAlign: ExcelStyle = { horizontal: 'center', vertical: 'middle' };
|
||||
const thinBorder: ExcelStyle = {
|
||||
top: { style: 'thin', color: { argb: 'FFD0D0D0' } },
|
||||
bottom: { style: 'thin', color: { argb: 'FFD0D0D0' } },
|
||||
left: { style: 'thin', color: { argb: 'FFD0D0D0' } },
|
||||
right: { style: 'thin', color: { argb: 'FFD0D0D0' } },
|
||||
};
|
||||
|
||||
for (const r of [row1, row2]) {
|
||||
r.eachCell(cell => {
|
||||
cell.fill = headerFill;
|
||||
cell.font = headerFont;
|
||||
cell.alignment = headerAlign;
|
||||
cell.border = thinBorder;
|
||||
});
|
||||
r.height = 22;
|
||||
}
|
||||
|
||||
// 颜色配置
|
||||
const phaseBarColor = 'FF2F5496'; // 深蓝 - 阶段
|
||||
const taskBarColor = 'FF5B9BD5'; // 蓝色 - 任务
|
||||
const subtaskBarColor = 'FF9DC3E6'; // 浅蓝 - 子任务
|
||||
const phaseBgColor = 'FFDCE6F1'; // 阶段行背景
|
||||
|
||||
// 写入数据行
|
||||
for (const { row: item, level } of flatRows) {
|
||||
const indent = level > 0 ? ' '.repeat(level) : '';
|
||||
const name = indent + item.name;
|
||||
const status = item._isPhase ? phaseStatusLabel(item.status) : statusLabel(item.status);
|
||||
const assignee = item.assignee || '';
|
||||
const startDate = item.startDate || '';
|
||||
const endDate = item.endDate || '';
|
||||
|
||||
const rowData: any[] = [name, status, assignee, startDate, endDate];
|
||||
|
||||
// 日期列填充
|
||||
for (const _date of dates) {
|
||||
rowData.push('');
|
||||
}
|
||||
|
||||
const dataRow = ws.addRow(rowData);
|
||||
dataRow.height = 24;
|
||||
|
||||
// 固定列样式
|
||||
for (let c = 1; c <= fixedColCount; c++) {
|
||||
const cell = dataRow.getCell(c);
|
||||
cell.border = thinBorder;
|
||||
cell.alignment = { vertical: 'middle', horizontal: c === 1 ? 'left' : 'center' };
|
||||
cell.font = { size: 10, bold: item._isPhase };
|
||||
if (item._isPhase) {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: phaseBgColor } };
|
||||
}
|
||||
}
|
||||
|
||||
// 甘特条填充
|
||||
if (startDate && endDate) {
|
||||
const barColor = item._isPhase ? phaseBarColor : level >= 2 ? subtaskBarColor : taskBarColor;
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const cell = dataRow.getCell(fixedColCount + i + 1);
|
||||
cell.border = thinBorder;
|
||||
if (dates[i] >= startDate && dates[i] <= endDate) {
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: barColor } };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 列宽设置
|
||||
ws.getColumn(1).width = 30; // 名称
|
||||
ws.getColumn(2).width = 10; // 状态
|
||||
ws.getColumn(3).width = 10; // 负责人
|
||||
ws.getColumn(4).width = 12; // 开始
|
||||
ws.getColumn(5).width = 12; // 结束
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
ws.getColumn(fixedColCount + i + 1).width = 4.5;
|
||||
}
|
||||
|
||||
// 冻结左侧固定列 + 表头
|
||||
ws.views = [{ state: 'frozen', xSplit: fixedColCount, ySplit: 2 }];
|
||||
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const projectName = store.currentProject?.name || '项目';
|
||||
saveAs(new Blob([buf]), `${projectName}_甘特图.xlsx`);
|
||||
ElMessage.success('导出成功');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
ElMessage.error('导出失败: ' + (e.message || e));
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除任务「${row.name}」?`, '提示', { type: 'warning' });
|
||||
await store.deleteTask([row._taskId]);
|
||||
ElMessage.success('删除成功');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table-view {
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
&__filter {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.text-placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="任务详情" size="500px" @close="handleClose">
|
||||
<el-form v-if="form" :model="form" label-width="80px">
|
||||
<el-form-item label="任务名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="parentName" label="上级任务">
|
||||
<el-link type="primary" @click="openParentTask">{{ parentName }}</el-link>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="阶段">
|
||||
<el-select v-model="form.phaseId" placeholder="选择阶段" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="p in store.phases"
|
||||
:key="p.id"
|
||||
:label="p.text"
|
||||
:value="parseInt(String(p.id).replace('p_', ''))"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="待办" :value="0" />
|
||||
<el-option label="进行中" :value="1" />
|
||||
<el-option label="已完成" :value="2" />
|
||||
<el-option label="已关闭" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="form.priority" style="width: 100%">
|
||||
<el-option label="P0 紧急" :value="0" />
|
||||
<el-option label="P1 高" :value="1" />
|
||||
<el-option label="P2 中" :value="2" />
|
||||
<el-option label="P3 低" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="form.category" placeholder="如:前端、后端、运营" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="form.assigneeName" placeholder="负责人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="日期范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始"
|
||||
end-placeholder="结束"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="预估工时">
|
||||
<el-input-number v-model="form.estimatedHours" :min="0" :step="0.5" />
|
||||
<span style="margin-left: 8px; color: #909399">
|
||||
实际: {{ form.actualHours || 0 }}h
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="进度">
|
||||
<el-slider v-model="form.progress" :max="100" show-input />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 工时记录 -->
|
||||
<div v-if="taskId" style="padding: 0 20px">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
|
||||
<span style="font-weight: 600">工时记录</span>
|
||||
<el-button size="small" type="primary" @click="timeLogVisible = true">
|
||||
添加工时
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="timeLogs" size="small" max-height="200">
|
||||
<el-table-column prop="logDate" label="日期" width="100" />
|
||||
<el-table-column prop="hours" label="工时(h)" width="70" align="center" />
|
||||
<el-table-column prop="userName" label="记录人" width="70" />
|
||||
<el-table-column prop="description" label="内容" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
|
||||
<time-log-dialog
|
||||
v-model="timeLogVisible"
|
||||
:task-id="taskId!"
|
||||
@saved="loadTimeLogs"
|
||||
/>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useCool } from '/@/cool';
|
||||
import { useProjectStore } from '../../store/project';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import TimeLogDialog from './time-log-dialog.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
taskId: number | null;
|
||||
projectId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void;
|
||||
(e: 'saved'): void;
|
||||
(e: 'open-task', id: number): void;
|
||||
}>();
|
||||
|
||||
const { service } = useCool();
|
||||
const store = useProjectStore();
|
||||
|
||||
// 用 computed 替代双向 watch,避免无限递归
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
const saving = ref(false);
|
||||
const timeLogVisible = ref(false);
|
||||
const timeLogs = ref<any[]>([]);
|
||||
const dateRange = ref<string[]>([]);
|
||||
const form = ref<any>({});
|
||||
const parentName = ref('');
|
||||
const originalFields = ref<Record<string, any>>({});
|
||||
|
||||
function toNumber(value: unknown) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function toDateString(value: unknown) {
|
||||
if (!value) return '';
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) loadTaskData();
|
||||
});
|
||||
|
||||
async function loadTaskData() {
|
||||
const id = props.taskId;
|
||||
if (id && id > 0) {
|
||||
const res = await service.project.task.info({ id });
|
||||
form.value = { ...res };
|
||||
// MySQL decimal 类型返回字符串,转为数字供 el-input-number 使用
|
||||
form.value.estimatedHours = toNumber(res.estimatedHours);
|
||||
form.value.actualHours = toNumber(res.actualHours);
|
||||
form.value.progress = toNumber(res.progress);
|
||||
const startDate = toDateString(res.startDate);
|
||||
const endDate = toDateString(res.endDate);
|
||||
form.value.startDate = startDate || null;
|
||||
form.value.endDate = endDate || null;
|
||||
originalFields.value = {
|
||||
status: res.status,
|
||||
priority: res.priority,
|
||||
category: res.category,
|
||||
assigneeName: res.assigneeName,
|
||||
startDate,
|
||||
endDate,
|
||||
estimatedHours: toNumber(res.estimatedHours),
|
||||
};
|
||||
dateRange.value = startDate ? [startDate, endDate] : [];
|
||||
if (res.parentId) {
|
||||
const parent = await service.project.task.info({ id: res.parentId });
|
||||
parentName.value = parent?.name || '';
|
||||
} else {
|
||||
parentName.value = '';
|
||||
}
|
||||
await loadTimeLogs();
|
||||
} else if (id === 0) {
|
||||
form.value = {
|
||||
projectId: props.projectId,
|
||||
name: '',
|
||||
description: '',
|
||||
status: 0,
|
||||
priority: 2,
|
||||
category: '',
|
||||
assigneeName: '',
|
||||
estimatedHours: 0,
|
||||
progress: 0,
|
||||
};
|
||||
dateRange.value = [];
|
||||
timeLogs.value = [];
|
||||
parentName.value = '';
|
||||
originalFields.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTimeLogs() {
|
||||
if (!props.taskId) return;
|
||||
const res = await service.project.time_log.page({
|
||||
page: 1,
|
||||
size: 50,
|
||||
taskId: props.taskId,
|
||||
});
|
||||
timeLogs.value = res.list || [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name) { ElMessage.warning('请输入任务名称'); return; }
|
||||
saving.value = true;
|
||||
form.value.startDate = dateRange.value?.[0] || null;
|
||||
form.value.endDate = dateRange.value?.[1] || null;
|
||||
try {
|
||||
if (form.value.id) {
|
||||
await service.project.task.update(form.value);
|
||||
|
||||
// 检测哪些可级联字段发生了变化
|
||||
const cascadeKeys: { key: string; label: string }[] = [
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'priority', label: '优先级' },
|
||||
{ key: 'category', label: '分类' },
|
||||
{ key: 'assigneeName', label: '负责人' },
|
||||
{ key: 'startDate', label: '开始日期' },
|
||||
{ key: 'endDate', label: '结束日期' },
|
||||
{ key: 'estimatedHours', label: '预估工时' },
|
||||
];
|
||||
const changedFields: Record<string, any> = {};
|
||||
const changedLabels: string[] = [];
|
||||
for (const { key, label } of cascadeKeys) {
|
||||
const oldVal = originalFields.value[key] ?? null;
|
||||
const newVal = form.value[key] ?? null;
|
||||
if (String(oldVal) !== String(newVal)) {
|
||||
changedFields[key] = newVal;
|
||||
changedLabels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedLabels.length > 0) {
|
||||
try {
|
||||
const has = await service.request({
|
||||
url: '/admin/project/task/hasChildren',
|
||||
params: { id: form.value.id },
|
||||
});
|
||||
if (has) {
|
||||
await ElMessageBox.confirm(
|
||||
`「${changedLabels.join('、')}」已修改,是否将所有子任务也同步修改?`,
|
||||
'同步子任务',
|
||||
{ confirmButtonText: '是,同步修改', cancelButtonText: '否,仅修改当前', type: 'info' }
|
||||
);
|
||||
await service.request({
|
||||
url: '/admin/project/task/cascadeStatus',
|
||||
method: 'POST',
|
||||
data: { id: form.value.id, fields: changedFields },
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 用户点击"否",不做级联
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await service.project.task.add(form.value);
|
||||
}
|
||||
ElMessage.success('保存成功');
|
||||
emit('saved');
|
||||
visible.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
form.value = {};
|
||||
parentName.value = '';
|
||||
}
|
||||
|
||||
function openParentTask() {
|
||||
const pid = form.value.parentId;
|
||||
if (pid) {
|
||||
visible.value = false;
|
||||
setTimeout(() => {
|
||||
emit('open-task', pid);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="添加工时" width="400px">
|
||||
<el-form :model="form" label-width="70px">
|
||||
<el-form-item label="日期" required>
|
||||
<el-date-picker v-model="form.logDate" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="工时" required>
|
||||
<el-input-number v-model="form.hours" :min="0.5" :step="0.5" :max="24" />
|
||||
<span style="margin-left: 8px">小时</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useCool } from '/@/cool';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
taskId: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void;
|
||||
(e: 'saved'): void;
|
||||
}>();
|
||||
|
||||
const { service } = useCool();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
const saving = ref(false);
|
||||
const form = reactive({
|
||||
logDate: '',
|
||||
hours: 1,
|
||||
description: '',
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.logDate) { ElMessage.warning('请选择日期'); return; }
|
||||
saving.value = true;
|
||||
try {
|
||||
await service.project.time_log.add({
|
||||
taskId: props.taskId,
|
||||
logDate: form.logDate,
|
||||
hours: form.hours,
|
||||
description: form.description,
|
||||
userName: '当前用户',
|
||||
});
|
||||
ElMessage.success('工时已记录');
|
||||
emit('saved');
|
||||
visible.value = false;
|
||||
form.logDate = '';
|
||||
form.hours = 1;
|
||||
form.description = '';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
179
packages/frontend/src/modules/project/views/detail.vue
Normal file
179
packages/frontend/src/modules/project/views/detail.vue
Normal file
@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="project-detail" v-loading="loading">
|
||||
<!-- 顶部项目信息栏 -->
|
||||
<div class="project-detail__header">
|
||||
<el-page-header @back="goBack">
|
||||
<template #content>
|
||||
<div class="project-detail__info">
|
||||
<span
|
||||
class="project-detail__color"
|
||||
:style="{ backgroundColor: project?.color || '#409EFF' }"
|
||||
/>
|
||||
<span class="project-detail__name">{{ project?.name }}</span>
|
||||
<el-tag :type="statusTagType(project?.status)" size="small">
|
||||
{{ statusLabel(project?.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<div class="project-detail__extra">
|
||||
<span v-if="project?.ownerName" class="project-detail__owner">
|
||||
负责人: {{ project.ownerName }}
|
||||
</span>
|
||||
<el-progress
|
||||
:percentage="project?.progress || 0"
|
||||
:stroke-width="8"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-button size="small" @click="phaseManagerVisible = true">
|
||||
阶段管理
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-page-header>
|
||||
</div>
|
||||
|
||||
<!-- Tab 视图切换 -->
|
||||
<el-tabs v-model="activeTab" class="project-detail__tabs">
|
||||
<el-tab-pane label="甘特图" name="gantt" lazy>
|
||||
<gantt-view @open-task="openTaskDrawer" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="日历" name="calendar" lazy>
|
||||
<calendar-view @open-task="openTaskDrawer" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="列表" name="table" lazy>
|
||||
<table-view @open-task="openTaskDrawer" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="看板" name="kanban" lazy>
|
||||
<kanban-view @open-task="openTaskDrawer" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 任务详情抽屉 -->
|
||||
<task-drawer
|
||||
v-model="taskDrawerVisible"
|
||||
:task-id="currentTaskId"
|
||||
:project-id="projectId"
|
||||
@saved="store.refresh()"
|
||||
@open-task="openTaskDrawer"
|
||||
/>
|
||||
|
||||
<!-- 阶段管理弹窗 -->
|
||||
<phase-manager
|
||||
v-model="phaseManagerVisible"
|
||||
:project-id="projectId"
|
||||
@saved="store.refresh()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useProjectStore } from '../store/project';
|
||||
import GanttView from './components/gantt.vue';
|
||||
import CalendarView from './components/calendar.vue';
|
||||
import TableView from './components/table.vue';
|
||||
import KanbanView from './components/kanban.vue';
|
||||
import TaskDrawer from './components/task-drawer.vue';
|
||||
import PhaseManager from './components/phase-manager.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useProjectStore();
|
||||
|
||||
const projectId = computed(() => Number(route.query.id));
|
||||
const project = computed(() => store.currentProject);
|
||||
const loading = ref(true);
|
||||
const activeTab = ref('gantt');
|
||||
const taskDrawerVisible = ref(false);
|
||||
const phaseManagerVisible = ref(false);
|
||||
const currentTaskId = ref<number | null>(null);
|
||||
|
||||
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
|
||||
const statusMap: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已完成', 3: '已归档' };
|
||||
const statusTagMap: Record<number, TagType> = { 0: 'info', 1: 'primary', 2: 'success', 3: 'warning' };
|
||||
|
||||
function statusLabel(s?: number) { return s !== undefined ? statusMap[s] || '未知' : ''; }
|
||||
function statusTagType(s?: number) { return s !== undefined ? statusTagMap[s] || 'info' : 'info'; }
|
||||
|
||||
function openTaskDrawer(taskId: number) {
|
||||
currentTaskId.value = taskId;
|
||||
taskDrawerVisible.value = true;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/project/list');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
await store.loadProject(projectId.value);
|
||||
await store.loadGanttData(projectId.value);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
store.reset();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-detail {
|
||||
padding: 20px;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__owner {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 200px);
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
296
packages/frontend/src/modules/project/views/list.vue
Normal file
296
packages/frontend/src/modules/project/views/list.vue
Normal file
@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="project-list">
|
||||
<div class="project-list__header">
|
||||
<div class="project-list__title">项目管理</div>
|
||||
<el-button type="primary" @click="openAdd">新建项目</el-button>
|
||||
</div>
|
||||
|
||||
<div class="project-list__filter">
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 120px">
|
||||
<el-option label="未开始" :value="0" />
|
||||
<el-option label="进行中" :value="1" />
|
||||
<el-option label="已完成" :value="2" />
|
||||
<el-option label="已归档" :value="3" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="filters.keyWord"
|
||||
placeholder="搜索项目名称"
|
||||
clearable
|
||||
style="width: 200px; margin-left: 10px"
|
||||
@clear="loadData"
|
||||
@keyup.enter="loadData"
|
||||
/>
|
||||
<el-button style="margin-left: 10px" @click="loadData">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<div class="project-list__cards">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="project-card"
|
||||
@click="goDetail(item)"
|
||||
>
|
||||
<div class="project-card__header">
|
||||
<span
|
||||
class="project-card__color"
|
||||
:style="{ backgroundColor: item.color || '#409EFF' }"
|
||||
/>
|
||||
<span class="project-card__name">{{ item.name }}</span>
|
||||
<el-tag :type="statusTagType(item.status)" size="small">
|
||||
{{ statusLabel(item.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="project-card__desc">{{ item.description || '暂无描述' }}</div>
|
||||
<el-progress :percentage="item.progress" :stroke-width="6" />
|
||||
<div class="project-card__footer">
|
||||
<span>{{ item.ownerName || '未分配' }}</span>
|
||||
<span v-if="item.startDate">
|
||||
{{ item.startDate }} ~ {{ item.endDate || '未定' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="project-card__actions" @click.stop>
|
||||
<el-button link type="primary" size="small" @click="openEdit(item)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(item)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-list__pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page.currentPage"
|
||||
v-model:page-size="page.pageSize"
|
||||
:total="page.total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="项目名称" required>
|
||||
<el-input v-model="form.name" placeholder="请输入项目名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="未开始" :value="0" />
|
||||
<el-option label="进行中" :value="1" />
|
||||
<el-option label="已完成" :value="2" />
|
||||
<el-option label="已归档" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="form.ownerName" placeholder="负责人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="日期范围">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="主题色">
|
||||
<el-color-picker v-model="form.color" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useCool } from '/@/cool';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const { service } = useCool();
|
||||
const router = useRouter();
|
||||
|
||||
const list = ref<any[]>([]);
|
||||
const filters = reactive({ status: undefined as number | undefined, keyWord: '' });
|
||||
const page = reactive({ currentPage: 1, pageSize: 12, total: 0 });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新建项目');
|
||||
const saving = ref(false);
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
status: 0,
|
||||
ownerName: '',
|
||||
color: '#409EFF',
|
||||
startDate: null as string | null,
|
||||
endDate: null as string | null,
|
||||
});
|
||||
const dateRange = ref<string[]>([]);
|
||||
|
||||
type TagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
|
||||
|
||||
const statusMap: Record<number, string> = { 0: '未开始', 1: '进行中', 2: '已完成', 3: '已归档' };
|
||||
const statusTagMap: Record<number, TagType> = { 0: 'info', 1: 'primary', 2: 'success', 3: 'warning' };
|
||||
|
||||
function statusLabel(s: number) { return statusMap[s] || '未知'; }
|
||||
function statusTagType(s: number) { return statusTagMap[s] || 'info'; }
|
||||
|
||||
async function loadData() {
|
||||
const res = await service.project.info.page({
|
||||
page: page.currentPage,
|
||||
size: page.pageSize,
|
||||
status: filters.status,
|
||||
keyWord: filters.keyWord,
|
||||
});
|
||||
list.value = res.list || [];
|
||||
page.total = res.pagination?.total || 0;
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
dialogTitle.value = '新建项目';
|
||||
Object.assign(form, { id: undefined, name: '', description: '', status: 0, ownerName: '', color: '#409EFF', startDate: null, endDate: null });
|
||||
dateRange.value = [];
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
dialogTitle.value = '编辑项目';
|
||||
Object.assign(form, item);
|
||||
dateRange.value = item.startDate ? [item.startDate, item.endDate] : [];
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name) { ElMessage.warning('请输入项目名称'); return; }
|
||||
saving.value = true;
|
||||
form.startDate = dateRange.value?.[0] || null;
|
||||
form.endDate = dateRange.value?.[1] || null;
|
||||
try {
|
||||
if (form.id) {
|
||||
await service.project.info.update(form);
|
||||
} else {
|
||||
await service.project.info.add(form);
|
||||
}
|
||||
ElMessage.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
await loadData();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: any) {
|
||||
await ElMessageBox.confirm(`确定删除项目「${item.name}」?`, '提示', { type: 'warning' });
|
||||
await service.project.info.delete({ ids: [item.id] });
|
||||
ElMessage.success('删除成功');
|
||||
await loadData();
|
||||
}
|
||||
|
||||
function goDetail(item: any) {
|
||||
router.push({ path: '/project/detail', query: { id: item.id } });
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-list {
|
||||
padding: 20px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
&__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 16px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user