初始化提交,现在这个项目的基础已经完成,等待后续的业务实现

This commit is contained in:
ymj 2026-05-21 14:24:06 +08:00
parent aaa88b2e63
commit 465945db55
46 changed files with 5403 additions and 74 deletions

View File

@ -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
-- ============================================================

View File

@ -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`
);

View File

@ -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),
];

View File

@ -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,
@ -1239,24 +1239,110 @@
]
},
{
"name": "本体建模",
"name": "项目管理",
"router": null,
"perms": null,
"type": 0,
"icon": "icon-tree",
"orderNum": 10,
"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": "/ontology/model-tree",
"name": "环境总览",
"router": "/geo/dashboard",
"perms": null,
"type": 1,
"icon": "icon-tree",
"icon": "icon-count",
"orderNum": 0,
"viewPath": "modules/ontology/views/model-tree.vue",
"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": []
@ -1264,3 +1350,30 @@
]
}
]
},
{
"name": "本体建模",
"router": null,
"perms": null,
"type": 0,
"icon": "icon-component",
"orderNum": 10,
"viewPath": null,
"keepAlive": true,
"isShow": false,
"childMenus": [
{
"name": "模型树管理",
"router": "/ontology/model-tree",
"perms": null,
"type": 1,
"icon": "icon-component",
"orderNum": 0,
"viewPath": "modules/ontology/views/model-tree.vue",
"keepAlive": true,
"isShow": false,
"childMenus": []
}
]
}
]

View 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;
};

View 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();
}
}

View File

@ -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);
}
}

View 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: '浏览器指纹 seedfingerprint-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;
}

View 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: '所属 Providerlocal / 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;
}

View 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[]>;
}

View 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);
}
}
}

View 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 占位');
}
}

View 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 = IP1: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 带时间戳后缀,避免复用同名旧 profilefingerprint-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;
});
}
/** 生成随机 fingerprintSeed1-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+cookieIP 突然变了"
*
*/
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 });
});
}
}

View 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');
}
}

View 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);
// 把测试结果写回 entityexitIp + 状态 + 延迟)
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}`);
}
}
}
}

View File

@ -0,0 +1,19 @@
import { ModuleConfig } from '@cool-midway/core';
/**
*
*/
export default () => {
return {
// 模块名称
name: '项目管理',
// 模块描述
description: '项目进度管理,支持甘特图、日历、看板等视图',
// 中间件
middlewares: [],
// 全局中间件
globalMiddlewares: [],
// 模块加载顺序
order: 0,
} as ModuleConfig;
};

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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 {}

View 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;
}

View 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;
}

View 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;
}

View File

@ -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;
}

View 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;
}

View 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);
}
}
}
}

View 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) });
}
}
}

View 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);
}
}
}

View 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 });
}
}
}

View 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 });
}
}

View 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'),
},
],
};
};

View 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>

View 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>

View 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>

View 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')
}
]
};
};

View 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,
};
});

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>

View 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);
}
}
// 12
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>

View File

@ -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>

View File

@ -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>

View 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>

View 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>