提交 e3ace44f authored 作者: 陈泽健's avatar 陈泽健

feat(service-manage): 实现 5.44 代理转发 - 授权/目标/文档管理

- 新增 five44_client.py 代理客户端,完整移植 5.44 认证签名算法
- 重构 service_manage.py 业务层,接入 5.44 代理转发(含自动重登)
- 更新路由层,授权/项目/文档操作全部支持代理模式
- 未配置目标时自动降级本地占位,保持向后兼容
- 授权页面 JS 完整实现(下载/上传/保存/回填)
- 基础模板新增 Toast 通知系统
- 整理 8 份 PRD 文档到 Docs/服务管理/ 分类目录
- 更新 HANDOFF 交接文档记录当前进度
- 238 单元测试全部通过
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 6a630735
# HANDOFF 服务管理模块交接文档
> 文档生成时间:2026-08-13
> 分支:troubleshoot-ai-assistant
> 状态:后端代理转发开发完成(Vue SPA 前端 + Flask 后端 + 5.44 代理客户端)
---
## 一、任务概述
开发"服务管理"模块(service-manage),提供服务授权管理、服务升级管理、服务信息查看等功能。
**关键里程碑**
1. 阶段一(2026-07-22):静态页面 + 基础路由 ✅
2. 阶段二(2026-08-13):后端业务层 + 5.44 代理转发 ✅
3. 阶段三(待部署):部署到 5.60 生产环境
---
## 二、已完成项
### 2.1 PRD 文档
| 文档 | 路径 | 状态 |
|------|------|------|
| 需求文档(初始) | `Docs/服务管理/需求文档/PRD_需求文档_服务管理模块.md` | ✅ 完成 |
| 需求文档(功能升级) | `Docs/服务管理/需求文档/PRD_需求文档_服务管理功能升级.md` | ✅ 完成 |
| 需求文档(平台化改造) | `Docs/服务管理/需求文档/PRD_需求文档_平台化改造与模块切换.md` | ✅ 完成 |
| 需求文档(返回首页改造) | `Docs/服务管理/需求文档/PRD_需求文档_返回首页改造与新增模块.md` | ✅ 完成 |
| 计划执行(初始) | `Docs/服务管理/计划执行/PRD_计划执行_服务管理模块.md` | ✅ 完成 |
| 计划执行(功能升级) | `Docs/服务管理/计划执行/PRD_计划执行_服务管理功能升级.md` | ✅ 完成 |
| 计划执行(平台化改造) | `Docs/服务管理/计划执行/PRD_计划执行_平台化改造与模块切换.md` | ✅ 完成 |
| 计划执行(返回首页改造) | `Docs/服务管理/计划执行/PRD_计划执行_返回首页改造与新增模块.md` | ✅ 完成 |
| 交接文档 | `Docs/服务管理/HANDOFF_服务管理.md` | ✅ 当前 |
### 2.2 后端代码
| 文件 | 行数 | 说明 |
|------|------|------|
| `skill/code/web/services/service_manage.py` | 575 | 业务层:目标 CRUD、项目信息、授权文件操作、5.44 代理转发 |
| `skill/code/web/services/five44_client.py` | 401 | 5.44 统一管理平台代理客户端(认证 + 签名 + 6 个业务接口) |
| `skill/code/web/routes/service_manage.py` | 327 | 路由层:API `/api/service-manage/*`,管理员权限校验 |
### 2.3 前端代码(Vue SPA)
| 文件 | 说明 |
|------|------|
| `frontend/src/views/service-manage/Authorization.vue` | 服务授权页(4 步骤:下载激活、上传授权、部署视图、自检文档) |
| `frontend/src/views/service-manage/Upgrade.vue` | 服务升级页(Tab 切换:服务重启 + 服务回滚) |
| `frontend/src/views/service-manage/Info.vue` | 服务信息页(CPU/内存/硬盘监控卡片) |
| `frontend/src/views/service-manage/Targets.vue` | 目标配置页(管理对接的 5.44 服务器) |
| `frontend/src/views/service-manage/Logs.vue` | 操作日志页 |
| `frontend/src/api/service-manage/index.ts` | API 接口层 |
| `frontend/src/types/service-manage.ts` | TypeScript 类型定义 |
### 2.4 后端 API 清单
| 路由 | 方法 | 功能 | 代理状态 |
|------|------|------|----------|
| `/api/service-manage/targets` | GET | 目标列表 | 本地 |
| `/api/service-manage/targets` | POST | 新增目标 | 本地 |
| `/api/service-manage/targets/<id>` | PUT | 更新目标 | 本地 |
| `/api/service-manage/targets/<id>` | DELETE | 删除目标 | 本地 |
| `/api/service-manage/targets/test` | POST | 测试连接 | 本地 |
| `/api/service-manage/license/download` | GET | 下载激活文件 | ✅ 代理 5.44 |
| `/api/service-manage/license/upload` | POST | 上传授权文件 | ✅ 代理 5.44 |
| `/api/service-manage/license/task-status` | GET | 查询授权任务状态 | ✅ 代理 5.44 |
| `/api/service-manage/project` | GET/POST | 项目信息读写 | ✅ 代理 5.44(保存) |
| `/api/service-manage/deploy-view/download` | GET | 下载部署视图 | ✅ 代理 5.44 |
| `/api/service-manage/deploy-view/upload` | POST | 上传部署视图 | ✅ 代理 5.44 |
| `/api/service-manage/self-check/download` | GET | 下载自检文档 | ✅ 代理 5.44 |
| `/api/service-manage/self-check/upload` | POST | 上传自检文档 | ✅ 代理 5.44 |
### 2.5 目标配置 CRUD
`service_manage.py` 实现了完整的目标服务器 CRUD(与 `service_monitor` 的目标数据完全独立):
| 功能 | 说明 |
|------|------|
| 新增目标 | name / host / port / protocol / auth_type / username / password(Fernet 加密存储) |
| 列表 | 脱敏输出(密码→has_password 布尔) |
| 更新 | 支持部分字段更新 |
| 删除 | 按 ID 删除 |
| 测试连接 | HTTP(S) GET 根路径,超时 10s |
### 2.6 单元测试
```
238 passed in 8.65s
```
- 全部 238 个测试通过(含核心模块 106 用例 + 服务监测模块 132 用例)
- 服务管理模块暂未单独编写测试(功能依赖 5.44 真实服务器)
---
## 三、架构设计
### 3.1 分层架构
```
路由层(routes/service_manage.py)
→ 业务层(services/service_manage.py)
→ 代理客户端(services/five44_client.py)→ HTTP → 5.44 目标服务器
→ 加密工具(service_monitor/utils/crypto.py)→ Fernet 加密
→ 本地存储(JSON 文件)
```
### 3.2 5.44 代理客户端(five44_client.py)
**认证机制**(从 5.44 的 `http_client.py` 逆向移植):
| 层 | 算法 | 说明 |
|----|------|------|
| 密码加密 | SHA256 摘要 | 登录前 `hashlib.sha256(password).hexdigest()` |
| 请求签名 | SHA256 + AES-CBC(PKCS7) | sign_str = timestamp + JSON(body) + random → SHA256 → AES-CBC |
| AES 密钥派生 | SHA256(Bearer token) | 有 token 时:密钥源 = SHA256("Bearer {token}") |
| AES 密钥派生 | SHA256(60位随机串) | 无 token 时:密钥源 = SHA256(random_code),随 RandomCode 头发送 |
| AES 密钥/IV | 取密钥源字符串子串 | 密钥 = source[16:32],IV = source[0:8] + source[-8:](UTF-8 字节,非 hex) |
**业务接口(6 个)**
| 方法 | 5.44 端点 | 说明 |
|------|-----------|------|
| `download_activation()` | GET `/exapi/system/downLincenceFile?category=java_python` | 下载激活文件 |
| `upload_license()` | POST `/backstage/exapi/system/uploadLincenceFile` | 上传授权文件(multipart) |
| `check_task_status()` | GET `/backstage/exapi/system/licenceTaskStatus?taskId=` | 查询授权任务状态 |
| `save_project()` | POST `/exapi/system/setOtherInfo` | 保存项目信息 |
| `download_deploy_view()` | GET `/exapi/system/exportDeployView` | 导出部署视图 |
| `download_self_check()` | GET `/exapi/system/exportServiceDetect` | 导出自检文档 |
| `upload_deploy_view()` | POST `/exapi/uploader/uploadDeployView` | 上传部署视图 |
| `upload_self_check()` | POST `/exapi/uploader/uploadServiceDetect` | 上传自检文档 |
**自动重试**:遇到 401/403 自动清除 token、重新登录、重试请求一次。
### 3.3 降级策略
未配置目标服务器时,所有授权操作降级为本地占位模式:
| 操作 | 本地降级行为 |
|------|-------------|
| 下载激活文件 | 生成 `licences.zip` 占位文件 |
| 下载部署视图 | 生成 `deploymentView{ts}.xlsx` 占位文件 |
| 下载自检文档 | 生成 `checkList{ts}.xlsx` 占位文件 |
| 上传授权文件 | 本地暂存到 `uploads/` 目录,返回 `taskId: "local-{filename}"` |
| 上传部署视图/自检文档 | 本地暂存到 `uploads/` 子目录 |
| 查询任务状态 | 直接返回 `status: "completed"` |
### 3.4 数据存储
| 文件 | 路径 | 说明 |
|------|------|------|
| `targets.json` | `service_manage_data/targets.json` | 目标服务器配置(密码 Fernet 加密) |
| `projects.json` | `service_manage_data/projects.json` | 项目信息(密码加密存储,读取脱敏) |
| `uploads/` | `service_manage_data/uploads/` | 上传文件暂存目录 |
路径常量通过 `utils/paths.py``SERVICE_MANAGE_DATA_DIR` / `MANAGE_TARGETS_FILE` / `MANAGE_PROJECTS_FILE` / `MANAGE_UPLOADS_DIR` 定义。
---
## 四、配置说明
### 4.1 5.44 目标服务器凭据
在服务管理页面 → 目标配置中添加:
| 字段 | 值 |
|------|-----|
| 名称 | 5.44 统一管理平台(或自定义) |
| 主机 | 192.168.5.44 |
| 端口 | 443 |
| 协议 | https |
| 认证方式 | password |
| 用户名 | `admin@xty` |
| 密码 | 见 `server-credentials` 记忆 |
### 4.2 依赖
| 库 | 用途 | 是否已安装 |
|----|------|-----------|
| `requests` | HTTP 请求 | ✅ 已安装 |
| `pycryptodome` | AES-CBC 加密签名 | ✅ 已安装 |
| `cryptography` | Fernet 密码加密(复用 `service_monitor`) | ✅ 已安装 |
---
## 五、已知问题
### 5.1 代理转发依赖目标配置
- 所有授权操作的代理转发要求先配置目标服务器(targets 中至少有一条有效记录)
- 未配置目标时自动降级本地占位,不影响页面功能验证
### 5.2 下载激活文件端点
`downLincenceFile` 在 PRD 中标注为 POST,但 5.44 实际实现使用 GET——已按 GET 实现。
### 5.3 上传文件大小限制
- 授权文件上传到 5.44 经 multipart 转发,需注意 5.44 侧的上传大小限制
- 本地暂存模式无大小限制
---
## 六、待办项
### 6.1 部署到 5.60 生产环境
1. 更新 `deploy/upload_to_server.py` 的上传清单:
- 新增 `skill/code/web/services/five44_client.py`
- 确认 `skill/code/web/services/service_manage.py` 已在清单
- 确认 `skill/code/web/routes/service_manage.py` 已在清单
- 确认 `skill/code/web/utils/paths.py` 中的服务管理路径常量已同步
- 确认前端 Vue 构建后的 dist 文件已包含
2. 部署命令:
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```
3. 部署后验证:
- 在浏览器中访问 `/service-manage/authorization` 确认页面正常
- 配置目标服务器(5.44)
- 测试授权文件下载/上传流程,使用 `F12` 网络面板确认请求转发到 5.44
### 6.2 编写单元测试
-`service_manage.py` 的业务函数编写测试(目标 CRUD、项目信息读写)
-`five44_client.py` 编写 mock 测试(签名算法、登录流程、业务接口)
- 目标:核心函数覆盖率 > 80%
### 6.3 前端页面完善
- 服务升级页(Upgrade.vue)的后端 API 尚未实现
- 服务信息页(Info.vue)的实时监控数据获取尚未实现
- 操作日志页(Logs.vue)的后端 API 尚未实现
### 6.4 服务升级与回滚
- 服务重启/回滚的后端 API 需要开发
- 下载配置、备份数据库、上传更新服务的功能需要实现
---
## 七、踩坑警示
### 7.1 5.44 签名算法
- AES 密钥和 IV 按 UTF-8 字符串字节解析(与 CryptoJS 行为一致),**不是 hex 解码**
- 无 token 时签名使用 `RandomCode` 头发送 60 位随机串,不能省略
- login 接口的 captcha 验证码固定为 `"csba"`(安全测试已知项)
### 7.2 密码加密复用
- 目标配置的密码加密复用 `service_monitor.utils.crypto` 的 Fernet 加密
- 如果 `crypto` 模块不可用,会静默降级为明文存储(仅开发环境)
- 生产环境必须确保 `crypto` 模块可用
### 7.3 数据隔离
- 服务管理模块的目标数据与 `service_monitor` 模块完全独立(两套 `targets.json`
- 服务管理数据存储在 `service_manage_data/` 目录下
### 7.4 代码分层
- 新增功能按 `routes/services/` 分层,纯工具放 `utils/`
- 路径常量从 `utils/paths.py` 取,不要在各文件重算
- 依赖方向单向:routes → services → utils,禁止反向导入
---
## 八、相关文档
- [[PRD_需求文档_服务管理功能升级]] — 5.44 代理转发需求
- [[PRD_计划执行_服务管理功能升级]] — 5.44 代理转发执行计划
- [[PRD_需求文档_服务管理模块]] — 初始模块需求
- [[PRD_计划执行_服务管理模块]] — 初始模块执行计划
- [[PRD_需求文档_平台化改造与模块切换]] — 平台化改造需求
- [[PRD_计划执行_平台化改造与模块切换]] — 平台化改造计划
- [[PRD_需求文档_返回首页改造与新增模块]] — 首页改造需求
- [[PRD_计划执行_返回首页改造与新增模块]] — 首页改造计划
\ No newline at end of file
# PRD_计划执行_平台化改造与模块切换
## 1. 项目概述
### 1.1 背景
当前站点是单功能"问题排查 AI 助手",访问 `/` 直接进入排查页面。需升级为多模块平台:新增平台首页展示模块卡片,问题排查助手降级为其中一个模块入口(`/troubleshoot`),后续可按需接入其他维护模块。
### 1.2 目标
| 目标编号 | 描述 | 优先级 |
|---------|------|--------|
| 1 | 模块注册机制 `modules.py` | 🟠 高 |
| 2 | 平台首页 `/`(模块卡片) | 🟠 高 |
| 3 | 排查助手路由迁移 `/``/troubleshoot` | 🟠 高 |
| 4 | 排查助手页加返回首页导航 | 🟡 中 |
### 1.3 开发周期
预估 5.5 小时(约 1 个工作日)。
---
## 2. 技术方案
### 2.1 模块注册机制(`utils/modules.py`,新增)
集中声明模块清单,提供 `get_modules(role=None)` 查询函数:
```python
"""modules.py — 平台模块注册
集中声明平台所有可用模块,供首页渲染和路由使用。
新增模块只需在此文件追加声明 + 注册 Blueprint,不改首页代码。
"""
from flask import session
# ============================================================
# 模块清单(声明式)
# ============================================================
MODULES = [
{
"id": "troubleshoot",
"name": "问题排查助手",
"icon": "🔍",
"description": "基于历史知识库的 AI 问题排查,357 条记录",
"url": "/troubleshoot",
"enabled": True,
"roles": [], # 空 = 所有登录用户可访问
"sort": 1,
},
{
"id": "service-monitor",
"name": "服务监控",
"icon": "📊",
"description": "服务器与服务运行状态监控、告警通知",
"url": "/service-monitor",
"enabled": False, # 预留,未上线
"roles": ["admin"],
"sort": 2,
},
# 后续模块在此追加
]
def get_modules(role=None):
"""获取可用模块列表。
参数:
role: 用户角色(None=不过滤,返回全部启用模块)
返回:
按 sort 排序的模块列表
"""
result = []
for m in MODULES:
if not m.get("enabled", True):
continue
if role is not None and m.get("roles") and role not in m["roles"]:
continue
result.append(m)
result.sort(key=lambda x: x.get("sort", 99))
return result
```
**关键约束**
- 模块声明集中在此文件,不散落各处
- `roles` 为空表示所有登录用户可访问(不限制)
- `enabled: False` 的模块不展示(预留但未上线)
### 2.2 平台首页路由(`routes/platform.py`,新增)
```python
"""platform.py — 平台首页路由"""
from flask import Blueprint, render_template, session, redirect, url_for
from decorators import page_login_required
from utils.modules import get_modules
bp = Blueprint('platform', __name__)
@bp.route('/')
def index():
"""平台首页 — 模块列表"""
user = session.get('user')
if not user:
return redirect(url_for('auth.login'))
role = user.get('role', '')
modules = get_modules(role=role)
return render_template('platform.html',
modules=modules,
user=user,
platform_name="运维辅助平台",
)
```
### 2.3 平台首页模板(`templates/platform.html`,新增)
独立模板,风格与现有 `index.html` / `login.html` 一致(渐变背景 + 卡片布局):
```html
<!-- platform.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5">
<title>{{ platform_name }}</title>
<style>
/* 复用 login/index 的设计系统变量 + 渐变背景 */
/* 模块卡片网格:桌面 3 列、平板 2 列、手机 1 列 */
/* 触控热区 ≥44px */
</style>
</head>
<body>
<!-- 顶部:平台标题 + 用户信息 + 退出 -->
<!-- 主体:模块卡片网格 -->
<!-- 每个卡片:图标 + 名称 + 描述 + "进入" 按钮 -->
<!-- 空状态:暂无可用模块 -->
</body>
</html>
```
### 2.4 排查助手路由迁移
#### 2.4.1 `routes/auth.py` 改动
`auth.py``index` 路由(`/` 重定向到 `/login` 或渲染 `index.html`)需移除或改为重定向到 `/troubleshoot`
实际上 `/` 现在由 `platform.py` 接管,`auth.py``index` 路由删除即可。
#### 2.4.2 `routes/troubleshoot.py` 改动
新增 `/troubleshoot` 页面路由(渲染 `index.html`):
```python
@bp.route('/troubleshoot')
def troubleshoot_page():
"""排查助手主页"""
return render_template('index.html')
```
#### 2.4.3 `templates/index.html` 改动
顶部用户信息栏增加"返回首页"链接:
```html
<a href="/" class="back-home">🏠 返回首页</a>
```
### 2.5 `server.py` 改动
注册 `platform` Blueprint:
```python
from routes.platform import bp as platform_bp
app.register_blueprint(platform_bp)
```
### 2.6 测试方案
| 模块 | 用例 | 覆盖点 |
|------|------|--------|
| `test_routes_auth.py` | 修改 | `/` 不再重定向到 `/login`(由 platform 接管) |
| `test_routes_troubleshoot.py` | 新增 | `GET /troubleshoot` 返回 200 |
| `test_routes_platform.py` | 新增 | `/` 未登录跳 `/login`;已登录返回模块卡片;角色过滤 |
| `test_modules.py` | 新增 | `get_modules()` 角色过滤 / enabled 过滤 / sort 排序 |
---
## 3. 实施计划
### 3.1 任务分解
| 序号 | 任务 | 预计时间 | 状态 | 依赖 |
|------|------|---------|------|------|
| 1 | `utils/modules.py` 模块注册 | 0.5h | 待开始 | — |
| 2 | `routes/platform.py` 平台首页路由 | 0.5h | 待开始 | 1 |
| 3 | `templates/platform.html` 平台首页模板 | 1.5h | 待开始 | 2 |
| 4 | 排查助手路由迁移 `/``/troubleshoot` | 0.5h | 待开始 | 2 |
| 5 | `templates/index.html` 加返回首页导航 | 0.5h | 待开始 | 4 |
| 6 | `server.py` 注册 platform Blueprint | 0.5h | 待开始 | 2 |
| 7 | 测试用例更新 + 新增 | 1h | 待开始 | 4, 5, 6 |
| 8 | 部署 + 验证 | 0.5h | 待开始 | 7 |
---
## 4. 测试验证
### 4.1 验证项
| 验收项 | 标准 | 实测 |
|--------|------|------|
| `/` 显示模块卡片 | 登录后看到"问题排查助手"卡片 | — |
| 卡片点击进入模块 | 跳转到 `/troubleshoot` | — |
| `/troubleshoot` 显示排查助手 | 原有功能全部正常 | — |
| 返回首页 | 排查助手页顶部有"返回首页"链接 | — |
| 角色过滤 | 普通用户/管理员看到各自模块 | — |
| 未登录 | `/``/login` | — |
| 移动端 | 首页卡片单列/多列自适应 | — |
| API 不变 | `/api/*` 全部正常 | — |
### 4.2 回归测试
- [ ] 145 用例全绿(路由变更需同步改测试 fixture)
- [ ] 排查助手全流程手动验证
### 4.3 端到端验证
```bash
# 1. 首页验证
curl -s http://192.168.5.60:8088/ -o /dev/null -w "%{http_code}"
# 期望:302(未登录跳 /login)
# 2. 登录后访问首页
# 期望:看到模块卡片
# 3. 排查助手验证
curl -s http://192.168.5.60:8088/api/health
# 期望:ok, 357 记录
# 4. 浏览器验证
# 登录 → 首页 → 点击"问题排查助手" → 排查页面 → 返回首页
```
---
## 5. 执行记录
### 5.1 执行日志
| 日期 | 任务 | 执行人 | 结果 | 备注 |
|------|------|--------|------|------|
| — | — | — | — | 待执行 |
### 5.2 问题记录
| 日期 | 问题 | 解决方案 | 状态 |
|------|------|---------|------|
| — | — | — | — |
---
## 6. 注意事项
1. **路由冲突**`platform.py` 注册 `/``auth.py` 原有 `/` 路由必须删除,否则 Flask 报 `AssertionError: duplicate route`
2. **Blueprint 注册顺序**`platform` Blueprint 需在 `auth` 之前注册(或确保 `/` 只定义一次)
3. **前端 API 路径不变**:排查助手 JS 中所有 `/api/...` 是绝对路径,不受主页路径迁移影响
4. **测试 fixture 更新**`conftest.py``app` fixture 创建的 test client 需适配新路由(`/` 不再是排查助手主页)
5. **部署清单同步**:新增 `modules.py` / `platform.py` / `platform.html` 需加入 `upload_to_server.py`
---
## 7. 相关文档
- [PRD_需求文档_平台化改造与模块切换](PRD_需求文档_平台化改造与模块切换.md) — 本项目需求文档
- [PRD_需求文档_P2级功能增强](PRD_需求文档_P2级功能增强.md) — P2 背景
# PRD 计划执行文档 — 服务管理模块功能升级(服务授权 + 目标配置)
> 版本:1.0 | 日期:2026-08-12 | 分支:troubleshoot-ai-assistant
---
## 执行步骤总览
| 步骤 | 内容 | 优先级 | 预估改动 |
|------|------|--------|----------|
| 1 | 后端:服务管理数据目录 + 目标配置服务 | P0 | 新建 2~3 文件 |
| 2 | 后端:服务管理 API 路由(授权 + 目标配置) | P0 | 修改 1 文件 |
| 3 | 前端:类型定义 + API 封装 | P0 | 新建 3 文件 |
| 4 | 前端:目标配置页面 | P0 | 新建 1 文件 |
| 5 | 前端:服务授权页面补全 | P0 | 修改 1 文件 |
| 6 | 前端:侧边栏菜单 + 路由 | P0 | 修改 2 文件 |
| 7 | 单元测试 + 集成测试 | P1 | 新建测试文件 |
| 8 | 部署 + 验证 | P1 | 修改部署脚本 |
---
## 步骤 1:后端数据目录 + 目标配置服务
### 1.1 新建数据目录
**文件**`skill/code/web/service_manage_data/`(运行时数据,加入 .gitignore)
```
service_manage_data/
├── targets.json # 目标配置(服务器密码 Fernet 加密)
└── projects.json # 项目信息
```
### 1.2 新建路径常量
**修改**`skill/code/web/utils/paths.py`
```python
# 服务管理数据目录
SERVICE_MANAGE_DATA_DIR = DATA_DIR / "service_manage_data"
SERVICE_MANAGE_DATA_DIR.mkdir(parents=True, exist_ok=True)
MANAGE_TARGETS_FILE = SERVICE_MANAGE_DATA_DIR / "targets.json"
MANAGE_PROJECTS_FILE = SERVICE_MANAGE_DATA_DIR / "projects.json"
```
### 1.3 新建目标配置服务
**文件**`skill/code/web/services/service_manage/service.py`(或 `services/service_manage.py`
```python
# -*- coding: utf-8 -*-
"""
service_manage 业务层 — 目标配置 + 项目信息
目标配置:管理员维护对接的目标服务器(如 5.44 统一管理平台)
凭据:密码 Fernet 加密存储(复用 service_monitor.utils.crypto)
"""
def list_targets() -> list:
"""列出全部目标(脱敏:密码→has_password 布尔)"""
def create_target(data: dict) -> dict:
"""新增目标(校验 host/port/name)"""
def update_target(target_id: str, data: dict) -> dict:
"""更新目标"""
def delete_target(target_id: str) -> bool:
"""删除目标"""
def test_connection(data: dict) -> dict:
"""测试 HTTP(S) 连通性(GET /,返回状态码)"""
def save_project(data: dict) -> dict:
"""保存项目信息到 projects.json(密码脱敏后存储)"""
def get_project() -> Optional[dict]:
"""读取项目信息(脱敏)"""
```
**关键点**
- 目标配置与「服务监测」的目标是两套独立数据(一个用于监测巡检,一个用于授权代理),互不干扰
- 复用 `service_monitor/utils/crypto.py``encrypt_password` / `decrypt_password`(同包可导入)
- 连通性测试用 `urllib.request``requests` 做 HTTP(S) GET,不引入 SSH 依赖
---
## 步骤 2:后端 API 路由
**修改**`skill/code/web/routes/service_manage.py`
### 2.1 权限辅助
```python
def _require_admin_json():
if 'user' not in session:
return jsonify({"success": False, "error": {"code": 401, "message": "未登录"}}), 401
if session.get('user', {}).get('role') != 'admin':
return jsonify({"success": False, "error": {"code": 403, "message": "权限不足"}}), 403
return None
```
### 2.2 API 路由清单
```python
# ============ 目标配置(管理员) ============
@bp.route('/api/service-manage/targets', methods=['GET'])
def manage_targets_list(): # 目标列表
@bp.route('/api/service-manage/targets', methods=['POST'])
def manage_targets_create(): # 新增目标
@bp.route('/api/service-manage/targets/<target_id>', methods=['PUT'])
def manage_targets_update(target_id): # 更新目标
@bp.route('/api/service-manage/targets/<target_id>', methods=['DELETE'])
def manage_targets_delete(target_id): # 删除目标
@bp.route('/api/service-manage/targets/test', methods=['POST'])
def manage_targets_test(): # 测试连接
# ============ 服务授权(管理员) ============
@bp.route('/api/service-manage/license/download', methods=['GET'])
def license_download(): # 下载激活文件(占位:返回本地模板或代理)
@bp.route('/api/service-manage/license/upload', methods=['POST'])
def license_upload(): # 上传授权文件(暂存 + 轮询状态)
@bp.route('/api/service-manage/license/task-status', methods=['GET'])
def license_task_status(): # 查询授权任务状态
@bp.route('/api/service-manage/project', methods=['GET', 'POST'])
def manage_project(): # 保存/读取项目信息
@bp.route('/api/service-manage/deploy-view/download', methods=['GET'])
def deploy_view_download(): # 下载部署视图(占位)
@bp.route('/api/service-manage/deploy-view/upload', methods=['POST'])
def deploy_view_upload(): # 上传部署视图(暂存)
@bp.route('/api/service-manage/self-check/download', methods=['GET'])
def self_check_download(): # 下载自检文档(占位)
@bp.route('/api/service-manage/self-check/upload', methods=['POST'])
def self_check_upload(): # 上传自检文档(暂存)
```
### 2.3 授权代理模式说明
**阶段一(本迭代)**:服务授权 API 先实现为「本地占位 + 文件暂存」:
- 下载激活/部署视图/自检文档:生成占位文件(或返回 501 提示待对接)
- 上传授权/部署视图/自检文档:暂存到 `service_manage_data/uploads/`,返回成功
**阶段二(后续迭代)**:对接目标服务器代理转发:
- 基于目标配置(targets.json)选择目标服务器
-`requests` 转发到 5.44 的 `/exapi/system/*`,携带 HMAC 签名头
- 任务状态轮询 `licenceTaskStatus`
---
## 步骤 3:前端类型定义 + API 封装
### 3.1 新建类型定义
**文件**`frontend/src/types/service-manage.ts`
```typescript
// 目标配置
export interface ManageTarget { ... }
export interface CreateManageTargetRequest { ... }
export interface TestManageTargetRequest { ... }
// 服务授权
export interface LicenseTaskStatus { ... }
export interface ProjectInfo { ... }
```
### 3.2 新建 API 封装
**文件**`frontend/src/api/service-manage/index.ts`
```typescript
import http from '@/utils/http'
import type { ManageTarget, ProjectInfo, LicenseTaskStatus } from '@/types/service-manage'
/** 目标列表 */
export async function getTargets() { ... }
/** 新增目标 */
export async function createTarget(data) { ... }
/** 更新目标 */
export async function updateTarget(id, data) { ... }
/** 删除目标 */
export async function deleteTarget(id) { ... }
/** 测试连接 */
export async function testConnection(data) { ... }
/** 下载激活文件 */
export async function downloadLicense() { ... }
/** 上传授权文件 */
export async function uploadLicense(file) { ... }
/** 查询授权任务状态 */
export async function getLicenseTaskStatus(taskId) { ... }
/** 保存项目信息 */
export async function saveProject(data) { ... }
/** 下载部署视图 */
export async function downloadDeployView() { ... }
/** 下载自检文档 */
export async function downloadSelfCheck() { ... }
```
**文件下载封装**`frontend/src/utils/download.ts` 或内联):
```typescript
export async function downloadBlob(url: string, filename: string) {
const res = await http.get(url, { responseType: 'blob' })
const blob = new Blob([res.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
```
---
## 步骤 4:前端目标配置页面
**文件**`frontend/src/views/service-manage/Targets.vue`(新建)
### 4.1 页面结构
```
┌──────────────────────────────────────────────────────────────┐
│ 🎯 目标配置 [+ 新增目标] │
├──────────────────────────────────────────────────────────────┤
│ el-table │
│ ┌──────────┬─────────────┬───────┬──────────┬─────────────┐ │
│ │ 名称 │ 地址 │ 端口 │ 状态 │ 操作 │ │
│ │ 5.44平台 │ 192.168.5.44│ 443 │ ✅ 在线 │ ✏️ 🗑️ 🔗 │ │
│ └──────────┴─────────────┴───────┴──────────┴─────────────┘ │
└──────────────────────────────────────────────────────────────┘
新增/编辑对话框(el-dialog + el-form)
名称 / 地址 / 端口 / 协议 / 认证方式 / 用户名 / 密码
[测试连接] [保存] [取消]
```
### 4.2 关键交互
- 列表加载:`onMounted``getTargets()`
- 新增:打开对话框 → 填表 → 测试连接(可选)→ 保存
- 编辑:行内 `✏️` → 回填表单 → 保存
- 删除:行内 `🗑️``ElMessageBox.confirm` → 确认后删除
- 测试连接:表单内「测试连接」按钮 → `testConnection()` → 成功/失败提示
---
## 步骤 5:前端服务授权页面补全
**修改**`frontend/src/views/service-manage/Authorization.vue`
### 5.1 Step 1 — 申请服务授权
```typescript
// 下载激活文件
async function handleDownloadActivation() {
const loading = ElLoading.service({ text: '正在生成激活文件...' })
try {
await downloadLicense() // Blob 下载
ElMessage.success('激活文件下载成功')
} catch (e) {
ElMessage.error('下载失败:' + e.message)
} finally {
loading.close()
}
}
// 上传授权文件
async function handleAuthUpload(file: any) {
const loading = ElLoading.service({ text: '正在上传授权文件...' })
try {
const res = await uploadLicense(file.raw)
if (res.success) {
ElMessage.success('上传成功,正在处理授权...')
pollLicenseTask(res.taskId) // 轮询状态
}
} finally {
loading.close()
}
return false
}
// 轮询授权任务状态(5 分钟超时,2s 间隔)
async function pollLicenseTask(taskId: string) {
const deadline = Date.now() + 5 * 60 * 1000
while (Date.now() < deadline) {
const res = await getLicenseTaskStatus(taskId)
if (res.status === 'completed') { ElMessage.success('授权完成'); return }
if (res.status === 'failed') { ElMessage.error('授权失败:' + res.message); return }
await sleep(2000)
}
ElMessage.warning('授权处理超时,请稍后刷新查看')
}
```
### 5.2 Step 2 — 填写项目信息
```typescript
// 保存项目信息
async function handleSaveProject() {
// 表单校验(必填项)
const loading = ElLoading.service({ text: '正在保存...' })
try {
const res = await saveProject({
salesPeople: projectForm.projectSale,
deployer: projectForm.deployer,
orderTime: formatDateTime(projectForm.orderTime),
serverPassword: projectForm.serverPassword,
projectOverview: projectForm.projectOverview,
})
if (res.success) ElMessage.success('项目信息保存成功')
} finally {
loading.close()
}
}
```
### 5.3 Step 3/4 — 下载部署视图/自检文档
```typescript
async function handleDownloadDeployView() {
await downloadDeployView() // Blob 下载
ElMessage.success('部署视图下载成功')
}
async function handleDownloadSelfCheck() {
await downloadSelfCheck() // Blob 下载
ElMessage.success('自检文档下载成功')
}
```
### 5.4 上传区域调整
- 授权文件上传:`accept=".zip,.lic,.key,.dat"``action` 改为自定义上传(`http-request``on-change` 手动调 API)
- 部署视图上传:`accept=".xlsx,.xls,.pdf"`
- 自检文档上传:`accept=".docx,.doc,.pdf"`
- 上传后显示文件列表 + 状态标签
---
## 步骤 6:前端侧边栏菜单 + 路由
### 6.1 侧边栏菜单
**修改**`frontend/src/layouts/ManageLayout.vue`
```typescript
const manageMenus = computed(() => [
{ key: 'authorization', icon: '🔑', label: '服务授权', path: '/service-manage/authorization' },
{ key: 'targets', icon: '🎯', label: '目标配置', path: '/service-manage/targets' }, // 新增
{ key: 'upgrade', icon: '🚀', label: '服务升级', path: '/service-manage/upgrade' },
{ key: 'info', icon: 'ℹ️', label: '服务信息', path: '/service-manage/info' },
{ key: 'logs', icon: '📝', label: '操作日志', path: '/service-manage/logs' },
])
```
### 6.2 路由
**修改**`frontend/src/router/index.ts`
```typescript
{
path: 'targets',
name: 'ManageTargets',
component: () => import('@/views/service-manage/Targets.vue'),
meta: { activeMenu: 'targets', title: '目标配置' }
},
```
---
## 步骤 7:单元测试
**文件**`skill/code/tests/test_service_manage.py`(新建)
```python
# 目标配置 CRUD 测试
def test_create_target():
"""新增目标 → 列表可见、密码脱敏"""
def test_create_target_invalid():
"""非法主机/端口/空名称 → ValueError"""
def test_update_target():
"""更新名称/端口 → 生效"""
def test_delete_target():
"""删除 → 列表移除"""
def test_password_encrypted():
"""密码 Fernet 加密存储,列表返回 has_password 布尔"""
# 项目信息测试
def test_save_project():
"""保存项目信息 → 可读取,密码脱敏"""
```
---
## 步骤 8:部署 + 验证
1. **前端构建**`cd frontend && npm run build`
2. **上传清单同步**:修改 `deploy/upload_to_server.py`
- `FILES_TO_UPLOAD` 增加:`routes/service_manage.py``services/service_manage.py`(或包目录)
- `DIRS_TO_UPLOAD` 增加:`web/service_manage_data/`(若需初始化)
3. **部署**`! cd deploy && SSH_PASSWORD='***' python upload_to_server.py`
4. **权威验证**`cd deploy && python verify_deployment.py`
5. **功能验证**
- 登录 → 服务管理 → 目标配置 → 新增目标 → 测试连接 → 保存
- 服务授权 → 4 步流程:下载/上传/保存
---
## 风险与注意事项
| 风险 | 应对 |
|------|------|
| 5.44 授权 API 需要 HMAC 签名,本地无法直接调用 | 本迭代先本地占位实现,代理转发放下一迭代 |
| 上传文件大小限制 2048M | 后端限制文件大小,前端提示 |
| 授权任务处理耗时(轮询 80+ 次) | 前端 2s 间隔轮询 + 5 分钟超时 |
| 目标配置与监测目标数据隔离 | 两套独立 JSON 文件,命名区分 |
| 服务器密码明文传输 | 前后端均用 HTTPS,存储 Fernet 加密 |
---
## 验收标准
1. ✅ 服务管理左侧栏显示「目标配置」菜单,可正常访问
2. ✅ 目标配置支持新增/编辑/删除/测试连接
3. ✅ 服务授权 4 步流程按钮可点击,调用后端 API 有反馈
4. ✅ 项目信息保存后写入后端,密码加密存储
5. ✅ 文件下载返回真实文件(或明确的占位提示)
6. ✅ 单元测试通过(新增用例全绿)
7. ✅ 部署到 5.60 后 verify_deployment.py 通过
\ No newline at end of file
# PRD 计划执行:服务管理模块
## 项目概述
### 背景
基于 `PRD_需求文档_服务管理模块.md`,实现服务管理模块的静态页面开发。参考 `https://192.168.5.44` 统一管理平台的设计风格,与现有 service-monitor 模块完全隔离。
### 目标
| 目标 | 说明 |
|------|------|
| 模块隔离 | 新模块 service-manage 与 service-monitor 物理隔离,互不影响 |
| 静态页面 | 实现三个页面:服务授权、服务升级、服务信息 |
| 权限控制 | 仅管理员可访问 |
| 响应式设计 | 适配桌面/平板/移动端 |
### 开发周期
- 预计总工时: 7h
- 开发周期: 1-2 天
---
## 技术方案
### 目录结构
```
skill/code/web/
├── service_manage/ # 新增目录
│ ├── __init__.py # Blueprint 暴露
│ └── routes.py # 路由定义
└── templates/service_manage/ # 新增目录
├── base.html # 基础模板(侧边栏 + 顶栏 + 内容区)
├── authorization.html # 服务授权页
├── upgrade.html # 服务升级页
└── info.html # 服务信息页
```
### Blueprint 定义
**文件**: `skill/code/web/service_manage/__init__.py`
```python
"""
service_manage — 服务管理模块(自包含子包)
对外仅暴露 Blueprint:
from service_manage import bp
app.register_blueprint(bp)
"""
from .routes import bp
__all__ = ["bp"]
```
**文件**: `skill/code/web/service_manage/routes.py`
```python
from flask import Blueprint, render_template
from web.decorators import page_login_required
bp = Blueprint('service-manage', __name__, url_prefix='/service-manage')
@bp.route('/')
@bp.route('/authorization')
@page_login_required
def authorization():
"""服务授权页面"""
return render_template('service_manage/authorization.html')
@bp.route('/upgrade')
@page_login_required
def upgrade():
"""服务升级页面"""
return render_template('service_manage/upgrade.html')
@bp.route('/info')
@page_login_required
def info():
"""服务信息页面"""
return render_template('service_manage/info.html')
```
### 模板继承关系
```
templates/base.html (主应用基础模板)
└── templates/service_manage/base.html (模块基础模板)
├── authorization.html
├── upgrade.html
└── info.html
```
### 样式方案
复用主应用 CSS(`/static/css/style.css`),并在 `service_manage/base.html` 中定义模块私有样式:
- 步骤卡片样式
- Tab 切换样式
- 服务列表表格样式
- 信息卡片样式
---
## 实施计划
### 阶段 1: 模块基础结构(1h)
| 序号 | 任务 | 预计时间 | 依赖 |
|------|------|----------|------|
| 1.1 | 创建 `skill/code/web/service_manage/` 目录 | 5min | - |
| 1.2 | 编写 `__init__.py`(Blueprint 暴露) | 10min | 1.1 |
| 1.3 | 编写 `routes.py`(路由定义) | 20min | 1.2 |
| 1.4 | 在 `server.py` 注册 Blueprint | 10min | 1.3 |
| 1.5 | 创建 `templates/service_manage/` 目录 | 5min | - |
| 1.6 | 编写 `base.html` 基础模板 | 10min | 1.5 |
### 阶段 2: 服务授权页面(2h)
| 序号 | 任务 | 预计时间 | 依赖 |
|------|------|----------|------|
| 2.1 | 编写 `authorization.html` 页面框架 | 30min | 1.6 |
| 2.2 | 实现 4 个步骤卡片布局 | 45min | 2.1 |
| 2.3 | 实现步骤 2 表单(5 个字段) | 30min | 2.2 |
| 2.4 | 样式调整和细节优化 | 15min | 2.3 |
### 阶段 3: 服务升级页面(2h)
| 序号 | 任务 | 预计时间 | 依赖 |
|------|------|----------|------|
| 3.1 | 编写 `upgrade.html` 页面框架 | 20min | 1.6 |
| 3.2 | 实现 Tab 切换功能(服务重启/服务回滚) | 30min | 3.1 |
| 3.3 | 实现顶部操作按钮栏 | 20min | 3.2 |
| 3.4 | 实现服务列表表格 | 40min | 3.3 |
| 3.5 | 样式调整和细节优化 | 10min | 3.4 |
### 阶段 4: 服务信息页面(1h)
| 序号 | 任务 | 预计时间 | 依赖 |
|------|------|----------|------|
| 4.1 | 编写 `info.html` 页面框架 | 15min | 1.6 |
| 4.2 | 实现监控类型下拉选择 | 15min | 4.1 |
| 4.3 | 实现 3 个信息卡片(CPU/内存/硬盘) | 20min | 4.2 |
| 4.4 | 样式调整和细节优化 | 10min | 4.3 |
### 阶段 5: 集成与验收(1h)
| 序号 | 任务 | 预计时间 | 依赖 |
|------|------|----------|------|
| 5.1 | 更新 `utils/modules.py` 模块元数据 | 10min | 1.4 |
| 5.2 | 验证三个页面路由正常 | 10min | 5.1 |
| 5.3 | 验证与 service-monitor 模块隔离 | 10min | 5.2 |
| 5.4 | 响应式布局测试和调整 | 20min | 5.3 |
| 5.5 | 管理员权限验证 | 10min | 5.4 |
---
## 测试验证
### 功能测试项
| 序号 | 测试项 | 预期结果 |
|------|--------|----------|
| 1 | 访问 `/service-manage/authorization` | 正确显示服务授权页面 |
| 2 | 访问 `/service-manage/upgrade` | 正确显示服务升级页面 |
| 3 | 访问 `/service-manage/info` | 正确显示服务信息页面 |
| 4 | 点击 Tab 切换(服务重启/服务回滚) | Tab 正确切换 |
| 5 | 服务升级页表格复选框全选 | 所有行被选中 |
| 6 | 响应式布局(移动端) | 页面自适应 |
### 隔离验证
| 序号 | 测试项 | 预期结果 |
|------|--------|----------|
| 1 | service-manage 代码不导入 service_monitor | 无交叉导入 |
| 2 | service-monitor 代码不导入 service_manage | 无交叉导入 |
| 3 | 两个模块数据目录独立 | 无共享数据 |
### 权限验证
| 序号 | 测试项 | 预期结果 |
|------|--------|----------|
| 1 | 未登录访问 `/service-manage/*` | 重定向到登录页 |
| 2 | 普通用户访问 `/service-manage/*` | 无权限提示(待定) |
| 3 | 管理员访问 `/service-manage/*` | 正常访问 |
---
## 执行记录
### 2026-07-22
| 时间 | 完成项 | 备注 |
|------|--------|------|
| - | 需求文档编写 | 完成 |
| - | 计划执行文档编写 | 完成 |
---
## 注意事项
### 隔离原则
- **代码隔离**: `service_manage/` 目录独立,不与 `service_monitor/` 共享代码
- **模板隔离**: `templates/service_manage/` 目录独立
- **数据隔离**: 暂无数据存储需求
### 样式复用
- 复用主应用 CSS,避免重复定义
- 遵循现有设计规范(颜色、字体、间距)
### 响应式设计
- 参考现有 `service_monitor` 的响应式实现
- 使用 CSS Grid/Flexbox 布局
---
## 相关文档
- [[PRD_需求文档_服务管理模块]]
- [[PRD_需求文档_服务监测模块]]
- [[PRD_计划执行_平台化改造与模块切换]]
\ No newline at end of file
# PRD 计划执行文档 — 返回首页导航栏改造 + 新增服务管理/服务监测模块
> 版本:1.0 | 日期:2026-07-14 | 分支:troubleshoot-ai-assistant
---
## 执行步骤
### 步骤 1:修改 modules.py — 新增模块声明
**文件**`skill/code/web/utils/modules.py`
**操作**:在 `MODULES` 列表中追加两个模块声明
```python
{
"id": "service-manage",
"name": "服务管理",
"icon": "🛠️",
"description": "新统一平台服务授权、服务升级、服务信息管理",
"url": "/service-manage",
"enabled": True,
"roles": ["admin"],
"sort": 2,
},
{
"id": "service-monitor",
"name": "服务监测",
"icon": "📊",
"description": "通过 SSH 连接监测服务器服务运行状态、输出报告",
"url": "/service-monitor",
"enabled": True,
"roles": [],
"sort": 3,
},
```
**注意**:原 `service-monitor`(enabled:False, roles:["admin"], sort:2)替换为新声明。
---
### 步骤 2:新建服务管理占位路由
**新建文件**`skill/code/web/routes/service_manage.py`
- Blueprint: `service-manage`
- 路由: `@bp.route('/service-manage')`
- 装饰器: `@page_login_required`
- 渲染: `service_manage.html`,传入 `user=session.get('user', {})`
---
### 步骤 3:新建服务管理占位模板
**新建文件**`skill/code/web/templates/service_manage.html`
- 顶部导航栏:`🛠️ 运行维护平台` + `/ 服务管理` + 用户名 + 退出
- 主体:`🚧 服务管理模块开发中` + 计划功能列表(服务授权/服务升级/服务信息)+ 返回首页按钮
- 移动端响应式
---
### 步骤 4:新建服务监测占位路由
**新建文件**`skill/code/web/routes/service_monitor.py`
- Blueprint: `service-monitor`
- 路由: `@bp.route('/service-monitor')`
- 装饰器: `@page_login_required`
- 渲染: `service_monitor.html`,传入 `user=session.get('user', {})`
---
### 步骤 5:新建服务监测占位模板
**新建文件**`skill/code/web/templates/service_monitor.html`
- 顶部导航栏:`🛠️ 运行维护平台` + `/ 服务监测` + 用户名 + 退出
- 主体:`🚧 服务监测模块开发中` + 计划功能列表(SSH连接监测/服务状态监测/报告输出)+ 返回首页按钮
- 移动端响应式
---
### 步骤 6:改造 index.html 顶部导航栏
**文件**`skill/code/web/templates/index.html`
**操作**
1.`<style>` 中添加 `.navbar` / `.navbar-left` / `.navbar-brand` / `.navbar-module` / `.navbar-user` / `.navbar-logout` 样式
2. 在移动端 `@media` 中添加导航栏适配(面包屑隐藏、触控热区 ≥44px)
3.`<body>` 后、`<div class="container">` 前插入导航栏 HTML
4. 删除 card 内部的 `user-info-bar` div(返回首页 + 用户名 + 退出已移到导航栏)
5. 删除移动端中已无用的 `.user-info` 样式
**导航栏结构**
```html
<div class="navbar">
<div class="navbar-left">
<a href="/" class="navbar-brand">🛠️ 运行维护平台</a>
<span class="navbar-module">/ 问题排查助手</span>
</div>
<div class="navbar-user">
<span id="userDisplayName">加载中...</span>
<button class="navbar-logout" onclick="logout()">🚪 退出</button>
</div>
</div>
```
---
### 步骤 7:server.py 注册新 Blueprint
**文件**`skill/code/web/server.py`
**操作**:在 `create_app()` 中注册两个新 Blueprint
```python
from routes.service_manage import bp as service_manage_bp
from routes.service_monitor import bp as service_monitor_bp
app.register_blueprint(service_manage_bp)
app.register_blueprint(service_monitor_bp)
```
---
### 步骤 8:部署清单确认
**文件**`deploy/upload_to_server.py`
**无需修改**`routes/``templates/` 目录已在 `DIRS_TO_UPLOAD` 中,新文件自动包含。
---
### 步骤 9:测试验证
1. `cd skill/code && python -m pytest -v` — 145 用例全绿
2. Chrome DevTools 验证:
- `/` 平台首页显示 3 个模块卡片(问题排查助手 + 服务管理 + 服务监测)
- 点击"服务管理"进入占位页,顶部导航栏可返回首页
- 点击"服务监测"进入占位页,顶部导航栏可返回首页
- 问题排查助手顶部导航栏可返回首页
3. 部署到 5.60 + `verify_deployment.py`
---
## 变更文件清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `skill/code/web/utils/modules.py` | 修改 | 新增 service-manage + service-monitor 声明 |
| `skill/code/web/routes/service_manage.py` | **新建** | 服务管理占位路由 |
| `skill/code/web/routes/service_monitor.py` | **新建** | 服务监测占位路由 |
| `skill/code/web/templates/service_manage.html` | **新建** | 服务管理占位页面 |
| `skill/code/web/templates/service_monitor.html` | **新建** | 服务监测占位页面 |
| `skill/code/web/templates/index.html` | 修改 | 返回首页改为顶部导航栏 |
| `skill/code/web/server.py` | 修改 | 注册新 Blueprint |
# PRD_需求文档_平台化改造与模块切换
## 基本信息
| 项目 | 内容 |
|------|------|
| 文档类型 | 需求文档 |
| 创建日期 | 2026-07-14 |
| 最后更新 | 2026-07-14 |
| 负责人 | 研发组(Claude 协助) |
| 优先级 | P2 🟠 |
| 状态 | 待开始 |
---
## 一、背景与目标
### 1.1 问题背景
当前「问题排查 AI 助手」是一个独立单功能站点:访问 `/` 直接进入排查助手主页面。平台后续将承载**多个维护类模块**(不仅限于问题知识助手),需要把现有站点升级为一个可扩展的**多模块平台**,问题排查助手降级为其中一个模块。
| 序号 | 现状 | 问题 |
|------|------|------|
| 1 | `/` 直接是排查助手主页 | 无法承载多模块,新模块无入口 |
| 2 | 站点定位是"问题排查助手" | 与"运维平台"定位不符,扩展性差 |
| 3 | 无模块切换机制 | 多模块并存时用户无法在模块间导航 |
### 1.2 修复目标
1. **新增平台首页** `/`:作为统一入口,以卡片/列表形式展示所有可用模块,点击进入对应模块
2. **问题排查助手降级为模块**:原 `/` 主页迁移到 `/troubleshoot`,首页改为模块列表
3. **预留服务监控模块**:在模块清单中声明"服务监控"模块(`enabled: false`,仅管理员可见),为后续接入预留入口
4. **建立模块注册机制**:通过配置声明模块清单,首页动态渲染,便于后续接入新模块
5. **共用服务与认证**:所有模块共用同一 Flask 服务、用户认证、缓存管理,各模块独立路由与页面
6. **保持现有功能零回归**:问题排查助手的搜索/AI 分析/提交/导出/缓存管理等全部功能不变,仅路径调整
### 1.3 范围说明
- 本次**只建框架**,先接入"问题排查助手"一个模块,**预留"服务监控"模块**`enabled: false`),后续按需接入
- 不涉及微服务拆分,全部在现有 Flask 服务内通过路由隔离实现
---
## 二、需求详情
### 2.1 模块注册机制
#### 2.1.1 需求规格
平台通过配置声明模块清单,首页动态渲染卡片。模块清单字段:
| 字段 | 说明 | 示例 |
|------|------|------|
| `id` | 模块唯一标识 | `troubleshoot` |
| `name` | 模块显示名 | `问题排查助手` |
| `icon` | 模块图标(emoji 或图标类) | `🔍` |
| `description` | 模块一句话描述 | `基于历史知识库的 AI 问题排查` |
| `url` | 模块入口路径 | `/troubleshoot` |
| `enabled` | 是否启用 | `true` |
| `roles` | 允许访问的角色(空=所有登录用户) | `[]` |
| `sort` | 排序权重 | `1` |
#### 2.1.2 实现方式
-`config.json` 新增 `modules` 数组,或新建 `utils/modules.py` 集中声明(推荐后者,便于扩展)
- 首页通过 `get_modules()` 读取清单并渲染卡片
#### 2.1.3 验收标准
- [ ] 模块清单可通过配置增删
- [ ] `enabled: false` 的模块不在首页展示
- [ ] 模块按 `sort` 排序展示
---
### 2.2 平台首页(新增)
#### 2.2.1 需求规格
| 项 | 规格 |
|----|------|
| 路径 | `/`(覆盖原排查助手主页) |
| 访问控制 | 登录后可见(未登录跳 `/login`) |
| 页面内容 | 顶部:平台标题 + 用户信息 + 退出;主体:模块卡片网格 |
| 卡片内容 | 图标 + 模块名 + 描述 + "进入"按钮 |
| 卡片点击 | 跳转到模块 `url` |
| 角色过滤 | 按 `modules[].roles` 过滤当前用户可见模块 |
| 响应式 | 移动端单列、桌面多列(沿用 P2-2 断点策略) |
| 空状态 | 无可用模块时显示"暂无可用模块"提示 |
#### 2.2.2 验收标准
- [ ] 访问 `/` 显示模块列表(非排查助手主页)
- [ ] 未登录跳转 `/login`
- [ ] 卡片点击进入对应模块
- [ ] 移动端响应式正常
- [ ] 普通用户/管理员看到各自有权限的模块
---
### 2.3 问题排查助手降级为模块
#### 2.3.1 路径调整
| 原路径 | 新路径 | 说明 |
|--------|--------|------|
| `/`(排查助手主页) | `/troubleshoot` | 排查助手主页迁移 |
| `/login` `/logout` | `/login` `/logout` | 不变(平台级认证) |
| `/api/*` | `/api/*` | 不变(API 路径保持) |
#### 2.3.2 需求规格
- 排查助手主页路由从 `/` 改为 `/troubleshoot`
- 排查助手页面顶部增加"返回平台首页"入口(链接到 `/`
- 排查助手所有功能(搜索/分析/提交/导出/缓存)**零变更**
- 前端 JS 中的 API 调用路径(相对路径 `/api/...`**无需改**
#### 2.3.3 验收标准
- [ ] `/troubleshoot` 正常显示排查助手主页
- [ ] `/` 显示平台首页(非排查助手)
- [ ] 排查助手全部功能正常(搜索/AI 分析/提交/导出/缓存)
- [ ] 排查助手页面有返回首页入口
---
### 2.4 平台导航
#### 2.4.1 需求规格
- 排查助手(及后续模块)页面顶部增加统一导航:平台名 + "返回首页"链接
- 首页顶部:平台标题 + 用户信息 + 退出(沿用现有用户信息栏样式)
#### 2.4.2 验收标准
- [ ] 各模块页可一键返回平台首页
- [ ] 平台标题一致
---
## 三、影响范围
### 3.1 涉及文件
| 文件 / 目录 | 变更类型 | 说明 |
|------------|---------|------|
| `web/utils/modules.py` | 新增 | 模块清单声明 + `get_modules()` |
| `web/routes/platform.py` | 新增 | 平台首页路由 `/` |
| `web/routes/auth.py` | 修改 | `/` 重定向改为 `/troubleshoot` 之外的处理(或交由 platform 路由) |
| `web/routes/troubleshoot.py` 或 server.py | 修改 | 排查助手主页路由 `/``/troubleshoot` |
| `web/templates/platform.html` | 新增 | 平台首页模板(模块卡片) |
| `web/templates/index.html` | 修改 | 顶部加"返回首页"导航 |
| `web/config.json` | 修改 | 可选:模块配置(若用配置驱动) |
| `deploy/upload_to_server.py` | 修改 | 上传清单补新文件 |
### 3.2 风险评估
| 风险项 | 等级 | 缓解措施 |
|--------|------|---------|
| 原 `/` 路径行为变化,外部书签失效 | 🟠 中 | `/troubleshoot` 与原 `/` 行为一致;老用户从首页点入即可 |
| 排查助手前端 JS 路径依赖 | 🟡 低 | API 用相对路径 `/api/...`,不随主页路径变化 |
| 认证流程受影响 | 🟡 低 | `/login` 不变,首页加 `@page_login_required` |
| 移动端首页布局 | 🟡 低 | 复用 P2-2 断点策略 |
---
## 四、非功能需求
| 项 | 要求 |
|----|------|
| 扩展性 | 新增模块只需在 `modules.py` 声明 + 注册路由,不改首页代码 |
| 兼容性 | 排查助手功能零回归;现有测试用例保持全绿 |
| 性能 | 首页轻量,无额外 API 调用(模块清单内存读取) |
| 安全 | 首页需登录;模块按角色过滤 |
| 可维护性 | 模块声明集中管理,不散落各处 |
---
## 五、时间估算
| 任务 | 工时 |
|------|------|
| 模块注册机制 `modules.py` | 0.5h |
| 平台首页路由 + 模板 | 2h |
| 排查助手路由迁移 `/``/troubleshoot` | 0.5h |
| 排查助手页加返回首页导航 | 0.5h |
| 移动端适配首页 | 1h |
| 测试 + 部署 | 1h |
| **合计** | **5.5h** |
---
## 六、验收清单
### 模块注册
- [ ] `modules.py` 声明模块清单
- [ ] `get_modules()` 支持角色过滤
### 平台首页
- [ ] `/` 显示模块卡片列表
- [ ] 未登录跳 `/login`
- [ ] 卡片点击进入模块
- [ ] 移动端响应式
### 排查助手降级
- [ ] `/troubleshoot` 显示排查助手主页
- [ ] 排查助手全部功能正常
- [ ] 页面有返回首页入口
### 测试与部署
- [ ] 现有 145 用例全绿(路由变更需同步改测试)
- [ ] 部署到 5.60 验证
---
## 维护记录
| 日期 | 更新内容 | 更新人 |
|------|---------|--------|
| 2026-07-14 | 初版创建 | 研发组 |
# PRD 需求文档 — 服务管理模块功能升级(服务授权 + 目标配置)
> 版本:1.0 | 日期:2026-08-12 | 分支:troubleshoot-ai-assistant
---
## 1. 需求背景
### 1.1 现状
当前「服务管理」模块仅有 Vue SPA 前端框架,页面内容均为占位符("开发中,敬请期待"):
| 页面 | 状态 |
|------|------|
| 服务授权(`/service-manage/authorization`) | 骨架页面,所有功能为空实现 |
| 服务升级(`/service-manage/upgrade`) | 空白页面 |
| 服务信息(`/service-manage/info`) | 空白页面 |
| 操作日志(`/service-manage/logs`) | 空白页面 |
后端 `routes/service_manage.py` 仅有一个空 Blueprint,无任何 API 实现。
### 1.2 目标
对标 5.44 统一管理平台的服务授权流程,在自研平台(5.60:8088)上实现完整的服务授权功能,并新增「目标配置」模块用于管理需要对接的目标服务器。
### 1.3 业务场景
| 场景 | 说明 |
|------|------|
| 服务授权 | 4 步流程:下载激活文件 → 上传授权文件 → 填写项目信息 → 下载部署视图/自检文档 |
| 目标配置 | 配置目标服务器(5.44 等)的连接信息,使授权操作可代理到目标服务器执行 |
---
## 2. 需求概述
### 2.1 服务授权(4 步流程)
整体对标 5.44 平台的授权流程,界面布局沿用当前 4 步卡片设计,但需对接自研后端代理 API。
**流程图:**
```
┌─────────────────────────────────────────────────────────────────┐
│ Step 1: 申请服务授权 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [📥 下载激活文件] → GET /api/service-manage/license/download │ │
│ │ [📤 上传授权文件] → POST /api/service-manage/license/upload │ │
│ │ 拖拽/点击上传 .zip/.lic/.key 文件 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Step 2: 填写项目信息 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 项目销售 * │ 部署人员 * │ 下单时间 * │ 服务器密码 * │ │
│ │ 项目概述 * │ [💾 保存] → POST /api/service-manage/project │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Step 3: 填写并提交部署视图 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [📥 下载部署视图] → GET /api/service-manage/deploy-view/download │
│ │ [📤 上传部署视图] → POST /api/service-manage/deploy-view/upload │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Step 4: 填写并提交自检文档 │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [📥 下载自检文档] → GET /api/service-manage/self-check/download │
│ │ [📤 上传自检文档] → POST /api/service-manage/self-check/upload │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 目标配置(左侧栏新增模块)
在服务管理左侧导航栏新增「目标配置」入口,用于管理对接的目标服务器(如 5.44 统一管理平台)。
**功能:**
| 功能 | 说明 |
|------|------|
| 目标列表 | 展示已配置的目标服务器(名称、地址、端口、状态) |
| 新增目标 | 添加目标服务器 IP、端口、认证凭据 |
| 编辑目标 | 修改目标服务器配置 |
| 删除目标 | 移除目标服务器配置 |
| 连通性测试 | 测试与目标服务器的连接状态 |
**设计说明:** 目标配置复用服务监测模块已有的 `target_service.py` 能力(CRUD + Fernet 加密 + SSH 连接测试),在服务管理模块中新增针对 HTTP API 代理的目标配置类型,主要存储目标服务器的 URL/端口/Token 等连接信息,用于授权 API 代理转发。
---
## 3. API 接口设计
### 3.1 服务授权 API
#### 3.1.1 下载激活文件
```
GET /api/service-manage/license/download
响应:文件流(licences.zip)
```
#### 3.1.2 上传授权文件
```
POST /api/service-manage/license/upload
Content-Type: multipart/form-data
Body: file=license.zip, iszip=true
响应:
{
"success": true,
"taskId": "a141b345-xxx",
"status": "pending"
}
```
#### 3.1.3 查询授权任务状态
```
GET /api/service-manage/license/task-status?taskId=xxx
响应:
{
"success": true,
"taskId": "xxx",
"status": "processing" | "completed" | "failed",
"message": "正在重新加载配置"
}
```
#### 3.1.4 保存项目信息
```
POST /api/service-manage/project
Body:
{
"salesPeople": "销售测试",
"deployer": "部署测试",
"orderTime": "2026-08-21 00:00:00",
"serverPassword": "Ubains@123",
"projectOverview": "项目概述测试内容"
}
响应:
{
"success": true,
"message": "设置软件版本成功"
}
```
#### 3.1.5 下载部署视图
```
GET /api/service-manage/deploy-view/download
响应:文件流(deploymentView<timestamp>.xlsx)
```
#### 3.1.6 下载自检文档
```
GET /api/service-manage/self-check/download
响应:文件流(checkList<timestamp>.xlsx)
```
### 3.2 目标配置 API
#### 3.2.1 目标列表
```
GET /api/service-manage/targets
响应:
{
"success": true,
"targets": [
{
"id": "tgt-xxx",
"name": "5.44 统一管理平台",
"host": "192.168.5.44",
"port": 443,
"protocol": "https",
"auth_type": "password",
"username": "superadmin",
"has_password": true,
"connected": true,
"created_at": "2026-08-12T00:00:00"
}
]
}
```
#### 3.2.2 新增目标
```
POST /api/service-manage/targets
Body:
{
"name": "5.44 统一管理平台",
"host": "192.168.5.44",
"port": 443,
"protocol": "https",
"auth_type": "password",
"username": "superadmin",
"password": "xxx"
}
响应:{ "success": true, "target": { ... } }
```
#### 3.2.3 更新目标
```
PUT /api/service-manage/targets/:id
响应:{ "success": true, "target": { ... } }
```
#### 3.2.4 删除目标
```
DELETE /api/service-manage/targets/:id
响应:{ "success": true }
```
#### 3.2.5 测试连接
```
POST /api/service-manage/targets/test
Body:
{
"host": "192.168.5.44",
"port": 443,
"protocol": "https",
"username": "superadmin",
"password": "xxx"
}
响应:
{
"success": true,
"message": "连接成功"
}
```
---
## 4. 前端页面设计
### 4.1 服务授权页面(Authorization.vue)
**当前状态:** 4 步卡片布局已存在,功能为空实现
**需要变更:**
| 卡片 | 当前 | 目标 |
|------|------|------|
| Step 1 | 下载/上传按钮 + 上传区域(空实现) | 对接真实 API,下载激活文件、上传授权文件、轮询任务状态 |
| Step 2 | 表单 + 保存按钮(空实现) | 对接项目信息保存 API,显示成功/失败提示 |
| Step 3 | 下载/上传按钮 + 上传区域(空实现) | 对接部署视图下载/上传 API |
| Step 4 | 下载/上传按钮 + 上传区域(空实现) | 对接自检文档下载/上传 API |
**交互细节:**
- 上传授权文件后显示进度条/状态提示
- 任务处理中禁用其他操作按钮
- 表单字段验证(必填项)
- 下载文件时使用 Blob 处理
### 4.2 目标配置页面(TargetConfig.vue)— 新增
**布局:**
```
┌──────────────────────────────────────────────────────────────┐
│ 🎯 目标配置 [+ 新增目标] │
├──────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 名称 │ 地址 │ 状态 │ 操作 │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ 5.44 管理平台 │ 192.168.5.44 │ ✅ 在线 │ ✏️ 🗑️ 🔗 │ │
│ │ 5.60 本机 │ 192.168.5.60 │ ✅ 在线 │ ✏️ 🗑️ 🔗 │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
🔗 = 测试连接
```
**新增目标对话框:**
```
┌─ 新增目标 ───────────────────────────────────┐
│ 名称 * [________________________] │
│ 地址 * [________________________] │
│ 端口 * [443_____] 协议 [HTTPS ▼] │
│ 认证方式 [密码 ▼] │
│ 用户名 * [________________________] │
│ 密码 * [________________________] │
│ ┌──────────┐ ┌──────────┐ │
│ │ 测试连接 │ │ 保存 │ │
│ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────┘
```
### 4.3 左侧导航栏变更
**ManageLayout.vue 侧边栏菜单新增:**
```
服务管理
├── 🔑 服务授权 → /service-manage/authorization
├── 🎯 目标配置 → /service-manage/targets ← 新增
├── 🚀 服务升级 → /service-manage/upgrade
├── ℹ️ 服务信息 → /service-manage/info
└── 📝 操作日志 → /service-manage/logs
```
---
## 5. 后端架构设计
### 5.1 整体架构
```
┌──────────────────────────────────────────────────────────────┐
│ Frontend (Vue SPA) │
│ /service-manage/* → 服务管理模块前端页面 │
└──────────────────────┬───────────────────────────────────────┘
│ HTTP API
┌──────────────────────▼───────────────────────────────────────┐
│ Backend (Flask) │
│ routes/service_manage.py ← 服务管理 API 路由层 │
│ services/service_manage/ ← 业务逻辑层(新增) │
│ ├── license_service.py ← 授权文件服务(代理/本地) │
│ ├── project_service.py ← 项目信息服务 │
│ └── target_service.py ← 目标配置服务(可复用监测模块) │
└──────────────────────┬───────────────────────────────────────┘
│ HTTP Proxy
┌──────────────────────▼───────────────────────────────────────┐
│ 5.44 统一管理平台(目标服务器) │
│ /exapi/system/* → 授权 API │
│ /backstage/exapi/system/* → 授权后台 API │
└──────────────────────────────────────────────────────────────┘
```
### 5.2 关键设计决策
| 决策 | 方案 |
|------|------|
| 授权 API 实现方式 | 后端代理模式:前端请求 → 自研后端 → 转发到配置的目标服务器 |
| 目标配置存储 | 复用 `target_service.py` 的加密存储机制(Fernet),或独立 JSON 文件 |
| 文件上传 | 前端 → 后端暂存 → 转发到目标服务器 |
| 任务状态轮询 | 后端轮询目标服务器,前端通过 SSE 或定时轮询获取状态 |
### 5.3 路由规划
```python
# routes/service_manage.py 新增 API 路由
# 授权相关
/api/service-manage/license/download # GET 下载激活文件
/api/service-manage/license/upload # POST 上传授权文件
/api/service-manage/license/task-status # GET 查询授权任务状态
/api/service-manage/project # POST 保存项目信息
/api/service-manage/deploy-view/download # GET 下载部署视图
/api/service-manage/deploy-view/upload # POST 上传部署视图
/api/service-manage/self-check/download # GET 下载自检文档
/api/service-manage/self-check/upload # POST 上传自检文档
# 目标配置
/api/service-manage/targets # GET 目标列表
/api/service-manage/targets # POST 新增目标
/api/service-manage/targets/:id # PUT 更新目标
/api/service-manage/targets/:id # DELETE 删除目标
/api/service-manage/targets/test # POST 测试连接
```
---
## 6. 数据类型定义
### 6.1 目标配置类型
```typescript
// frontend/src/types/service-manage.ts
/** 目标服务器配置 */
export interface ManageTarget {
id: string
name: string
host: string
port: number
protocol: 'http' | 'https'
auth_type: 'password' | 'token' | 'none'
username: string
has_password: boolean
connected: boolean
created_at: string
updated_at: string
}
/** 新建目标请求 */
export interface CreateManageTargetRequest {
name: string
host: string
port?: number
protocol?: 'http' | 'https'
auth_type?: 'password' | 'token' | 'none'
username?: string
password?: string
}
/** 测试连接请求 */
export interface TestManageTargetRequest {
host: string
port: number
protocol: 'http' | 'https'
username?: string
password?: string
}
```
### 6.2 授权相关类型
```typescript
/** 授权任务状态 */
export interface LicenseTaskStatus {
taskId: string
status: 'pending' | 'processing' | 'completed' | 'failed'
message: string
}
/** 项目信息 */
export interface ProjectInfo {
salesPeople: string
deployer: string
orderTime: string
serverPassword: string
projectOverview: string
}
```
---
## 7. 非功能需求
| 需求 | 要求 |
|------|------|
| 权限控制 | 服务授权/目标配置仅管理员可操作(`requiresAdmin: true`) |
| 安全性 | 服务器密码Fernet加密存储,传输使用HTTPS |
| 错误处理 | 统一错误响应格式,前端友好提示 |
| 文件大小限制 | 上传授权文件限制 2048M |
| 任务超时 | 授权任务状态轮询超时 5 分钟 |
| 响应式 | 适配移动端(768px 断点,卡片单列布局) |
---
## 8. 目录结构变更
```
# 新增文件
frontend/src/api/service-manage/ # 服务管理 API 封装
├── index.ts # API 索引
├── license.ts # 授权 API
├── project.ts # 项目信息 API
└── target.ts # 目标配置 API
frontend/src/types/service-manage.ts # 服务管理类型定义
frontend/src/views/service-manage/Targets.vue # 目标配置页面
# 修改文件
frontend/src/views/service-manage/Authorization.vue # 补全功能
frontend/src/layouts/ManageLayout.vue # 新增"目标配置"菜单项
frontend/src/router/index.ts # 新增目标配置路由
skill/code/web/routes/service_manage.py # 补全 API 路由
skill/code/web/services/service_manage/ # 新建业务逻辑层(可选目录)
# 数据文件
skill/code/web/service_manage_data/ # 服务管理数据目录
├── targets.json # 目标配置存储
└── projects.json # 项目信息存储
```
---
## 9. 附录:5.44 平台 API 参考
以下为 5.44 统一管理平台的原生 API,作为自研平台代理实现时的参考。
| 序号 | 接口 | 方法 | 说明 |
|------|------|------|------|
| 1 | `/exapi/system/downLincenceFile?category=java_python` | POST | 下载激活文件 |
| 2 | `/backstage/exapi/system/uploadLincenceFile` | POST | 上传授权文件(multipart) |
| 3 | `/backstage/exapi/system/licenceTaskStatus?taskId=xxx` | GET | 轮询授权任务状态 |
| 4 | `/exapi/system/setOtherInfo` | POST | 保存项目信息 |
| 5 | `/exapi/system/exportDeployView` | GET | 导出部署视图 |
| 6 | `/exapi/system/exportServiceDetect` | GET | 导出自检文档 |
**认证方式:** JWT Bearer Token + HMAC 签名(`x-sign``x-random``x-timestamp` 自定义头)
\ No newline at end of file
# PRD 需求文档:服务管理模块
## 基本信息
| 项目 | 内容 |
|------|------|
| 文档类型 | PRD 需求文档 |
| 所属模块 | 服务管理(service-manage) |
| 创建日期 | 2026-07-22 |
| 负责人 | czj |
| 优先级 | P1 |
| 状态 | 开发中 |
| 关联分支 | troubleshoot-ai-assistant |
## 背景与目标
### 背景
当前项目已实现"服务监测"模块(service-monitor),用于监测服务器/服务状态并输出报告。现需要开发配套的"服务管理"模块(service-manage),提供服务授权管理、服务升级管理、服务信息查看等功能。
### 目标
1. 参考 `https://192.168.5.44` 统一管理平台的模块设计,实现静态页面
2. 与现有"服务监测"模块完全隔离,互不影响
3. 仅管理员可访问,确保安全性
### 非目标
- 本次仅开发静态页面,不实现后端业务逻辑
- 不涉及与外部系统的集成(如授权文件生成、服务重启等)
## 需求详情
### 1. 服务授权页面(Service Authorization)
**页面路径**: `/service-manage/authorization`
**功能描述**: 服务授权申请流程,分4个步骤卡片展示
#### 步骤 1: 申请服务授权
- 卡片标题:"1. 申请服务授权"
- 操作按钮:
- "下载激活文件" 按钮
- "上传授权文件" 按钮(文件上传控件)
#### 步骤 2: 填写项目信息
- 卡片标题:"2. 填写项目信息"
- 表单字段(带必填标记 *):
- 项目销售(文本输入框)
- 部署人员(文本输入框)
- 下单时间(日期时间选择器)
- 服务器密码(密码输入框)
- 项目概述(多行文本框)
- 操作按钮:"保存" 按钮
#### 步骤 3: 填写并提交部署视图
- 卡片标题:"3. 填写并提交部署视图"
- 操作按钮:"下载部署视图" 按钮
#### 步骤 4: 填写并提交自检文档
- 卡片标题:"4. 填写并提交自检文档"
- 操作按钮:"下载自检文档" 按钮
**验收标准**:
- [ ] 页面正确显示4个步骤卡片
- [ ] 表单字段布局合理,标签清晰
- [ ] 按钮样式与平台整体风格一致
- [ ] 响应式布局适配移动端
---
### 2. 服务升级页面(Service Upgrade)
**页面路径**: `/service-manage/upgrade`
**功能描述**: 服务重启/回滚管理,Tab 切换 + 服务列表表格
#### 顶部标签页
- Tab 1: "服务重启"(默认选中)
- Tab 2: "服务回滚"
#### 顶部操作栏
- "下载配置" 按钮
- "备份数据库文件" 按钮
- "上传更新服务" 按钮(文件上传)
- "重启已选服务" 按钮(批量操作,默认禁用)
- "下载日志" 按钮(批量操作,默认禁用)
#### 服务列表表格
| 列名 | 说明 |
|------|------|
| ☐ | 全选复选框 |
| 服务名称 | 如"运维系统"、"预定系统2.0" |
| 服务类型 | 如"PYTHON"、"JAVA" |
| 升级路径 | 服务部署路径 |
| 重启脚本路径 | 启动脚本路径 |
| 操作 | "重启"按钮、"下载日志"按钮 |
**验收标准**:
- [ ] Tab 切换功能正常
- [ ] 表格正确渲染,列宽合理
- [ ] 复选框可选择/取消
- [ ] 批量操作按钮根据选择状态启用/禁用
- [ ] 响应式布局适配移动端
---
### 3. 服务信息页面(Service Info)
**页面路径**: `/service-manage/info`
**功能描述**: 服务监控信息概览
#### 筛选区域
- 监控类型下拉选择框(默认值:"system")
#### 信息卡片区域
- CPU 使用率(进度条/数值展示)
- 内存使用率(进度条/数值展示)
- 硬盘使用率(进度条/数值展示)
**验收标准**:
- [ ] 下拉选择框功能正常
- [ ] 信息卡片布局清晰
- [ ] 进度条/数值展示直观
---
## 页面布局设计
### 通用布局
- 继承 `base.html` 模板
- 顶部导航栏:显示当前模块路径(如"系统管理 / 服务授权")
- 左侧菜单栏:
- 服务授权(链接到 `/service-manage/authorization`
- 服务升级(链接到 `/service-manage/upgrade`
- 服务信息(链接到 `/service-manage/info`
### 顶部 Tab 导航
- 三个一级菜单作为 Tab:
- 服务授权
- 服务升级
- 服务信息
- 点击 Tab 切换到对应页面
---
## 技术方案
### 模块隔离方案
参考 `service_monitor` 的隔离模式:
```
skill/code/web/
├── service_manage/ # 新增:服务管理模块(自包含子包)
│ ├── __init__.py # 仅暴露 Blueprint
│ ├── routes.py # 路由层:页面路由
│ └── templates/ # 模板(可选,或复用主 templates/)
└── templates/service_manage/ # 模板目录
├── base.html # 基础模板
├── authorization.html # 服务授权页
├── upgrade.html # 服务升级页
└── info.html # 服务信息页
```
### Blueprint 注册
- Blueprint 名称: `service-manage`
- URL 前缀: `/service-manage`
- 权限控制: `@page_login_required` + 管理员权限检查
### 模板继承
- 创建 `templates/service_manage/base.html` 作为模块基础模板
- 子模板继承 base.html,填充 `{% block content %}`
---
## 影响范围
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `skill/code/web/service_manage/__init__.py` | 新增 | Blueprint 定义 |
| `skill/code/web/service_manage/routes.py` | 新增 | 页面路由 |
| `skill/code/web/templates/service_manage/base.html` | 新增 | 基础模板 |
| `skill/code/web/templates/service_manage/authorization.html` | 新增 | 服务授权页 |
| `skill/code/web/templates/service_manage/upgrade.html` | 新增 | 服务升级页 |
| `skill/code/web/templates/service_manage/info.html` | 新增 | 服务信息页 |
| `skill/code/web/server.py` | 修改 | 注册新 Blueprint |
| `skill/code/web/utils/modules.py` | 修改 | 更新模块元数据 |
---
## 非功能需求
| 项目 | 要求 |
|------|------|
| 性能 | 静态页面加载时间 < 500ms |
| 兼容性 | 支持 Chrome、Edge、Firefox 最新版本 |
| 响应式 | 适配桌面(>1200px)、平板(768-1200px)、移动端(<768px) |
| 可访问性 | 符合 WCAG 2.1 AA 标准 |
---
## 时间估算
| 任务 | 预计时间 |
|------|----------|
| 创建模块目录结构和 Blueprint | 0.5h |
| 开发基础模板 (base.html) | 1h |
| 开发服务授权页面 | 2h |
| 开发服务升级页面 | 2h |
| 开发服务信息页面 | 1h |
| 注册 Blueprint 和模块元数据更新 | 0.5h |
| 响应式适配和样式调整 | 1h |
| **总计** | **7h** |
---
## 验收清单
- [ ] 三个页面均可正常访问
- [ ] 页面布局与参考平台一致
- [ ] Tab 切换功能正常
- [ ] 表单和按钮样式统一
- [ ] 响应式布局正常
- [ ] 与 service-monitor 模块完全隔离,无交叉影响
- [ ] 仅管理员可访问
---
## 附录
### 参考链接
- 参考平台: `https://192.168.5.44/#/backend/backstage`
- 账号: superadmin / Ubains@1357
### 相关文档
- [[PRD_需求文档_服务监测模块]]
- [[PRD_计划执行_平台化改造与模块切换]]
\ No newline at end of file
# PRD 需求文档 — 返回首页导航栏改造 + 新增服务管理/服务监测模块
> 版本:1.0 | 日期:2026-07-14 | 分支:troubleshoot-ai-assistant
---
## 1. 需求背景
### 1.1 返回首页问题
当前问题排查助手模块(`/troubleshoot`)的"返回首页"链接嵌在用户信息栏内,样式为蓝色小文字按钮,不够显眼,用户反馈"无法返回主页"。
**目标**:将返回首页改为顶部导航栏形式,与平台首页(`/`)风格统一,确保跨模块导航清晰可见。
### 1.2 新增模块
平台首页当前仅有"问题排查助手"一个模块卡片,需要新增两个模块占位:
- **服务管理**:用于新统一平台的服务授权、服务升级、服务信息管理(本期仅占位)
- **服务监测**:用于通过 SSH 连接监测服务器服务运行状态、输出报告(本期仅占位)
---
## 2. 功能需求
### 2.1 顶部导航栏改造
| 需求项 | 说明 |
|--------|------|
| 导航栏位置 | 页面顶部,card 外部上方 |
| 导航栏风格 | 与平台首页统一:渐变背景 + 毛玻璃效果 + 白色文字 |
| 左侧内容 | `🛠️ 运行维护平台` 品牌链接(href="/")+ `/ 模块名` 面包屑 |
| 右侧内容 | 用户名(角色)+ 退出按钮 |
| 移动端适配 | 面包屑隐藏,退出按钮 ≥44px 触控热区 |
| 影响范围 | `index.html`(问题排查助手页面) |
### 2.2 服务管理模块(占位)
| 需求项 | 说明 |
|--------|------|
| 模块 ID | `service-manage` |
| 模块名称 | 服务管理 |
| 图标 | 🛠️ |
| 描述 | 新统一平台服务授权、服务升级、服务信息管理 |
| URL | `/service-manage` |
| 访问权限 | 仅管理员(`roles: ["admin"]`) |
| 页面内容 | 占位页面:顶部导航栏 + "🚧 服务管理模块开发中" + 计划功能列表 + 返回首页按钮 |
| 本期实现 | 仅占位卡片 + 静态页面,不实现具体功能 |
### 2.3 服务监测模块(占位)
| 需求项 | 说明 |
|--------|------|
| 模块 ID | `service-monitor` |
| 模块名称 | 服务监测 |
| 图标 | 📊 |
| 描述 | 通过 SSH 连接监测服务器服务运行状态、输出报告 |
| URL | `/service-monitor` |
| 访问权限 | 所有登录用户(`roles: []`) |
| 页面内容 | 占位页面:顶部导航栏 + "🚧 服务监测模块开发中" + 计划功能列表 + 返回首页按钮 |
| 本期实现 | 仅占位卡片 + 静态页面,不实现具体功能 |
---
## 3. 非功能需求
| 项目 | 要求 |
|------|------|
| 移动端适配 | 所有新增页面需响应式适配(≤768px 手机、≤1024px 平板) |
| 导航一致性 | 所有模块页面顶部导航栏风格统一 |
| 单元测试 | 现有 145 用例不受影响,全绿 |
| 部署 | 更新到 192.168.5.60:8088 |
---
## 4. 约束
- 服务管理/服务监测本期**仅占位**,不实现具体功能
- 不修改 `search_engine.py` / `safety_filter.py` / `cache_manager.py` 公开接口
- 新增模块遵循现有 routes/services/utils 三层架构
......@@ -27,7 +27,8 @@ from services.service_manage import (
update_target, delete_target, test_connection,
save_project, get_project,
get_activation_file, get_deploy_view_file, get_self_check_file,
save_uploaded_file,
save_uploaded_file, upload_license_file, check_license_task,
_upload_to_target,
)
logger = logging.getLogger("service_manage.routes")
......@@ -184,7 +185,7 @@ def license_download():
@bp.route('/api/service-manage/license/upload', methods=['POST'])
def license_upload():
"""上传授权文件(暂存,后续代理转发到目标服务器)。"""
"""上传授权文件(代理到目标服务器;无目标时本地暂存)。"""
guard = _require_admin_json()
if guard:
return guard
......@@ -195,16 +196,9 @@ def license_upload():
if not file_storage.filename:
return jsonify({"success": False, "error": {"code": 400, "message": "文件名为空"}}), 400
result = save_uploaded_file(file_storage, subdir="license")
_audit("upload_license", f"上传授权文件: {result['filename']}")
# 阶段一:本地暂存后直接返回成功(未来对接目标服务器后返回真实 taskId)
return jsonify({
"success": True,
"taskId": f"local-{result['filename']}",
"status": "pending",
"message": "授权文件已上传,处理中",
})
result = upload_license_file(file_storage)
_audit("upload_license", f"上传授权文件: {result.get('filename', '')}")
return jsonify(result)
except Exception as e:
logger.exception("上传授权文件失败")
return jsonify({"success": False, "error": {"code": 500, "message": str(e)}}), 500
......@@ -212,18 +206,18 @@ def license_upload():
@bp.route('/api/service-manage/license/task-status', methods=['GET'])
def license_task_status():
"""查询授权任务状态(阶段一:本地模式直接返回完成)。"""
"""查询授权任务状态(代理到目标服务器;本地模式直接返回完成)。"""
guard = _require_admin_json()
if guard:
return guard
# 阶段一:本地暂存模式,任务立即完成
# 后续对接目标服务器后,需要轮询目标服务器的真实状态
return jsonify({
"success": True,
"taskId": request.args.get("taskId", ""),
"status": "completed",
"message": "授权处理完成",
})
try:
task_id = request.args.get("taskId", "")
result = check_license_task(task_id)
result["taskId"] = task_id
return jsonify(result)
except Exception as e:
logger.exception("查询授权任务状态失败")
return jsonify({"success": False, "error": {"code": 500, "message": str(e)}}), 500
@bp.route('/api/service-manage/project', methods=['GET', 'POST'])
......@@ -274,7 +268,7 @@ def deploy_view_download():
@bp.route('/api/service-manage/deploy-view/upload', methods=['POST'])
def deploy_view_upload():
"""上传部署视图。"""
"""上传部署视图(代理到目标服务器;无目标时本地暂存)。"""
guard = _require_admin_json()
if guard:
return guard
......@@ -285,9 +279,9 @@ def deploy_view_upload():
if not file_storage.filename:
return jsonify({"success": False, "error": {"code": 400, "message": "文件名为空"}}), 400
result = save_uploaded_file(file_storage, subdir="deploy-view")
_audit("upload_deploy_view", f"上传部署视图: {result['filename']}")
return jsonify({"success": True, "message": "部署视图上传成功", "file": result})
result = _upload_to_target(file_storage, "upload_deploy_view")
_audit("upload_deploy_view", f"上传部署视图: {file_storage.filename}")
return jsonify(result)
except Exception as e:
logger.exception("上传部署视图失败")
return jsonify({"success": False, "error": {"code": 500, "message": str(e)}}), 500
......@@ -315,7 +309,7 @@ def self_check_download():
@bp.route('/api/service-manage/self-check/upload', methods=['POST'])
def self_check_upload():
"""上传自检文档。"""
"""上传自检文档(代理到目标服务器;无目标时本地暂存)。"""
guard = _require_admin_json()
if guard:
return guard
......@@ -326,9 +320,9 @@ def self_check_upload():
if not file_storage.filename:
return jsonify({"success": False, "error": {"code": 400, "message": "文件名为空"}}), 400
result = save_uploaded_file(file_storage, subdir="self-check")
_audit("upload_self_check", f"上传自检文档: {result['filename']}")
return jsonify({"success": True, "message": "自检文档上传成功", "file": result})
result = _upload_to_target(file_storage, "upload_self_check")
_audit("upload_self_check", f"上传自检文档: {file_storage.filename}")
return jsonify(result)
except Exception as e:
logger.exception("上传自检文档失败")
return jsonify({"success": False, "error": {"code": 500, "message": str(e)}}), 500
\ No newline at end of file
# -*- coding: utf-8 -*-
"""
five44_client.py — 5.44 统一管理平台代理客户端(服务授权模块阶段二)
从目标服务器(5.44)的 http_client.py 逆向移植的认证 + 签名算法:
1. 登录前密码 SHA256 摘要(与前端一致)
2. 请求签名:x_random + x_timestamp + JSON(body) → SHA256 → AES-CBC(PKCS7)
- 有 token 时:AES 密钥源 = SHA256("Bearer {token}")
- 无 token 时:AES 密钥源 = SHA256(60 位随机串),该串随 RandomCode 头发出
3. AES 密钥/IV 均按 UTF-8 字节解析(与 CryptoJS 传字符串行为一致),非 hex
职责:封装 5.44 全部 6 个授权相关接口,自动登录 + 缓存 token。
注意:
- 本客户端仅用于服务授权代理,与服务监测模块(service_monitor)完全独立。
- 依赖 requests + pycryptodome(pycryptodome 缺失时签名直接失败并给出明确错误,
不静默降级——代理模式必须真实验签,否则 5.44 一律返回"无效token")。
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import random
import string
import time
from typing import Any, Dict, Optional, Tuple
import requests
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
HAS_CRYPTO = True
except ImportError: # pragma: no cover - 依赖缺失路径
HAS_CRYPTO = False
logger = logging.getLogger("service_manage.five44")
# 5.44 固定验证码旁路(安全测试已知项)
DEFAULT_CAPTCHA = "csba"
# 下载激活文件的产品类别参数
LICENSE_CATEGORY = "java_python"
class Five44Error(Exception):
"""5.44 平台交互错误(登录失败/请求失败/服务端业务错误)。"""
class Five44Client:
"""5.44 统一管理平台 API 客户端(认证 + 签名 + 业务调用)。"""
def __init__(
self,
base_url: str,
username: str,
password: str,
captcha: str = DEFAULT_CAPTCHA,
verify_ssl: bool = False,
timeout: int = 30,
) -> None:
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.captcha = captcha
self.verify_ssl = verify_ssl
self.timeout = timeout
self.session = requests.Session()
self._token: Optional[str] = None
# ============================================================
# 签名基础
# ============================================================
@staticmethod
def encrypt_password(password: str) -> str:
"""登录密码 SHA256 摘要(与前端一致)。"""
return hashlib.sha256(password.encode("utf-8")).hexdigest()
@staticmethod
def _sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@staticmethod
def _generate_random_string(min_len: int = 8, max_len: int = 16) -> str:
length = random.randint(min_len, max_len)
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
@staticmethod
def _aes_encrypt(plaintext: str, key_str: str, iv_str: str) -> str:
"""AES-CBC PKCS7 加密(CryptoJS 字符串密钥按 UTF-8 编码,非 hex)。"""
if not HAS_CRYPTO:
raise Five44Error("pycryptodome 未安装,无法进行请求签名(pip install pycryptodome)")
key_bytes = key_str.encode("utf-8") # 16 字节
iv_bytes = iv_str.encode("utf-8") # 16 字节
cipher = AES.new(key_bytes, AES.MODE_CBC, iv_bytes)
padded = pad(plaintext.encode("utf-8"), AES.block_size)
return base64.b64encode(cipher.encrypt(padded)).decode("utf-8")
def _generate_sign(
self,
body_data: Optional[dict] = None,
bearer_token: str = "",
) -> Dict[str, str]:
"""生成签名头 {X-RANDOM, X-TIMESTAMP, X-SIGN, [RandomCode]}。"""
x_random = self._generate_random_string(8, 16)
x_timestamp = str(int(time.time() * 1000))
if body_data is not None:
sign_str = (
x_timestamp
+ json.dumps(body_data, separators=(",", ":"), ensure_ascii=False)
+ x_random
)
else:
sign_str = x_timestamp + x_random
sign_hash = self._sha256(sign_str)
random_code = None
if bearer_token:
aes_key_source = self._sha256(bearer_token)
else:
random_code = self._generate_random_string(60, 60)
aes_key_source = self._sha256(random_code)
aes_key = aes_key_source[16:32]
aes_iv = aes_key_source[0:8] + aes_key_source[-8:]
x_sign = self._aes_encrypt(sign_hash, aes_key, aes_iv)
headers = {
"X-RANDOM": x_random,
"X-TIMESTAMP": x_timestamp,
"X-SIGN": x_sign,
}
if random_code:
headers["RandomCode"] = random_code
return headers
# ============================================================
# 登录 / token 管理
# ============================================================
def get_captcha_uuid(self) -> str:
"""获取验证码 UUID(GET /platform/api/code)。"""
url = f"{self.base_url}/platform/api/code"
try:
resp = self.session.get(url, verify=self.verify_ssl, timeout=self.timeout)
data = resp.json()
except Exception as e:
raise Five44Error(f"获取验证码失败: {e}") from e
uuid_val = (
data.get("uuid")
or (data.get("data") or {}).get("uuid")
or ""
)
return str(uuid_val)
def login(self) -> str:
"""登录 5.44 平台,返回 accessToken,失败抛 Five44Error。"""
uuid_val = self.get_captcha_uuid()
payload = {
"username": self.username,
"password": self.encrypt_password(self.password),
"code": self.captcha,
"uuid": uuid_val,
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
}
headers.update(self._generate_sign(body_data=payload, bearer_token=""))
try:
resp = self.session.post(
f"{self.base_url}/platform/api/auth/login",
json=payload,
headers=headers,
verify=self.verify_ssl,
timeout=self.timeout,
)
except requests.RequestException as e:
raise Five44Error(f"登录请求异常: {e}") from e
try:
data = resp.json()
except ValueError:
raise Five44Error(f"登录响应非 JSON(HTTP {resp.status_code})") from None
token = (
data.get("accessToken")
or data.get("token")
or data.get("access_token")
or (data.get("data") or {}).get("accessToken")
or (data.get("data") or {}).get("token")
or (data.get("data") or {}).get("access_token")
)
if not token:
msg = data.get("msg") or data.get("message") or "未知原因"
raise Five44Error(f"5.44 登录失败: {msg}")
self._token = str(token)
logger.info("5.44 登录成功: %s", self.username)
return self._token
def ensure_token(self) -> str:
"""获取有效 token(已登录直接返回,否则重新登录)。"""
return self._token or self.login()
def clear_token(self) -> None:
self._token = None
# ============================================================
# 带签名请求
# ============================================================
def _request(
self,
method: str,
path: str,
json_data: Optional[dict] = None,
params: Optional[dict] = None,
files: Optional[dict] = None,
) -> requests.Response:
"""发送带签名请求;遇到 401/无效token 自动重新登录重试一次。"""
token = self.ensure_token()
bearer = f"Bearer {token}"
body_for_sign = None
if method.lower() in ("post", "put", "patch") and json_data is not None:
body_for_sign = json_data
elif method.lower() in ("get", "delete") and params:
body_for_sign = params
headers = {
"Accept": "application/json, text/plain, */*",
}
if json_data is not None:
headers["Content-Type"] = "application/json"
headers.update(self._generate_sign(body_data=body_for_sign, bearer_token=bearer))
if files is None:
headers["Authorization"] = bearer
url = f"{self.base_url}{path}"
try:
resp = self.session.request(
method, url,
json=json_data if json_data is not None else None,
params=params if params is not None else None,
files=files,
headers=headers,
verify=self.verify_ssl,
timeout=self.timeout,
)
except requests.RequestException as e:
raise Five44Error(f"请求 5.44 异常 [{path}]: {e}") from e
# token 失效时重登重试一次
if resp.status_code in (401, 403) and self._token:
self.clear_token()
token = self.login()
bearer = f"Bearer {token}"
headers["Authorization"] = bearer
headers.update(self._generate_sign(body_data=body_for_sign, bearer_token=bearer))
try:
resp = self.session.request(
method, url,
json=json_data if json_data is not None else None,
params=params if params is not None else None,
files=files,
headers=headers,
verify=self.verify_ssl,
timeout=self.timeout,
)
except requests.RequestException as e:
raise Five44Error(f"重试请求 5.44 异常 [{path}]: {e}") from e
return resp
def _check_ok(self, resp: requests.Response, path: str) -> Dict[str, Any]:
"""解析 JSON 响应,非成功时抛 Five44Error。"""
try:
data = resp.json()
except ValueError:
raise Five44Error(
f"5.44 返回非 JSON [{path}]:HTTP {resp.status_code}"
) from None
if not isinstance(data, dict):
raise Five44Error(f"5.44 返回格式异常 [{path}]")
# 业务失败判断:code 非 200/0/success
code = data.get("code", 200)
if resp.status_code >= 400 or (isinstance(code, int) and code not in (0, 200)):
msg = data.get("msg") or data.get("message") or f"HTTP {resp.status_code}"
raise Five44Error(f"5.44 业务失败 [{path}]: {msg}")
if data.get("success") is False:
msg = data.get("message") or data.get("msg") or "未知原因"
raise Five44Error(f"5.44 业务失败 [{path}]: {msg}")
return data
# ============================================================
# 业务接口(6 个)
# ============================================================
def download_activation(self) -> Tuple[bytes, str]:
"""下载激活文件(POST /exapi/system/downLincenceFile?category=java_python)。
Returns: (文件字节, 服务端文件名)
"""
path = f"/exapi/system/downLincenceFile?category={LICENSE_CATEGORY}"
resp = self._request("GET", path)
if resp.status_code >= 400:
raise Five44Error(
f"下载激活文件失败:HTTP {resp.status_code}"
)
return resp.content, self.download_name(resp, "licences.zip")
def upload_license(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
"""上传授权文件(POST /backstage/exapi/system/uploadLincenceFile,multipart)。"""
path = "/backstage/exapi/system/uploadLincenceFile"
resp = self._request(
"POST",
path,
files={"file": (filename, file_bytes)},
)
if resp.status_code >= 400:
raise Five44Error(f"上传授权文件失败:HTTP {resp.status_code}")
return self._check_ok(resp, path)
def upload_deploy_view(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
"""上传部署视图(POST /exapi/uploader/uploadDeployView,multipart)。"""
path = "/exapi/uploader/uploadDeployView"
resp = self._request(
"POST",
path,
files={"file": (filename, file_bytes)},
)
if resp.status_code >= 400:
raise Five44Error(f"上传部署视图失败:HTTP {resp.status_code}")
return self._check_ok(resp, path)
def upload_self_check(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
"""上传自检文档(POST /exapi/uploader/uploadServiceDetect,multipart)。"""
path = "/exapi/uploader/uploadServiceDetect"
resp = self._request(
"POST",
path,
files={"file": (filename, file_bytes)},
)
if resp.status_code >= 400:
raise Five44Error(f"上传自检文档失败:HTTP {resp.status_code}")
return self._check_ok(resp, path)
def check_task_status(self, task_id: str) -> Dict[str, Any]:
"""查询授权任务状态(GET /backstage/exapi/system/licenceTaskStatus?taskId=xxx)。"""
path = "/backstage/exapi/system/licenceTaskStatus"
resp = self._request("GET", path, params={"taskId": task_id})
if resp.status_code >= 400:
raise Five44Error(f"查询授权任务状态失败:HTTP {resp.status_code}")
return self._check_ok(resp, path)
def save_project(self, data: dict) -> Dict[str, Any]:
"""保存项目信息(POST /exapi/system/setOtherInfo)。"""
path = "/exapi/system/setOtherInfo"
resp = self._request("POST", path, json_data=data)
if resp.status_code >= 400:
raise Five44Error(f"保存项目信息失败:HTTP {resp.status_code}")
return self._check_ok(resp, path)
def download_deploy_view(self) -> Tuple[bytes, str]:
"""导出部署视图(GET /exapi/system/exportDeployView)。"""
path = "/exapi/system/exportDeployView"
resp = self._request("GET", path)
if resp.status_code >= 400:
raise Five44Error(f"导出部署视图失败:HTTP {resp.status_code}")
return resp.content, self.download_name(resp, "deploymentView.xlsx")
def download_self_check(self) -> Tuple[bytes, str]:
"""导出自检文档(GET /exapi/system/exportServiceDetect)。"""
path = "/exapi/system/exportServiceDetect"
resp = self._request("GET", path)
if resp.status_code >= 400:
raise Five44Error(f"导出自检文档失败:HTTP {resp.status_code}")
return resp.content, self._download_name(resp, "checkList.xlsx")
@staticmethod
def download_name(resp: requests.Response, default: str) -> str:
"""从 Content-Disposition 解析文件名,解析失败用默认名。"""
import re
disposition = resp.headers.get("Content-Disposition") or ""
match = re.search(
r"filename\*\s*=\s*UTF-8''([^;]+)|filename\s*=\s*\"?([^\";\s]+)\"?",
disposition,
)
if match:
name = (match.group(1) or match.group(2) or "").strip()
if name:
return name
return default
\ No newline at end of file
......@@ -6,7 +6,9 @@ service_manage.py — 服务管理模块业务层
1. 目标配置:管理员维护对接的目标服务器(如 5.44 统一管理平台)
凭据:密码 Fernet 加密存储(复用 service_monitor.utils.crypto)
2. 项目信息:保存/读取授权项目信息
3. 授权文件:上传/下载/状态查询(本地暂存模式)
3. 授权文件:上传/下载/状态查询(代理 5.44 或本地占位)
4. 5.44 代理模式:配置了目标后,下载/上传/任务状态/项目信息全部转发
到目标服务器(阶段二),无目标时降级为本地占位(阶段一,仅开发用)
与 service_monitor 的目标完全独立(两套数据、两套存储),互不干扰。
"""
......@@ -28,6 +30,8 @@ from utils.paths import (
MANAGE_UPLOADS_DIR,
)
from services.five44_client import Five44Client, Five44Error
logger = logging.getLogger("service_manage")
# 校验白名单
......@@ -35,6 +39,17 @@ _HOST_RE = re.compile(r"^[A-Za-z0-9.\-]{1,255}$")
_NAME_RE = re.compile(r"^.{1,64}$")
def _validate_port(port) -> int:
"""校验端口为 1-65535 的整数,非法抛 ValueError。"""
try:
port = int(port)
except (TypeError, ValueError):
raise ValueError("端口必须为 1-65535 的数字") from None
if port < 1 or port > 65535:
raise ValueError("端口必须为 1-65535 的数字")
return port
# ============================================================
# 密码加密工具(复用 service_monitor 的 crypto)
# ============================================================
......@@ -156,10 +171,8 @@ def create_target(data: dict) -> dict:
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not name or not _NAME_RE.match(name):
raise ValueError("目标名称不能为空(最长 64 字符)")
if not host or not _HOST_RE.match(host):
raise ValueError("主机地址非法")
if not name or not _NAME_RE.match(name) or not host or not _HOST_RE.match(host):
raise ValueError("目标名称/主机地址非法")
if protocol not in ("http", "https"):
raise ValueError("协议必须为 http 或 https")
if auth_type not in ("password", "token", "none"):
......@@ -169,12 +182,7 @@ def create_target(data: dict) -> dict:
if auth_type == "password" and not password:
raise ValueError("密码不能为空")
try:
port = int(port)
if port < 1 or port > 65535:
raise ValueError
except (TypeError, ValueError):
raise ValueError("端口必须为 1-65535 的数字")
_validate_port(port)
now = _now()
target = {
......@@ -216,10 +224,7 @@ def update_target(target_id: str, data: dict) -> dict:
raise ValueError("主机地址非法")
target["host"] = host
if "port" in data:
try:
target["port"] = int(data["port"])
except (TypeError, ValueError):
raise ValueError("端口必须为数字")
_validate_port(data["port"])
if "protocol" in data:
if data["protocol"] not in ("http", "https"):
raise ValueError("协议必须为 http 或 https")
......@@ -322,11 +327,57 @@ def get_project() -> Optional[dict]:
# ============================================================
# 授权文件操作(本地暂存模式)
# 5.44 代理客户端
# ============================================================
def _get_proxy_target() -> Optional[dict]:
"""获取用于授权代理的目标服务器(第一个配置的目标,无则 None)。"""
targets = _load_targets()
if not targets:
return None
t = targets[0]
if not t.get("host") or not t.get("username") or not t.get("password_enc"):
return None
return t
def get_five44_client() -> Optional[Five44Client]:
"""构建 5.44 客户端;未配置目标返回 None(降级本地占位)。"""
t = _get_proxy_target()
if not t:
return None
try:
password = decrypt_password(t["password_enc"])
except Exception as e:
logger.error("解密目标服务器密码失败: %s", e)
return None
return Five44Client(
base_url=f"{t.get('protocol', 'https')}://{t['host']}:{t.get('port', 443)}",
username=t["username"],
password=password,
verify_ssl=False,
)
def _download_remote(client: Five44Client, getter) -> tuple:
"""调用 5.44 下载接口并落盘到 uploads 目录。
Returns: (本地文件路径, 原始文件名)
"""
content, filename = getter()
_ensure_dirs()
filepath = MANAGE_UPLOADS_DIR / filename
filepath.write_bytes(content)
logger.info("从 5.44 下载文件: %s (%d bytes)", filename, len(content))
return filepath, filename
# ============================================================
# 授权文件操作(代理 5.44 / 本地占位)
# ============================================================
def _make_placeholder_file(filename: str, content: str = "占位文件") -> Path:
"""生成占位文件(阶段一:实际文件待对接目标服务器后替换)。"""
"""生成占位文件(阶段一降级:未配置目标服务器时使用)。"""
_ensure_dirs()
filepath = MANAGE_UPLOADS_DIR / filename
filepath.write_text(content, encoding="utf-8")
......@@ -334,12 +385,22 @@ def _make_placeholder_file(filename: str, content: str = "占位文件") -> Path
def get_activation_file() -> Path:
"""获取激活文件(本地生成占位文件)。"""
"""获取激活文件(代理 5.44;无目标时本地生成占位文件)。"""
client = get_five44_client()
if client is not None:
filepath, _ = _download_remote(client, client.download_activation)
return filepath
logger.warning("未配置目标服务器,生成占位激活文件")
return _make_placeholder_file("licences.zip", "激活文件占位内容")
def get_deploy_view_file() -> Path:
"""获取部署视图文件(本地生成占位文件)。"""
"""获取部署视图文件(代理 5.44;无目标时本地生成占位文件)。"""
client = get_five44_client()
if client is not None:
filepath, _ = _download_remote(client, client.download_deploy_view)
return filepath
logger.warning("未配置目标服务器,生成占位部署视图")
ts = datetime.now().strftime("%Y%m%d%H%M%S")
return _make_placeholder_file(
f"deploymentView{ts}.xlsx",
......@@ -348,7 +409,12 @@ def get_deploy_view_file() -> Path:
def get_self_check_file() -> Path:
"""获取自检文档文件(本地生成占位文件)。"""
"""获取自检文档文件(代理 5.44;无目标时本地生成占位文件)。"""
client = get_five44_client()
if client is not None:
filepath, _ = _download_remote(client, client.download_self_check)
return filepath
logger.warning("未配置目标服务器,生成占位自检文档")
ts = datetime.now().strftime("%Y%m%d%H%M%S")
return _make_placeholder_file(
f"checkList{ts}.xlsx",
......@@ -356,8 +422,109 @@ def get_self_check_file() -> Path:
)
def upload_license_file(file_storage) -> dict:
"""上传授权文件到 5.44(无目标时降级本地暂存)。
Returns: {"success", "taskId", "status", "message"}
"""
client = get_five44_client()
if client is None:
result = save_uploaded_file(file_storage, subdir="license")
logger.warning("未配置目标服务器,授权文件本地暂存")
# 与前端轮询约定兼容:本地模式状态立即 completed
return {
"success": True,
"taskId": f"local-{result['filename']}",
"status": "completed",
"message": "授权文件已暂存(未对接目标服务器)",
}
filename = file_storage.filename or f"upload_{uuid.uuid4().hex[:8]}"
file_bytes = file_storage.read()
data = client.upload_license(file_bytes, filename)
task_id = (
data.get("taskId")
or data.get("task_id")
or (data.get("data") or {}).get("taskId")
or ""
)
return {
"success": True,
"taskId": task_id or f"remote-{filename}",
"status": "pending",
"message": data.get("msg") or data.get("message") or "授权文件已上传,处理中",
}
def check_license_task(task_id: str) -> dict:
"""查询授权任务状态(代理 5.44;本地 taskId 直接返回完成)。
Returns: {"success", "status", "message"}
"""
if not task_id or task_id.startswith("local-"):
return {"success": True, "status": "completed", "message": "授权处理完成"}
client = get_five44_client()
if client is None:
return {"success": True, "status": "completed", "message": "授权处理完成"}
_task_id = task_id.removeprefix("remote-")
data = client.check_task_status(_task_id) if _task_id else client.check_task_status(task_id)
# 服务端状态字段可能为:status / taskStatus / state
status = (
data.get("status")
or data.get("taskStatus")
or (data.get("data") or {}).get("status")
or "pending"
)
return {
"success": True,
"status": str(status).lower(),
"message": data.get("msg") or data.get("message") or "",
}
def save_project(data: dict) -> dict:
"""保存项目信息(代理 5.44;成功/失败均返回)。
5.44 保存成功后仍写本地 projects.json(供页面回填 + 脱敏读取),
密码字段已加密存储。若 5.44 不可达则抛 Five44Error 由路由层返回 502。
"""
client = get_five44_client()
if client is not None:
# 代理到 5.44(密码字段也随请求发送,5.44 侧自行处理)
client.save_project(dict(data))
_ensure_dirs()
project = {
"salesPeople": (data.get("salesPeople") or "").strip(),
"deployer": (data.get("deployer") or "").strip(),
"orderTime": (data.get("orderTime") or "").strip(),
"serverPassword": encrypt_password(data.get("serverPassword") or ""),
"projectOverview": (data.get("projectOverview") or "").strip(),
"updated_at": _now(),
}
MANAGE_PROJECTS_FILE.write_text(
json.dumps(project, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info("保存项目信息")
return {"success": True, "message": "项目信息保存成功"}
def get_project() -> Optional[dict]:
"""读取项目信息(脱敏:密码→has_password 布尔)。"""
_ensure_dirs()
if not MANAGE_PROJECTS_FILE.exists():
return None
try:
data = json.loads(MANAGE_PROJECTS_FILE.read_text(encoding="utf-8"))
data["has_password"] = bool(data.get("serverPassword"))
data.pop("serverPassword", None)
return data
except (json.JSONDecodeError, OSError):
return None
def save_uploaded_file(file_storage, subdir: str = "") -> dict:
"""保存上传文件到 uploads 目录。
"""保存上传文件到 uploads 目录(未配置目标服务器时的降级暂存)
Args:
file_storage: Flask request.files 中的文件对象
......@@ -383,4 +550,27 @@ def save_uploaded_file(file_storage, subdir: str = "") -> dict:
"filename": filename,
"path": str(filepath.relative_to(SERVICE_MANAGE_DATA_DIR)),
"size": size,
}
\ No newline at end of file
}
def _upload_to_target(file_storage, endpoint: str) -> dict:
"""将上传文件转发到 5.44 指定导出/上传接口(部署视图/自检文档)。
Args:
file_storage: Flask request.files 中的文件对象
endpoint: Five44Client 方法名("upload_deploy_view" / "upload_self_check")
Returns:
{"success": True, "message": str}
"""
client = get_five44_client()
if client is None:
result = save_uploaded_file(file_storage, subdir=endpoint)
logger.warning("未配置目标服务器,%s 文件本地暂存", endpoint)
return {"success": True, "message": "文件已暂存(未对接目标服务器)", "file": result}
filename = file_storage.filename or f"upload_{uuid.uuid4().hex[:8]}"
file_bytes = file_storage.read()
method = getattr(client, endpoint)
data = method(file_bytes, filename)
msg = data.get("msg") or data.get("message") or "上传成功"
return {"success": True, "message": msg}
\ No newline at end of file
......@@ -55,12 +55,12 @@
<div class="step-number">2</div>
<div class="step-title">填写项目信息</div>
<form id="project-form" onsubmit="handleSaveProject(event)">
<div class="form-group"><label class="form-label">项目销售 <span class="required">*</span></label><input type="text" class="form-input" placeholder="请输入项目的销售" required></div>
<div class="form-group"><label class="form-label">部署人员 <span class="required">*</span></label><input type="text" class="form-input" placeholder="请输入项目部署或更新人员" required></div>
<div class="form-group"><label class="form-label">下单时间 <span class="required">*</span></label><input type="datetime-local" class="form-input" required></div>
<div class="form-group"><label class="form-label">服务器密码 <span class="required">*</span></label><input type="password" class="form-input" placeholder="请输入服务器密码" required></div>
<div class="form-group"><label class="form-label">项目概述 <span class="required">*</span></label><textarea class="form-input" placeholder="请输入项目概述" required></textarea></div>
<button type="submit" class="btn-save">💾 保存</button>
<div class="form-group"><label class="form-label">项目销售 <span class="required">*</span></label><input type="text" id="pj-sales" class="form-input" placeholder="请输入项目的销售" required></div>
<div class="form-group"><label class="form-label">部署人员 <span class="required">*</span></label><input type="text" id="pj-deployer" class="form-input" placeholder="请输入项目部署或更新人员" required></div>
<div class="form-group"><label class="form-label">下单时间 <span class="required">*</span></label><input type="datetime-local" id="pj-order-time" class="form-input" required></div>
<div class="form-group"><label class="form-label">服务器密码 <span class="required">*</span></label><input type="password" id="pj-password" class="form-input" placeholder="请输入服务器密码" required></div>
<div class="form-group"><label class="form-label">项目概述 <span class="required">*</span></label><textarea id="pj-overview" class="form-input" placeholder="请输入项目概述" required></textarea></div>
<button type="submit" id="pj-save" class="btn-save">💾 保存</button>
</form>
</div>
<div class="step-card">
......@@ -91,10 +91,200 @@
{% endblock %}
{% block extra_js %}
<script>
function handleDownload(type) { alert("下载功能开发中,敬请期待"); }
function handleAuthUpload(input) { if (input.files.length > 0) alert("授权文件上传功能开发中,敬请期待"); }
function handleDeployUpload(input) { if (input.files.length > 0) alert("部署视图上传功能开发中,敬请期待"); }
function handleCheckUpload(input) { if (input.files.length > 0) alert("自检文档上传功能开发中,敬请期待"); }
function handleSaveProject(e) { e.preventDefault(); alert("项目信息保存功能开发中,敬请期待"); }
// ============================================================
// 通用工具
// ============================================================
function _errMsg(d) {
return (d && d.error && d.error.message) ? d.error.message : '请求失败';
}
function _readFile(f) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(reader.result); };
reader.onerror = function() { reject(new Error('文件读取失败')); };
reader.readAsDataURL(f);
});
}
function _download(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename || 'download';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function _downloadBlob(data, filename) {
var blob = new Blob([data], {type: 'application/octet-stream'});
var url = URL.createObjectURL(blob);
_download(url, filename);
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
}
// ============================================================
// 步骤 1:申请服务授权
// ============================================================
function handleDownload(type) {
var urlMap = {
'activation': '/api/service-manage/license/download',
'deploy-view': '/api/service-manage/deploy-view/download',
'self-check': '/api/service-manage/self-check/download'
};
var url = urlMap[type];
if (!url) return;
fetch(url, {credentials: 'include'})
.then(function(resp) {
if (resp.status === 401 || resp.status === 403) {
resp.json().then(function(d) {
window.location.href = '/login';
});
throw new Error('未登录或权限不足');
}
if (!resp.ok) {
return resp.json().then(function(d) { throw new Error(_errMsg(d)); });
}
var disposition = resp.headers.get('Content-Disposition') || '';
var match = /filename[*]=UTF-8''([^;]+)|filename="?([^";]+)"?/.exec(disposition);
var name = null;
if (match) name = decodeURIComponent((match[1] || match[2] || '').replace(/\+/g, ' '));
return resp.blob().then(function(blob) {
_downloadBlob(blob, name || 'download');
});
})
.then(function() {
showToast('文件下载完成', 'success');
})
.catch(function(e) {
if (e.message !== '未登录或权限不足') showToast('下载失败:' + e.message, 'error');
});
}
function handleAuthUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
_uploadFile('/api/service-manage/license/upload', file, '授权文件上传成功', 'handleAuthUpload', input);
}
// ============================================================
// 通用上传
// ============================================================
function _uploadFile(url, file, okMsg, fnName, input) {
var form = new FormData();
form.append('file', file);
fetch(url, {method: 'POST', body: form, credentials: 'include'})
.then(function(resp) {
return resp.json().then(function(d) {
if (!resp.ok) throw new Error(_errMsg(d));
return d;
});
})
.then(function(d) {
if (d.success) {
showToast(okMsg, 'success');
} else {
throw new Error(d.message || '上传失败');
}
})
.catch(function(e) {
showToast('上传失败:' + e.message, 'error');
})
.finally(function() {
if (input) input.value = '';
});
}
// ============================================================
// 步骤 2:项目信息
// ============================================================
function handleSaveProject(e) {
e.preventDefault();
var btn = document.getElementById('pj-save');
btn.disabled = true;
btn.textContent = '保存中...';
var data = {
salesPeople: document.getElementById('pj-sales').value.trim(),
deployer: document.getElementById('pj-deployer').value.trim(),
orderTime: document.getElementById('pj-order-time').value,
serverPassword: document.getElementById('pj-password').value,
projectOverview: document.getElementById('pj-overview').value.trim()
};
fetch('/api/service-manage/project', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'include',
body: JSON.stringify(data)
})
.then(function(resp) {
return resp.json().then(function(d) {
if (!resp.ok) throw new Error(_errMsg(d));
return d;
});
})
.then(function(d) {
if (d.success) {
showToast('项目信息保存成功', 'success');
document.getElementById('pj-password').value = '';
} else {
throw new Error(d.message || '保存失败');
}
})
.catch(function(e) {
showToast('保存失败:' + e.message, 'error');
})
.finally(function() {
btn.disabled = false;
btn.textContent = '💾 保存';
});
}
function loadProject() {
fetch('/api/service-manage/project', {credentials: 'include'})
.then(function(resp) { return resp.json(); })
.then(function(d) {
if (!d.success || !d.project) return;
var p = d.project;
document.getElementById('pj-sales').value = p.salesPeople || '';
document.getElementById('pj-deployer').value = p.deployer || '';
var t = p.orderTime || '';
// ISO 秒级时间转 datetime-local 输入格式
if (t.length >= 16) {
document.getElementById('pj-order-time').value = t.slice(0, 16);
} else {
document.getElementById('pj-order-time').value = t;
}
document.getElementById('pj-overview').value = p.projectOverview || '';
if (p.has_password) {
document.getElementById('pj-password').placeholder = '服务器密码已保存(留空则不修改)';
document.getElementById('pj-password').removeAttribute('required');
} else {
document.getElementById('pj-password').placeholder = '请输入服务器密码';
document.getElementById('pj-password').setAttribute('required', 'required');
}
})
.catch(function() {});
}
// ============================================================
// 步骤 3 / 4:部署视图 / 自检文档上传
// ============================================================
function handleDeployUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
_uploadFile('/api/service-manage/deploy-view/upload', file, '部署视图上传成功', 'handleDeployUpload', input);
}
function handleCheckUpload(input) {
var file = input.files && input.files[0];
if (!file) return;
_uploadFile('/api/service-manage/self-check/upload', file, '自检文档上传成功', 'handleCheckUpload', input);
}
// 页面加载时回填已保存的项目信息
document.addEventListener('DOMContentLoaded', loadProject);
</script>
{% endblock %}
\ No newline at end of file
......@@ -186,6 +186,37 @@
font-size: 12px;
}
/* ===== Toast 通知 ===== */
#toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 360px;
}
.toast {
background: #fff;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
border-left: 4px solid var(--primary);
font-size: 14px;
color: var(--gray-900);
opacity: 0;
transform: translateX(20px);
transition: opacity 0.3s, transform 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.toast.show { opacity: 1; transform: translateX(0); }
.toast.toast-success { border-left-color: var(--green); }
.toast.toast-error { border-left-color: var(--red); }
.toast.toast-warning { border-left-color: var(--yellow); }
/* ===== 移动端响应式 ===== */
@media screen and (max-width: 768px) {
.sidebar {
......@@ -253,6 +284,9 @@
{% block content %}{% endblock %}
</main>
<!-- Toast 容器 -->
<div id="toast-container"></div>
<div class="footer">运行维护平台 · 服务管理模块</div>
<script>
......@@ -281,6 +315,24 @@
} catch (e) {}
window.location.href = '/login';
}
// Toast 通知
function showToast(message, type) {
type = type || 'success';
var container = document.getElementById('toast-container');
var toast = document.createElement('div');
toast.className = 'toast toast-' + type;
var icons = {success: '✅', error: '❌', warning: '⚠️'};
toast.innerHTML = (icons[type] || 'ℹ️') + ' ' + message;
container.appendChild(toast);
requestAnimationFrame(function() {
toast.classList.add('show');
});
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
}
</script>
{% block extra_js %}{% endblock %}
</body>
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论