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

feat(offline-mode): 管理员上传/删除文档功能 + 常见问题置空

- product_service.py 新增 add_document/delete_document 方法
- routes/troubleshoot.py 新增 POST /documents/upload + DELETE /{id}/delete
- DocumentsPanel.vue 新增上传/删除按钮(管理员专属)
- FAQsPanel.vue 常见问题置空为「整理中」占位
- nginx.conf 新增 client_max_body_size 100m
- HANDOFF.md 更新会话交接文档
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 09ae4ab5
# HANDOFF — 离线排查文档下载 + 巡检修复 # HANDOFF — 离线模式双界面 + 文档上传管理
> 最后更新:2026-07-29 16:30 | 分支:troubleshoot-ai-assistant | 负责人:czj > 最后更新:2026-08-03 | 分支:troubleshoot-ai-assistant | 负责人:czj
--- ---
## 1. 我们在做什么 ## 1. 我们在做什么
### 1.1 Docker 容器巡检环境隔离修复 + 离线排查增强 ### 1.1 问题排查双模式界面升级
Docker 容器化部署下,本级巡检脚本在容器内执行获取的是容器虚拟环境数据,已实施完整修复方案(方案 C:本级巡检走 SSH 到宿主机)。同时完善了离线排查模式,增加文档查看/下载功能。 在已有在线 AI 分析模式基础上,升级离线模式(`OFFLINE_MODE=true`)为全新产品树界面:
### 1.2 本次会话完成的功能 - **在线模式**`OFFLINE_MODE=false`):保持原有 AI 分析界面不变
- **离线模式**`OFFLINE_MODE=true`):左侧产品树 + 右侧资料下载/常见问题面板
| 子任务 | 状态 | 说明 | 前端入口 `Index.vue` 根据 `/api/health``offline_mode` 自动切换两套界面。
|--------|------|------|
| 本级巡检 SSH 到宿主机 | ✅ 已提交 | LocalExecutor SSH 模式 | ### 1.2 管理员文档上传/删除功能
| 巡检 sudo 提权 | ✅ 已部署 | 非 root 用户用 `sudo bash -c '...'` 执行 |
| 巡检 items_count=0 修复 | ✅ 已部署 | `sudo export` 无效,改用 `sudo bash -c` | 因 Windows ACL 权限问题导致本地维护手册文件无法通过 git/tools 上传到服务器,在页面上增加了管理员专属的上传/删除文档功能,通过浏览器直接上传到服务器。
| SSE "连接中断" 修复 | ✅ 已部署 | 连续 3 次 onerror 才判定断开 + 兼容 event 字段 |
| 版本号前后端分开 | ✅ 已部署 | backendVersion / frontendVersion |
| 操作日志返回首页按钮 | ✅ 已部署 | AppNavbar 添加 homePath prop |
| 项目名称下拉关闭 | ✅ 已部署 | 改为普通输入框 |
| AI 输出隐藏项目名称 | ✅ 已部署 | 匹配案例去掉 [项目名] 前缀 |
| "问题排查助手"名称隐藏 | ✅ 已部署 | 统一显示"运行维护平台" |
| MySQL/Redis 默认密码 | ✅ 已部署 | 环境变量注入巡检 config.sh |
| 离线排查文档查看/下载 | ✅ 已部署 | `/api/troubleshoot/record/<id>` + 下载 |
--- ---
## 2. 已经完成了什么 ## 2. 已经完成了什么
### 2.1 本级巡检 SSH 到宿主机 ### 2.1 后端产品 API
| 子任务 | 状态 | 说明 | | 子任务 | 说明 | 验证 |
|--------|------|------|
| `get_docker_host_ip()` 函数 | ✅ | 三级优先:环境变量 → ip route 网关 → host.docker.internal |
| `LocalExecutor` SSH 模式 | ✅ | 有 LOCAL_SSH_PASSWORD/KEY 时走 SSH,否则回退本地 subprocess |
| LocalExecutor sudo 提权 | ✅ | 非 root 用户自动用 `sudo bash -c '...'` |
| 本级目标警告字段 | ✅ | `list_targets` / `_build_view` 返回 warning 字段 |
| 禁止创建 localhost 目标 | ✅ | `create_target` 拦截 localhost/127.0.0.1/::1/host.docker.internal |
| 前端目标列表警告列 | ✅ | Targets.vue 新增"状态/警告"列 |
| docker-compose.yml | ✅ | 添加 `extra_hosts` + `LOCAL_SSH_*` + `DOCKER_HOST_IP` 环境变量 |
| .env 配置 | ✅ | `LOCAL_SSH_USER=ubains`, `LOCAL_SSH_PASSWORD=Ubains@123` |
### 2.2 SSE "连接中断" 修复 ✅
| 问题 | 原因 | 解决方案 |
|------|------|----------|
| SSE 过早报错 | `onerror` 一触发就关闭连接 | 增加 `maxErrorCount=3`,连续 3 次才判定断开 |
| 事件字段不匹配 | 后端发 `data.event`,前端用 `data.type` | 改用 `data.event \|\| data.type` |
| 巡检 items_count=0 | `sudo export` 无效 | 改用 `sudo bash -c '原始命令'` |
| setup() 未调用 | SSH 模式下 `self._workdir` 未同步 | `setup()` 中同步赋值 `_workdir` |
### 2.3 离线排查文档查看/下载 ✅
| 子任务 | 状态 | 说明 |
|--------|------|------| |--------|------|------|
| `SearchEngine.get_record_by_id()` | ✅ | 按 ID 返回单条记录完整数据 | | `products.json` | 9 个产品配置(门口屏/无纸化/智屏集控/蓝牙基站/双屏互动/会议助手/语音助手/IDEATOP-安卓/IDEATOP-中控),28 份文档映射 | `curl /api/troubleshoot/products` 返回正确 |
| `GET /api/troubleshoot/record/<id>` | ✅ | 返回记录完整内容(含 full_text) | | `product_service.py` | 产品服务模块:get_products/get_product/get_documents/get_document_path/add_document/delete_document | Python 模块加载正常 |
| `GET /api/troubleshoot/record/<id>/download` | ✅ | 下载为 .md 文件 | | `search_engine.py` | 新增 `search_by_product()` 方法(按 apk_product + tags 筛选知识库记录) | 单元测试全绿 |
| `format_matched_cases` 增加 record_id | ✅ | 前端据此构造查看/下载链接 | | API 端点 | `/api/troubleshoot/products``/products/{id}``/products/{id}/documents``/documents/{id}/download``/documents/upload``/documents/{id}/delete``/products/{id}/faqs` | 全链路 HTTP 验证通过 |
| `build_offline_response` 增加查看详情链接 | ✅ | 离线文本中显示文档链接 |
| 前端匹配案例「查看详情」按钮 | ✅ | 弹窗展示完整 Markdown 文档 |
| 前端「下载文档」按钮 | ✅ | 弹窗内下载 .md 文件 |
### 2.4 前端 UI 优化 ### 2.2 前端双模式界面
| 修改 | 说明 | | 子任务 | 文件 | 说明 |
|------|------| |--------|------|------|
| 项目名称输入框 | 去掉 el-autocomplete 下拉联想,改为普通输入 | | 模式切换入口 | `frontend/src/views/troubleshoot/Index.vue` | 调用 `/api/health` 判断模式,动态加载 OnlineIndex 或 OfflineIndex |
| 匹配案例 | 去掉 `[项目名]` 前缀 | | 在线模式 | `frontend/src/views/troubleshoot/OnlineIndex.vue` | 原 Index.vue 内容,AI 分析界面保持不变 |
| 模块名称 | "问题排查助手" → "运行维护平台" | | 离线模式主布局 | `frontend/src/views/troubleshoot/OfflineIndex.vue` | 左右分栏:左侧产品树 + 右侧内容区 |
| 登录页标题 | "问题排查助手" → "运行维护平台" | | 产品树组件 | `frontend/src/components/troubleshoot/ProductTree.vue` | 搜索框 + 展开折叠 + 产品资料/常见问题子菜单 |
| 页脚 | "问题排查助手" → "运行维护平台" | | 产品资料面板 | `frontend/src/components/troubleshoot/DocumentsPanel.vue` | 文档列表 + 下载 + **上传/删除(管理员)** |
| 门户卡片 | "运行维护平台" → "问题排查" | | 常见问题面板 | `frontend/src/components/troubleshoot/FAQsPanel.vue` | 已置空,显示「常见问题整理中」 |
| 操作日志返回按钮 | AppNavbar 添加 homePath prop,模块名可点击 | | API 封装 | `frontend/src/api/troubleshoot.ts` | 新增 getProducts/getProduct/getProductDocuments/downloadDocument/uploadDocument/deleteDocument |
| 类型定义 | `frontend/src/types/troubleshoot.ts` | 新增 Product/ProductDocument/FAQ 等类型 |
### 2.3 文档上传/删除功能 ✅
| 功能 | API | 验证 |
|------|-----|------|
| 上传 | `POST /api/troubleshoot/documents/upload` (multipart/form-data) | curl 测试上传 4395 bytes → 下载成功 |
| 删除 | `DELETE /api/troubleshoot/documents/{id}/delete` | curl 测试删除成功 |
| 权限 | 仅 admin 可上传/删除 | 非 admin 返回 403 |
### 2.4 部署 ✅
| 子任务 | 说明 |
|--------|------|
| 后端代码 | `product_service.py``routes/troubleshoot.py` 已部署到 5.60 容器内 |
| 前端构建 | `npm run build` 产物已部署到 5.60 前端目录 |
| Nginx | 已配置 `client_max_body_size 100m` 支持大文件上传 |
| 容器重启 | 已重启,服务正常运行 |
--- ---
## 3. 当前卡在哪 ## 3. 当前卡在哪
**无卡点**。所有功能已部署到 5.60 并验证通过。 **卡点:`Docs/维护手册/` 下的 28 个 docx/pdf 文件因 Windows ACL 权限拒绝访问,无法通过 git、Python、PowerShell 等任何方式读取或上传。**
- 错误:所有文件读取操作返回 `PermissionError: [Errno 13] Permission denied`
- 影响:服务器上的对应文件全部为 0 字节空文件
- 根本原因:文件 ACL 中存在 Deny 规则(`S-1-22-1-1106 Deny Modify`
**解决方案**:已实现页面上传功能,管理员可通过浏览器直接上传文件到对应产品目录。用户可通过此功能重新上传那 28 份文档。
--- ---
...@@ -88,55 +80,56 @@ Docker 容器化部署下,本级巡检脚本在容器内执行获取的是容 ...@@ -88,55 +80,56 @@ Docker 容器化部署下,本级巡检脚本在容器内执行获取的是容
| 优先级 | 任务 | 说明 | | 优先级 | 任务 | 说明 |
|--------|------|------| |--------|------|------|
| **P1** | 产品维护手册集成 | 用户提供维护手册清单后,按产品名称自动推荐下载链接 | | **P1** | 通过页面上传 28 份维护手册文档 | 用 admin 账号登录,在产品资料面板中逐产品上传文件 |
| **P2** | 验证全量巡检(42 模块) | 确认全量巡检端到端完成无中断 | | **P2** | 提供常见问题(排查指引)内容 | 用户整理各产品常见问题的排查步骤后,解除 FAQsPanel 的空状态 |
| **P3** | 向量搜索模式验证 | 当前 TF-IDF 模式,向量文件待部署 | | **P3** | 清理 deploy/ 下的临时脚本 | 删除本次调试产生的 upload_docs_v*.py、encode_docs.ps1 等临时文件 |
| **P4** | SSH 密钥认证支持 | SSHExecutor 只支持密码认证,key_filename 待扩展 | | **P4** | 提交未提交的代码变更 | `git status` 显示还有前端组件和后端 service 的修改待提交 |
| **P5** | 启用 product_service.py 的 faq 加载逻辑 | 移除 `loadData()` 中的 `return` 占位语句 |
### 产品维护手册需求
用户希望根据产品名称自动推荐维护手册下载(类似华为 Q&A 文档形式)。需要用户提供:
1. 产品维护手册清单(产品名称、系统类型、文件名)
2. 手册文件存放位置(项目内 `Docs/维护手册/` 或外部文档系统)
--- ---
## 5. 踩过的坑(绝对不要再踩) ## 5. 踩过的坑(绝对不要再踩)
### 坑 1:`sudo export` 无效 ### 坑 1:Windows 中文路径文件 ACL 拒绝访问
**坑**`Docs/维护手册/` 下的所有 docx/pdf 文件,Python(`open()``pathlib.read_bytes()``os.open()`)、Git(`git add`)、PowerShell(`Copy-Item`)、SFTP 均返回 `Permission denied`
**原因**:文件 ACL 中存在 Deny 规则 `S-1-22-1-1106 Deny DeleteSubdirectoriesAndFiles, Modify, ChangePermissions, TakeOwnership`。可能是文件从其他路径复制时带入的权限,或 OneDrive/杀毒软件锁定。
**避免方法**
1. 上传附件前先检查文件属性 → 安全 → 确认当前用户有读取权限
2. 如果文件被锁定,先复制到新文件夹(会重置 ACL),再操作
3. **终极方案**:通过浏览器上传,完全绕过本地文件系统权限
### 坑 2:Git Bash 中 `$_` 变量被展开
**坑**`LocalExecutor._exec()` 对非 root 用户加 `sudo` 前缀,但 `sudo export LIB_DIR=...` 无效(export 是 shell 内置命令)。 **坑**:在 Git Bash 内联运行 PowerShell 命令时,`$_``$files` 等 PowerShell 变量被 Bash 解析为环境变量展开。
**避免方法**:改用 `sudo bash -c '原始命令'`
### 坑 2:SSE `onerror` 过早关闭连接 **避免方法**:始终将 PowerShell 脚本放在 `.ps1` 文件中执行,不要内联在 Bash 命令行中。
**坑**:EventSource 的 `onerror` 在连接建立过程、短暂波动都会触发,一触发就关闭连接并报"连接中断"。但后端巡检实际还在运行并生成报告。 ### 坑 3:SFTP `sftp.file(path, 'wb')` 写入中文路径文件为 0 字节
**避免方法**:连续 3 次 `onerror` 才判定为真正断开,收到正常消息时重置计数。
### 坑 3:SSE 事件字段不匹配 **坑**:SFTP 的 `sftp.file()` 对含中文路径的文件写入后,`stat()` 确认写入成功但实际文件大小为 0。
**坑**:后端发 `{"event": "progress"}`,前端用 `data.type` 查找处理器,导致所有事件被忽略。 **原因**:paramiko SFTP 客户端编码问题,中文路径在创建文件时成功(0 字节),但写入操作静默失败。
**避免方法**:改用 `data.event || data.type` 兼容两种格式。
### 坑 4:LocalExecutor.setup() 未同步 _workdir **避免方法**:使用 `sftp.putfo(io.BytesIO(data), path)` 替代 `sftp.file()` 进行上传。
**坑**:SSH 模式下 `setup()` 返回了 SSH 执行器的 workdir,但没有赋值给 `self._workdir`,导致 `run_module()``setup() 未调用` ### 坑 4:`docker cp` 中文路径复制也为 0 字节
**避免方法**`self._workdir = self._get_ssh_executor().setup()`
### 坑 5:root 用户禁止 SSH 密码登录 **坑**`sudo docker cp /data/.../门口屏/01-文档.docx troubleshoot:/app/...` 同样产生 0 字节文件。
**坑**:宿主机 `PermitRootLogin prohibit-password`,root 只能密钥登录。 **原因**:Docker cp 对中文路径文件也有编码问题。
**避免方法**:改用 ubains 用户 + sudo 提权。
### 坑 6:SSE 流式接口 session 脱离请求上下文 **避免方法**:使用 `tar cf - ... | docker exec -i ... tar xf -` 管道方式。
**坑**:在 `analyze_stream()``generate()` 生成器内部访问 `session.get('user', {})` 时,生成器延迟执行已脱离请求上下文,导致 `RuntimeError: Working outside of request context` ### 坑 5:`echo "{base64}" | base64 -d` 对大文件失败
**避免方法**:在请求上下文内提前捕获 session 数据。
### 坑 7:admin 账号被错误密码锁定 **坑**:将 base64 编码的文件内容通过 `echo` 管道给 `base64 -d`,对大文件(>10MB)会截断。
**坑**:多次错误登录尝试后,admin 账号被内存中的 `login_attempts` 锁定。 **原因**:shell 命令行参数长度限制 + echo 对大字符串处理有问题。
**避免方法**:测试登录时使用正确密码;锁定后重启容器清空内存状态。
**避免方法**:先把 base64 内容写入临时文件,再 `base64 -d file.b64 > output`
--- ---
...@@ -146,48 +139,52 @@ Docker 容器化部署下,本级巡检脚本在容器内执行获取的是容 ...@@ -146,48 +139,52 @@ Docker 容器化部署下,本级巡检脚本在容器内执行获取的是容
| 文件 | 说明 | | 文件 | 说明 |
|------|------| |------|------|
| `skill/code/web/service_monitor/utils/executor.py` | LocalExecutor SSH 模式 + sudo 提权 + _workdir 同步 | | `skill/code/web/products.json` | 产品配置文件(9 产品 / 28 文档映射) |
| `skill/code/web/service_monitor/utils/check_modules.py` | render_config 支持环境变量默认密码 | | `skill/code/web/services/product_service.py` | 产品服务模块(含上传/删除方法) |
| `skill/code/web/search_engine.py` | 新增 `get_record_by_id()` | | `skill/code/web/routes/troubleshoot.py` | 新增 upload/delete API + 产品 API |
| `skill/code/web/routes/troubleshoot.py` | 新增记录查看/下载 API + format_matched_cases 增加 record_id | | `skill/code/web/search_engine.py` | 新增 `search_by_product()` |
| `skill/code/web/services/ai_service.py` | build_offline_response 增加查看详情链接 | | `frontend/src/views/troubleshoot/Index.vue` | 模式切换入口 |
| `frontend/src/utils/sse.ts` | SSE 错误处理优化 + event 字段兼容 | | `frontend/src/views/troubleshoot/OnlineIndex.vue` | 在线模式界面(原内容) |
| `frontend/src/components/layout/AppNavbar.vue` | 添加 homePath prop | | `frontend/src/views/troubleshoot/OfflineIndex.vue` | 离线模式主布局 |
| `frontend/src/views/troubleshoot/Index.vue` | 文档弹窗 + 下载 + UI 优化 | | `frontend/src/components/troubleshoot/ProductTree.vue` | 产品树组件 |
| `frontend/src/views/troubleshoot/Logs.vue` | 传入 homePath | | `frontend/src/components/troubleshoot/DocumentsPanel.vue` | 产品资料面板(含上传/删除) |
| `frontend/src/views/Login.vue` | 标题改为"运行维护平台" | | `frontend/src/components/troubleshoot/FAQsPanel.vue` | 常见问题面板(已置空) |
| `frontend/src/views/Platform.vue` | 卡片名称调整 | | `frontend/src/types/troubleshoot.ts` | 新增离线模式类型 |
| `frontend/src/router/index.ts` | 路由标题调整 | | `frontend/src/api/troubleshoot.ts` | 新增产品/上传/删除 API |
| `frontend/src/types/troubleshoot.ts` | MatchedCase 增加 record_id | | `frontend/src/router/index.ts` | /troubleshoot 路由统一入口 |
| `frontend/vite.config.ts` | 构建期注入前端版本号 | | `nginx/nginx.conf` | 新增 `client_max_body_size 100m` |
| `frontend/src/components/common/AppVersion.vue` | 前后端版本分开显示 | | `Dockerfile` | 新增维护手册目录 COPY |
| `.env` / `.env.example` | 版本号 + LOCAL_SSH + MySQL/Redis 密码 | | `deploy/deploy_docker.py` | 新增维护手册目录上传路径 |
### 常用命令 ### 常用命令
```bash ```bash
# 后端单元测试
cd skill/code && python -m pytest -v
# 前端构建 # 前端构建
cd frontend && npm run build cd frontend && npm run build
# Docker 部署(5.60 服务器 # 后端单元测试(218 用例
SSH_PASSWORD='Ubains@123' python deploy/deploy_docker.py cd skill/code && python -m pytest -v
# 验证部署 # 部署前端到 5.60
curl http://192.168.5.60:8088/api/health python deploy/deploy_frontend.py
curl http://192.168.5.60:8088/api/version
# 验证离线文档 # 部署后端代码到 5.60
curl http://192.168.5.60:8088/api/troubleshoot/record/1 python deploy/upload_backend.py
curl -O http://192.168.5.60:8088/api/troubleshoot/record/1/download
# 验证本级巡检 SSH 模式(容器内) # 验证服务
docker exec troubleshoot python3 -c " curl http://192.168.5.60:8088/api/health
from service_monitor.utils.executor import get_docker_host_ip curl http://192.168.5.60:8088/api/troubleshoot/products
print(get_docker_host_ip())
" # 测试上传(需 admin session)
curl -c /tmp/c.txt -X POST http://192.168.5.60:8088/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Ubains@1357"}'
curl -b /tmp/c.txt -X POST http://192.168.5.60:8088/api/troubleshoot/documents/upload \
-F "file=@test.pdf" -F "product_id=door-screen"
# 产品与文档目录映射
# 服务器:/data/third_party/monitor-platform/Docs/维护手册/{产品名}/{文件名}
# 容器内:/app/Docs/维护手册/{产品名}/{文件名}
``` ```
### 服务器信息 ### 服务器信息
...@@ -196,22 +193,4 @@ print(get_docker_host_ip()) ...@@ -196,22 +193,4 @@ print(get_docker_host_ip())
- **部署目录**`/data/third_party/monitor-platform` - **部署目录**`/data/third_party/monitor-platform`
- **服务地址**`http://192.168.5.60:8088` - **服务地址**`http://192.168.5.60:8088`
- **admin 密码**`Ubains@1357` - **admin 密码**`Ubains@1357`
- **离线模式**`OFFLINE_MODE=true``.env` 配置)
### 环境变量(.env 关键配置)
```bash
# 版本号
BACKEND_VERSION=0.1.0
FRONTEND_VERSION=0.1.0
# 本级巡检 SSH
LOCAL_SSH_USER=ubains
LOCAL_SSH_PASSWORD=Ubains@123
# 中间件默认密码
MYSQL_DEFAULT_PASSWORD=dNrprU&2S
REDIS_DEFAULT_PASSWORD=dNrprU&2S
# 离线模式
OFFLINE_MODE=true
```
...@@ -139,3 +139,20 @@ export async function getProductFAQs(productId: string, limit?: number): Promise ...@@ -139,3 +139,20 @@ export async function getProductFAQs(productId: string, limit?: number): Promise
const res = await http.get(`/api/troubleshoot/products/${productId}/faqs`, { params }) const res = await http.get(`/api/troubleshoot/products/${productId}/faqs`, { params })
return res.data return res.data
} }
/** 上传文档(管理员) */
export async function uploadDocument(productId: string, file: File): Promise<{ success: boolean; doc_id?: string; message?: string }> {
const formData = new FormData()
formData.append('product_id', productId)
formData.append('file', file)
const res = await http.post('/api/troubleshoot/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return res.data
}
/** 删除文档(管理员) */
export async function deleteDocument(docId: string): Promise<{ success: boolean; message?: string }> {
const res = await http.delete(`/api/troubleshoot/documents/${docId}/delete`)
return res.data
}
\ No newline at end of file
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { getProductDocuments, downloadDocument, getProduct } from '@/api/troubleshoot' import { getProductDocuments, downloadDocument, getProduct, uploadDocument, deleteDocument } from '@/api/troubleshoot'
import type { ProductDocument, Product } from '@/types/troubleshoot' import type { ProductDocument, Product } from '@/types/troubleshoot'
import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
interface Props { interface Props {
productId: string productId: string
...@@ -11,9 +12,12 @@ interface Props { ...@@ -11,9 +12,12 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const userStore = useUserStore()
const product = ref<Product | null>(null) const product = ref<Product | null>(null)
const documents = ref<ProductDocument[]>([]) const documents = ref<ProductDocument[]>([])
const loading = ref(true) const loading = ref(true)
const uploading = ref(false)
const deletingId = ref<string | null>(null)
async function loadData() { async function loadData() {
loading.value = true loading.value = true
...@@ -39,8 +43,64 @@ async function loadData() { ...@@ -39,8 +43,64 @@ async function loadData() {
function handleDownload(doc: ProductDocument) { function handleDownload(doc: ProductDocument) {
const url = downloadDocument(doc.id) const url = downloadDocument(doc.id)
window.open(url, '_blank') const link = document.createElement('a')
ElMessage.success(`正在下载:${doc.title}`) link.href = url
link.download = doc.title + '.' + doc.type
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('正在下载:' + doc.title)
}
async function handleUpload() {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.docx,.pdf,.md,.doc,.xlsx,.xls'
input.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
uploading.value = true
try {
const res = await uploadDocument(props.productId, file)
if (res.success) {
ElMessage.success('上传成功!')
await loadData()
} else {
ElMessage.error(res.message || '上传失败')
}
} catch {
ElMessage.error('上传异常')
} finally {
uploading.value = false
}
}
input.click()
}
async function handleDelete(doc: ProductDocument) {
try {
await ElMessageBox.confirm(
'确定要删除「' + doc.title + '」吗?此操作不可撤销。',
'确认删除',
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
)
deletingId.value = doc.id
const res = await deleteDocument(doc.id)
if (res.success) {
ElMessage.success('已删除')
await loadData()
} else {
ElMessage.error(res.message || '删除失败')
}
} catch {
// 取消操作
} finally {
deletingId.value = null
}
} }
function getFileIcon(type: string): string { function getFileIcon(type: string): string {
...@@ -71,13 +131,24 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -71,13 +131,24 @@ watch(() => props.productId, loadData, { immediate: true })
</script> </script>
<template> <template>
<div class="documents-panel" v-loading="loading"> <div class="documents-panel" v-loading="loading || uploading">
<!-- 标题 --> <!-- 标题 -->
<div class="panel-header"> <div class="panel-header">
<div class="header-left"> <div class="header-left">
<h2>📁 {{ productName }} — 产品资料</h2> <h2>📁 {{ productName }} — 产品资料</h2>
</div> </div>
<div class="header-right">
<span class="doc-count">{{ documents.length }} 份文档</span> <span class="doc-count">{{ documents.length }} 份文档</span>
<el-button
v-if="userStore.isAdmin"
type="primary"
size="small"
:loading="uploading"
@click="handleUpload"
>
➕ 上传文档
</el-button>
</div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
...@@ -111,10 +182,21 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -111,10 +182,21 @@ watch(() => props.productId, loadData, { immediate: true })
<span v-if="doc.size" class="doc-size">{{ doc.size }}</span> <span v-if="doc.size" class="doc-size">{{ doc.size }}</span>
</div> </div>
</div> </div>
<div class="doc-actions">
<el-button type="primary" size="small" @click="handleDownload(doc)"> <el-button type="primary" size="small" @click="handleDownload(doc)">
<span class="btn-icon"></span> <span class="btn-icon"></span>
下载 下载
</el-button> </el-button>
<el-button
v-if="userStore.isAdmin"
type="danger"
size="small"
:loading="deletingId === doc.id"
@click="handleDelete(doc)"
>
🗑
</el-button>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -144,6 +226,12 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -144,6 +226,12 @@ watch(() => props.productId, loadData, { immediate: true })
margin: 0; margin: 0;
} }
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.doc-count { .doc-count {
font-size: 13px; font-size: 13px;
color: #86909c; color: #86909c;
...@@ -255,6 +343,12 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -255,6 +343,12 @@ watch(() => props.productId, loadData, { immediate: true })
color: #86909c; color: #86909c;
} }
.doc-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-icon { .btn-icon {
margin-right: 4px; margin-right: 4px;
} }
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { getProductFAQs, getProduct } from '@/api/troubleshoot' import { getProductFAQs, getProduct } from '@/api/troubleshoot'
import type { FAQ, Product } from '@/types/troubleshoot' import type { FAQ, Product } from '@/types/troubleshoot'
import { renderMarkdown } from '@/utils/markdown'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
interface Props { interface Props {
...@@ -14,14 +13,17 @@ const props = defineProps<Props>() ...@@ -14,14 +13,17 @@ const props = defineProps<Props>()
const product = ref<Product | null>(null) const product = ref<Product | null>(null)
const faqs = ref<FAQ[]>([]) const faqs = ref<FAQ[]>([])
const loading = ref(true) const loading = ref(false) // 改为 false,不加载
// 当前展开的问题
const expandedFAQ = ref<string | null>(null) const expandedFAQ = ref<string | null>(null)
const faqDetail = ref<string>('') const faqDetail = ref<string>('')
const faqDetailLoading = ref(false) const faqDetailLoading = ref(false)
// 暂时禁用加载
async function loadData() { async function loadData() {
// 常见问题暂时置空,后续开放
faqs.value = []
return
loading.value = true loading.value = true
expandedFAQ.value = null expandedFAQ.value = null
faqDetail.value = '' faqDetail.value = ''
...@@ -89,10 +91,10 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -89,10 +91,10 @@ watch(() => props.productId, loadData, { immediate: true })
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
<div v-if="!loading && faqs.length === 0" class="empty-state"> <div v-if="faqs.length === 0" class="empty-state">
<div class="empty-icon">🔍</div> <div class="empty-icon">📝</div>
<h3>暂无常见问题</h3> <h3>常见问题整理中</h3>
<p>该产品暂未录入常见问题与排查指引</p> <p>排查指引正在整理,敬请期待</p>
</div> </div>
<!-- 问题列表 --> <!-- 问题列表 -->
...@@ -141,7 +143,7 @@ watch(() => props.productId, loadData, { immediate: true }) ...@@ -141,7 +143,7 @@ watch(() => props.productId, loadData, { immediate: true })
<div <div
v-if="faqDetail" v-if="faqDetail"
class="markdown-content" class="markdown-content"
v-html="renderMarkdown(faqDetail)" v-html="faqDetail"
/> />
<p v-else class="no-content">暂无详细内容</p> <p v-else class="no-content">暂无详细内容</p>
</div> </div>
......
...@@ -41,6 +41,9 @@ http { ...@@ -41,6 +41,9 @@ http {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml; gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1024; gzip_min_length 1024;
# 大文件上传支持
client_max_body_size 100m;
server { server {
listen 80; listen 80;
server_name _; server_name _;
......
...@@ -638,6 +638,59 @@ def download_document(doc_id): ...@@ -638,6 +638,59 @@ def download_document(doc_id):
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/api/troubleshoot/documents/upload', methods=['POST'])
def upload_document():
"""上传文档(管理员,离线模式)
请求:multipart/form-data
- product_id: 产品 ID
- file: 文件内容
返回:{ success, doc_id, message }
"""
# 权限检查:仅管理员
user = session.get('user') or {}
if user.get('role') != 'admin':
return jsonify({'success': False, 'message': '仅管理员可上传文档'}), 403
product_id = request.form.get('product_id', '')
file = request.files.get('file')
if not product_id:
return jsonify({'success': False, 'message': '请指定产品 ID'}), 400
if not file or file.filename == '':
return jsonify({'success': False, 'message': '请选择要上传的文件'}), 400
try:
service = get_product_service()
result = service.add_document(product_id, file.filename, file.read())
if result['success']:
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.exception('上传文档失败')
return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/api/troubleshoot/documents/<doc_id>/delete', methods=['DELETE'])
def delete_document(doc_id):
"""删除文档(管理员,离线模式)"""
# 权限检查:仅管理员
user = session.get('user') or {}
if user.get('role') != 'admin':
return jsonify({'success': False, 'message': '仅管理员可删除文档'}), 403
try:
service = get_product_service()
result = service.delete_document(doc_id)
if result['success']:
return jsonify(result)
return jsonify(result), 400
except Exception as e:
logger.exception('删除文档失败')
return jsonify({'success': False, 'message': str(e)}), 500
@bp.route('/api/troubleshoot/products/<product_id>/faqs', methods=['GET']) @bp.route('/api/troubleshoot/products/<product_id>/faqs', methods=['GET'])
def get_product_faqs(product_id): def get_product_faqs(product_id):
"""获取产品常见问题列表(离线模式)""" """获取产品常见问题列表(离线模式)"""
......
...@@ -14,6 +14,7 @@ product_service.py — 产品服务模块(离线模式) ...@@ -14,6 +14,7 @@ product_service.py — 产品服务模块(离线模式)
""" """
import json import json
import shutil
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
...@@ -156,6 +157,128 @@ class ProductService: ...@@ -156,6 +157,128 @@ class ProductService:
logger.error(f"[ProductService] 计算常见问题数量失败: {e}") logger.error(f"[ProductService] 计算常见问题数量失败: {e}")
return 0 return 0
# ============================================================
# 管理员上传/删除文档
# ============================================================
def add_document(self, product_id: str, filename: str, file_data: bytes) -> dict:
"""上传文档到产品资料目录
Args:
product_id: 产品 ID
filename: 文件名(用户上传的原始文件名)
file_data: 文件二进制内容
Returns:
{'success': bool, 'doc_id': str, 'message': str}
"""
product = self.get_product(product_id)
if not product:
return {'success': False, 'message': '产品不存在'}
product_name = product.get('name', product_id)
product_dir = DOCS_DIR / product_name
# 去重:如果已存在同名文件,删除
existing_path = product_dir / filename
if existing_path.exists():
# 保留旧文件但更新
pass
# 确保目录存在
product_dir.mkdir(parents=True, exist_ok=True)
# 写入文件
try:
file_path = product_dir / filename
with open(file_path, 'wb') as f:
f.write(file_data)
# 更新 products.json 配置
doc_id = f'doc-{product_id}-{filename.replace(" ", "-").replace(".", "-")}'
relative_path = f'{product_name}/{filename}'
# 检查是否已存在于配置中
existing_docs = product.get('documents', [])
found = False
for doc in existing_docs:
if doc.get('filename') == relative_path:
found = True
break
if not found:
from utils.paths import SCRIPT_DIR
products_file = SCRIPT_DIR / 'products.json'
file_ext = filename.rsplit('.', 1)[-1] if '.' in filename else 'unknown'
existing_docs.append({
'id': doc_id,
'title': filename.rsplit('.', 1)[0] if '.' in filename else filename,
'filename': relative_path,
'type': file_ext.lower(),
})
# 写回配置
with open(products_file, 'r', encoding='utf-8') as f:
config = json.load(f)
for p in config.get('products', []):
if p['id'] == product_id:
p['documents'] = existing_docs
with open(products_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
# 重新加载配置
self._load_products()
logger.info(f"[ProductService] 文档已上传: {relative_path} ({len(file_data)} bytes)")
return {'success': True, 'doc_id': doc_id, 'message': f'文档 {filename} 上传成功', 'size': len(file_data)}
except Exception as e:
logger.error(f"[ProductService] 上传文档失败: {e}")
return {'success': False, 'message': str(e)}
def delete_document(self, doc_id: str) -> dict:
"""删除文档资料
Args:
doc_id: 文档 ID
Returns:
{'success': bool, 'message': str}
"""
try:
# 查找文档信息
for p in self._products:
for doc in p.get('documents', []):
if doc['id'] == doc_id:
filename = doc.get('filename', '')
product_id = p['id']
# 删除文件
file_path = DOCS_DIR / filename
if file_path.exists():
file_path.unlink()
# 从配置中移除
from utils.paths import SCRIPT_DIR
products_file = SCRIPT_DIR / 'products.json'
with open(products_file, 'r', encoding='utf-8') as f:
config = json.load(f)
for cp in config.get('products', []):
if cp['id'] == product_id:
cp['documents'] = [d for d in cp.get('documents', []) if d['id'] != doc_id]
with open(products_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
# 重新加载
self._load_products()
logger.info(f"[ProductService] 文档已删除: {filename}")
return {'success': True, 'message': f'文档已删除'}
return {'success': False, 'message': '文档不存在'}
except Exception as e:
logger.error(f"[ProductService] 删除文档失败: {e}")
return {'success': False, 'message': str(e)}
# 单例 # 单例
_product_service = None _product_service = None
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论