Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 521e6c8824 | |||
| 8bfd0cfdc3 | |||
| eee2bcc071 | |||
| cec5607c45 | |||
| caf9b7ed85 | |||
| 68ce1dbab9 |
@@ -0,0 +1,61 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 本地开发环境变量
|
||||||
|
# =============================================================================
|
||||||
|
# 这是给 docker-compose.dev.yml 用的,不是生产 .env
|
||||||
|
# 用法:docker compose -f docker-compose.dev.yml up -d (会自动加载)
|
||||||
|
# 安全:此文件可以提交到 git(都是假值,无敏感信息)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 关键开关:开发模式
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# DEV_MODE=true 会启用以下 mock:
|
||||||
|
# 1. 跳过企微 OAuth(用 /api/dev/login?userid=xxx 直接登)
|
||||||
|
# 2. 默认 userid 设为 dev-user-001
|
||||||
|
# 3. 跳过 JS-SDK 签名校验
|
||||||
|
# 4. 详细日志输出
|
||||||
|
DEV_MODE=true
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 数据库(Docker 内部用 service name)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
POSTGRES_USER=wecom
|
||||||
|
POSTGRES_PASSWORD=wecom_dev
|
||||||
|
POSTGRES_DB=wecom_it_desk_dev
|
||||||
|
DATABASE_URL=postgresql://wecom:wecom_dev@localhost:5432/wecom_it_desk_dev
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 企微(本地用假值,不真调)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
WECOM_CORP_ID=dev_corp_id_xxxxx
|
||||||
|
WECOM_AGENT_ID=1000001
|
||||||
|
WECOM_SECRET=dev_secret_placeholder
|
||||||
|
WECOM_TOKEN=dev_token_placeholder
|
||||||
|
WECOM_ENCODING_AES_KEY=dev_aes_key_43_chars_placeholder_xxxxxxxxx
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 集成(本地用假值,API 调用会失败但不影响主流程)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
HUORONG_BASE_URL=http://localhost:9999
|
||||||
|
HUORONG_ACCESS_KEY_ID=dev_key
|
||||||
|
HUORONG_ACCESS_KEY_SECRET=dev_secret
|
||||||
|
LIANRUAN_BASE_URL=http://localhost:9998
|
||||||
|
LIANRUAN_API_ACCOUNT=dev
|
||||||
|
LIANRUAN_API_PASSWORD=dev
|
||||||
|
RAGFLOW_BASE_URL=http://localhost:9997
|
||||||
|
RAGFLOW_API_KEY=dev
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 应用配置
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
APP_ENV=development
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Mock 用户(DEV_MODE=true 时)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
DEV_DEFAULT_USERID=dev-user-001
|
||||||
|
DEV_DEFAULT_NAME=开发测试用户
|
||||||
|
DEV_DEFAULT_DEPT=信息技术部
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 企微IT智能服务台 — 项目状态看板
|
||||||
|
|
||||||
|
> 📌 **这个文件就是项目的"驾驶舱仪表盘"**。任何时候新开 session,**先读这个文件就懂上下文**。
|
||||||
|
>
|
||||||
|
> 📝 **更新规则**:每次 Claude 完成 / 开始 / 阻塞重要任务,会主动更新本文件。你也可以自己改(纯 markdown,git 跟踪)。
|
||||||
|
|
||||||
|
最后更新:**2026-06-16 11:10**(Claude 自动维护,看板上一次刷新)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 一句话总览
|
||||||
|
|
||||||
|
**项目状态**:**v0.5.6-dev-tooling 完成**,本地 4 端 dev 链路全通(Mock 企微 OAuth + 3 个新 migration + 1 个 decorator bug 修复)。
|
||||||
|
**当前主线**:**等用户决策要不要上生产**(生产 3 个 migration + 1 个 bug 修复可上,7 个 dev 改动留在本地)。
|
||||||
|
**待回复**:#83 OTM 是什么 / 跟项目什么关系。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 正在做(in_progress,1 件)
|
||||||
|
|
||||||
|
| # | 任务 | 我做什么 | 你做什么 | 完成定义 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| #90 | 后端 pytest 测试套件 | 补 token_service / scoring_service 等 | 等结果 | 20+ 测试通过 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 P0 必做(下一个 sprint)
|
||||||
|
|
||||||
|
| # | 任务 | 重要程度 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| #48 | v1.0 收窄 set_real_ip_from | 🔴 P0 | 现 allow 0.0.0.0/0 是临时方案,正式上线前必须改精确代理 IP |
|
||||||
|
| #81 | v0.6.0 敏感词检测 + 语气优化 | 🔴 P0 | 下一个版本的核心功能 |
|
||||||
|
| #80 | v0.5.4 应急页 nginx 路由 + 部署 | 🔴 P0 | 当前生产缺路由,功能上了但用户访问不到 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 P1 重要(看时间做)
|
||||||
|
|
||||||
|
| # | 任务 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| #73 | 修后端文件未真正覆盖 | `yes | cp -f` 路径,部署时偶尔没生效 |
|
||||||
|
| #86 | 排查流程图零依赖部分 review + 文档化 | 把 Mermaid 流程图从代码里剥离成可读文档 |
|
||||||
|
| #88 | 管理后台 RBAC 角色权限 | 管理后台细粒度角色权限(大功能,2-3 天) |
|
||||||
|
| #83 | 澄清"OTM 跟项目关系" | **我在这等你回答**:OTM 是什么?需要对接吗? |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 P2 / 等用户决策
|
||||||
|
|
||||||
|
| # | 任务 | 卡在哪 |
|
||||||
|
|---|---|---|
|
||||||
|
| **🆕 服务器更新?** | 把今天的 3 个 migration + 1 个 bug 修复部署到生产 v0.5.6 | **等你看这份看板后拍板** |
|
||||||
|
| #31 | 推 docker 镜像到生产 registry | 等你确认要走哪条路(自建 Harbor / 阿里云 / 别的) |
|
||||||
|
| #43 | 配置 HTTPS | 等域名备案完成 + 证书到位 |
|
||||||
|
| #53 | 用户在企微验证 /itportal/ | 等你去企微点一点 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 最近搞定(给你信心)
|
||||||
|
|
||||||
|
### 2026-06-16(今天)
|
||||||
|
|
||||||
|
#### 🛠️ Dev 环境(本地链路全通)
|
||||||
|
|
||||||
|
- ✅ **本地 dev 4 端链路跑通**(#89-92):
|
||||||
|
- backend (8000) + h5 (5174) + agent (5173) + admin (5175) + portal (5176) 全起
|
||||||
|
- Mock 企微 OAuth 全通(`/api/dev/login` 给 token)
|
||||||
|
- portal → H5 / 坐席 / 管理员 跳转正常
|
||||||
|
- ✅ **修了 3 个 dev 启动坑**:
|
||||||
|
1. `pydantic==2.7.5` → `2.7.4`(2.7.5 被 PyPI yank)
|
||||||
|
2. docker-compose 加 `PYTHONPATH=/app`(alembic 1.13+ 不再默认 prepend cwd)
|
||||||
|
3. dev 启动必须用 `--env-file .env.dev`(根 `.env` 冲突)
|
||||||
|
|
||||||
|
#### 🐛 Bug 修复
|
||||||
|
|
||||||
|
- ✅ **#93 修 portal dev 模式跳错端口**:`import.meta.env.DEV` 判断,生产走相对路径,dev 走完整 URL
|
||||||
|
- ✅ **#97 修 require_role 装饰器**:`@wraps` 让 FastAPI 看到 `__wrapped__` 签名,Depends 未被解析 → `current_user` 实际是 Depends 对象。用 `inspect` 合并 signature + 手动设 `wrapper.__signature__` 修
|
||||||
|
- ✅ **#99 dev 模式短路企微推送**:避免 `.env.dev` 用 `dev_corp_id_xxxxx` 调企微 API 返 `invalid corpid` 噪音
|
||||||
|
|
||||||
|
#### 🗃️ 数据库 migration(3 个)
|
||||||
|
|
||||||
|
- ✅ **#94 alembic 010**:加 `agents.otp_secret` + `agents.otp_enabled`
|
||||||
|
- ✅ **#94 alembic 011**:加 `conversations.impact_scope` + `is_blocking` + `emotion_state`(用户坐席发消息 500 的真因)
|
||||||
|
- ✅ **#96 alembic 012**:加 `conversations.dify_conversation_id` + `employees.it_level` + `it_level_source` + `notes`
|
||||||
|
|
||||||
|
#### 🛡️ 防错工具(留底用)
|
||||||
|
|
||||||
|
- ✅ **#95 dev-check-schema-drift.ps1**:对比 SQLAlchemy 模型 vs Postgres schema,漂移 exit 1。以后模型加字段忘 migration 一跑就发现(用 docker exec,免去 Python 依赖)
|
||||||
|
|
||||||
|
#### 📋 其他
|
||||||
|
|
||||||
|
- ✅ **#68 H5 空白页闪一下**:dev 模式验证不再白屏(生产未复测)
|
||||||
|
|
||||||
|
### 历史(选重点)
|
||||||
|
|
||||||
|
- ✅ v0.5.5:应急页 v0.5.4 + 移除 IT 设备升级 + admin 登录修复 + 内容审核架构
|
||||||
|
- ✅ v0.5.3:重打后端部署包(5 IT + 2 HR + 1 行政 + 1 财务 = 9 条)
|
||||||
|
- ✅ v0.5.6-dev-tooling 已 tag + push gitea(本地 dev 工具集)
|
||||||
|
- ✅ messages.id varchar=UUID SQL bug 修了(#60)+ 10 个回归测试通过
|
||||||
|
- ✅ nginx /api/admin/ 和 /itadmin/ 修复 403/allow(#57)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 怎么跑起来(3 步)
|
||||||
|
|
||||||
|
### 1. 后端 dev(已经在跑 ✅)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\资料\03-项目开发\wecom_it_smart_desk-claude
|
||||||
|
docker compose -f docker-compose.dev.yml --env-file .env.dev up -d
|
||||||
|
curl http://localhost:8000/api/dev/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 前端 dev(已经在跑 ✅)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 一次性装 4 个前端依赖(已装好)
|
||||||
|
.\scripts\dev-frontend-install.ps1
|
||||||
|
|
||||||
|
# 之后:一起起所有前端
|
||||||
|
.\scripts\dev-frontend-start.ps1
|
||||||
|
# 单独停:.\scripts\dev-frontend-start.ps1 -Stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 浏览器验证
|
||||||
|
|
||||||
|
- portal:http://localhost:5176/itportal/select
|
||||||
|
- H5:http://localhost:5174/itdesk/
|
||||||
|
- 坐席:http://localhost:5173/itagent/
|
||||||
|
- 管理员:http://localhost:5175/itadmin/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 怎么读这份文档
|
||||||
|
|
||||||
|
**你是运维小白,不需要懂代码**。看这个文件就能 1 分钟懂:
|
||||||
|
|
||||||
|
1. **"现在在干嘛?"** → 看「正在做」表
|
||||||
|
2. **"接下来要干嘛?"** → 看「P0 必做」表
|
||||||
|
3. **"我需要做什么?"** → 看「正在做」表里的「你做什么」列
|
||||||
|
4. **"今天有啥进展?"** → 看「最近搞定」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Claude 怎么帮你
|
||||||
|
|
||||||
|
每次开新 session 我会:
|
||||||
|
|
||||||
|
1. **第一件事**:读这个文件 + TaskList,告诉你"上次到这了"
|
||||||
|
2. **完成一件重要事**:更新这个文件(改状态、加完成项)
|
||||||
|
3. **遇到阻塞**:写在「P2 / 等用户决策」里,等你回话
|
||||||
|
4. **新需求进来**:跟当前 in_progress 比较,看是**接着做**还是**并行加**(参考你的"并行处理"反馈)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**这个文件就是你和 Claude 之间的"工作交接本"。有问题改这里就行。**
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Docker 构建时排除 — 避免 .env 等敏感/开发文件进入镜像
|
||||||
|
# =============================================================================
|
||||||
|
# 关联:memory/v070-alpha-env-override-bug.md
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 开发 .env 文件(不要进生产镜像,会被 pydantic-settings 优先读)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python 缓存
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
|
# 测试产物
|
||||||
|
pytest-*.log
|
||||||
|
pytest_result.txt
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# 备份文件
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# 测试数据库
|
||||||
|
*.db
|
||||||
|
it_smart_desk.db
|
||||||
|
|
||||||
|
# 临时脚本(用过的工具脚本,不需要进生产)
|
||||||
|
check_all_tables.py
|
||||||
|
check_db.py
|
||||||
|
hello.py
|
||||||
|
migrate_*.py
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# 本地文档
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# node_modules(理论上不会有,但保险)
|
||||||
|
node_modules/
|
||||||
+10
-1
@@ -45,12 +45,21 @@ RUN apt-get update && \
|
|||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 🔧 v0.7.0-alpha 修复:显式设置 PYTHONPATH
|
||||||
|
# 原因:alembic 1.13+ 不默认 prepend cwd,导致 `from app.config import settings` 失败
|
||||||
|
# 关联:memory/docker-dev-alembic-pythonpath.md(同样问题 dev 环境也中招)
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# 从构建阶段复制已安装的 Python 包
|
# 从构建阶段复制已安装的 Python 包
|
||||||
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# 复制项目代码
|
# 复制项目代码(排除 .env 和 .env.*,避免覆盖 docker-compose 注入的环境变量)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
# 删除可能被 COPY 进镜像的开发 .env
|
||||||
|
# 原因:pydantic-settings 会优先读 /app/.env,会覆盖 compose 的 environment 块
|
||||||
|
# 关联:memory/v070-alpha-backend-env-override-bug.md
|
||||||
|
RUN rm -f /app/.env /app/.env.* || true
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 后端 开发镜像 Dockerfile
|
||||||
|
# =============================================================================
|
||||||
|
# 与 Dockerfile(prod) 区别:
|
||||||
|
# - 不需要 gcc / libpq-dev(用预编译的 psycopg2-binary)
|
||||||
|
# - 装 pytest 用于跑测试
|
||||||
|
# - 不需要 multi-stage build(开发用,镜像大一点无所谓)
|
||||||
|
# - 装 watchfiles 配合 uvicorn --reload
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
LABEL maintainer="IT服务台开发团队"
|
||||||
|
LABEL description="企微IT智能服务台后端 - 开发模式"
|
||||||
|
|
||||||
|
# 换 apt 源(公司内网,默认 deb.debian.org 可能不通)
|
||||||
|
RUN sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
|
||||||
|
sed -i "s|deb.debian.org|mirrors.aliyun.com|g" /etc/apt/sources.list 2>/dev/null || true
|
||||||
|
|
||||||
|
# 安装运行时依赖(精简版)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends libpq5 curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 换 PyPI 源 + 装依赖
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
--timeout 120 \
|
||||||
|
--retries 5 \
|
||||||
|
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||||
|
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||||
|
-r requirements.txt && \
|
||||||
|
pip install --no-cache-dir \
|
||||||
|
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||||
|
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||||
|
pytest pytest-asyncio httpx watchfiles
|
||||||
|
|
||||||
|
# 复制项目代码(在 dev 模式下用 volume mount 覆盖)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 默认命令(在 docker-compose.dev.yml 里覆盖)
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""add agent OTP fields
|
||||||
|
|
||||||
|
Revision ID: 010_add_agent_otp
|
||||||
|
Revises: 009_add_message_status
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
v0.5.6: 添加坐席 OTP 二次验证字段
|
||||||
|
- 新增 otp_secret 字段(存储 TOTP secret,绑定时生成)
|
||||||
|
- 新增 otp_enabled 字段(是否启用 OTP 二次验证)
|
||||||
|
- 都是 nullable=True,默认 False,不破坏现有坐席
|
||||||
|
|
||||||
|
为什么需要这个 migration:
|
||||||
|
Agent 模型里加了 otp_secret 和 otp_enabled 字段,
|
||||||
|
但没有对应的 alembic migration 把它落到 DB schema 里。
|
||||||
|
查询时报 UndefinedColumnError:
|
||||||
|
column agents.otp_secret does not exist
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '010_add_agent_otp'
|
||||||
|
down_revision = '009_add_message_status'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 otp_secret + otp_enabled 字段"""
|
||||||
|
op.add_column(
|
||||||
|
'agents',
|
||||||
|
sa.Column(
|
||||||
|
'otp_secret',
|
||||||
|
sa.String(64),
|
||||||
|
nullable=True,
|
||||||
|
comment='TOTP 密钥(base32,绑定时生成)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'agents',
|
||||||
|
sa.Column(
|
||||||
|
'otp_enabled',
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
comment='是否启用 OTP 二次验证'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除 OTP 字段"""
|
||||||
|
op.drop_column('agents', 'otp_enabled')
|
||||||
|
op.drop_column('agents', 'otp_secret')
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""add conversation impact fields
|
||||||
|
|
||||||
|
Revision ID: 011_add_conversation_impact
|
||||||
|
Revises: 010_add_agent_otp
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
v0.5.6: 补齐 Conversation 模型的 3 个评估字段
|
||||||
|
- impact_scope (int, default 0): 影响范围(受影响人数)
|
||||||
|
- is_blocking (bool, default False): 是否阻断员工工作
|
||||||
|
- emotion_state (str(20), default 'normal'): 情绪状态
|
||||||
|
|
||||||
|
为什么需要这个 migration:
|
||||||
|
Conversation 模型里加了 impact_scope/is_blocking/emotion_state,
|
||||||
|
但缺 alembic migration 落库。坐席发消息时 SQLAlchemy 查
|
||||||
|
conversations.* 全字段,报:
|
||||||
|
column conversations.impact_scope does not exist
|
||||||
|
|
||||||
|
跟 010_add_agent_otp 是同一类问题(模型新字段无 migration)。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '011_add_conversation_impact'
|
||||||
|
down_revision = '010_add_agent_otp'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 impact_scope + is_blocking + emotion_state 字段"""
|
||||||
|
op.add_column(
|
||||||
|
'conversations',
|
||||||
|
sa.Column(
|
||||||
|
'impact_scope',
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('0'),
|
||||||
|
comment='影响范围(受影响人数,0=未评估)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'conversations',
|
||||||
|
sa.Column(
|
||||||
|
'is_blocking',
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
comment='是否阻断员工工作'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'conversations',
|
||||||
|
sa.Column(
|
||||||
|
'emotion_state',
|
||||||
|
sa.String(20),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'normal'"),
|
||||||
|
comment='情绪状态(normal/worried/angry/urgent)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除 3 个评估字段"""
|
||||||
|
op.drop_column('conversations', 'emotion_state')
|
||||||
|
op.drop_column('conversations', 'is_blocking')
|
||||||
|
op.drop_column('conversations', 'impact_scope')
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""sync remaining model fields
|
||||||
|
|
||||||
|
Revision ID: 012_sync_remaining_fields
|
||||||
|
Revises: 011_add_conversation_impact
|
||||||
|
Create Date: 2026-06-16
|
||||||
|
|
||||||
|
v0.5.6: 补齐 dev-check-schema-drift 找到的 4 个漂移字段
|
||||||
|
- conversations.dify_conversation_id (VARCHAR(128), nullable)
|
||||||
|
- employees.it_level (VARCHAR(20), default 'silver')
|
||||||
|
- employees.it_level_source (VARCHAR(20), default 'system')
|
||||||
|
- employees.notes (JSON, default '{}')
|
||||||
|
|
||||||
|
为什么需要这个 migration:
|
||||||
|
之前手动 011 只补了 NOT NULL 那些(坐席发消息会 500 的),
|
||||||
|
但 dev-check-schema-drift.ps1 又发现 4 个字段也没建 migration。
|
||||||
|
之前是 nullable 没立即暴露,运行 SELECT * FROM conversations 时
|
||||||
|
PostgreSQL 会按顺序填,nullable 列缺不会立刻 500,但 INSERT/UPDATE
|
||||||
|
涉及这些字段时会出错,或者 Alembic autogenerate 会持续报告漂移。
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = '012_sync_remaining_fields'
|
||||||
|
down_revision = '011_add_conversation_impact'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""加 4 个漂移字段"""
|
||||||
|
|
||||||
|
# 1) conversations.dify_conversation_id - Dify 多轮对话上下文
|
||||||
|
op.add_column(
|
||||||
|
'conversations',
|
||||||
|
sa.Column(
|
||||||
|
'dify_conversation_id',
|
||||||
|
sa.String(128),
|
||||||
|
nullable=True,
|
||||||
|
comment='Dify会话ID(多轮对话上下文)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) employees.it_level - IT 技能等级
|
||||||
|
op.add_column(
|
||||||
|
'employees',
|
||||||
|
sa.Column(
|
||||||
|
'it_level',
|
||||||
|
sa.String(20),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'silver'"),
|
||||||
|
comment='IT技能等级(bronze/silver/gold/platinum/diamond/star/king)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) employees.it_level_source - 等级来源
|
||||||
|
op.add_column(
|
||||||
|
'employees',
|
||||||
|
sa.Column(
|
||||||
|
'it_level_source',
|
||||||
|
sa.String(20),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'system'"),
|
||||||
|
comment='等级来源(system/manual/assessment)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) employees.notes - 坐席备注 JSON
|
||||||
|
op.add_column(
|
||||||
|
'employees',
|
||||||
|
sa.Column(
|
||||||
|
'notes',
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'{}'"),
|
||||||
|
comment='坐席备注(JSON 格式)'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除 4 个字段"""
|
||||||
|
op.drop_column('employees', 'notes')
|
||||||
|
op.drop_column('employees', 'it_level_source')
|
||||||
|
op.drop_column('employees', 'it_level')
|
||||||
|
op.drop_column('conversations', 'dify_conversation_id')
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 管理后台 API 子包
|
||||||
|
# =============================================================================
|
||||||
|
# 包标记文件
|
||||||
|
# 2026-06-16 添加: 修复与同名文件 app/api/admin.py 冲突
|
||||||
|
# 背景: router.py 引用 from app.api.admin.security_comparison import router
|
||||||
|
# Python 优先选 admin.py 当 module,导致 admin/ 目录被忽略
|
||||||
|
# 加上此文件后,admin/ 目录被识别为正式 package,优先于同名 .py 文件
|
||||||
|
# =============================================================================
|
||||||
@@ -12,7 +12,7 @@ from uuid import uuid4
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.api.admin import require_admin
|
from app.api.admin_api import require_admin
|
||||||
from app.services.security_comparison import (
|
from app.services.security_comparison import (
|
||||||
TerminalSecurityComparison,
|
TerminalSecurityComparison,
|
||||||
comparison_task_config,
|
comparison_task_config,
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 开发模式 Mock 登录
|
||||||
|
# =============================================================================
|
||||||
|
# ⚠️ 警告:此模块只在 DEV_MODE=true 时可用
|
||||||
|
# - 仅供本地开发 / 集成测试使用
|
||||||
|
# - 生产环境(DEV_MODE 未设置或 false)会直接 403
|
||||||
|
# - 部署前必须确认 .env / .env.production 没有 DEV_MODE=true
|
||||||
|
# 用法:
|
||||||
|
# GET /api/dev/login?userid=dev-user-001&name=测试&role=user
|
||||||
|
# GET /api/dev/users # 列出所有预设 dev 用户
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.dependencies import get_redis
|
||||||
|
from app.services.token_service import TokenService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dev", tags=["dev-mock"])
|
||||||
|
|
||||||
|
|
||||||
|
def _dev_mode_enabled() -> bool:
|
||||||
|
"""检查是否启用了开发模式。
|
||||||
|
|
||||||
|
三个检查源(任一为 true 即启用):
|
||||||
|
1. 环境变量 DEV_MODE=true
|
||||||
|
2. settings.dev_mode(从 .env.dev 读)
|
||||||
|
3. DEBUG 模式 + 本地主机(最严格)
|
||||||
|
"""
|
||||||
|
env_val = os.getenv("DEV_MODE", "false").lower() == "true"
|
||||||
|
if env_val:
|
||||||
|
return True
|
||||||
|
# 兜底:从 settings 读
|
||||||
|
if hasattr(settings, "dev_mode") and getattr(settings, "dev_mode", False):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 预设 dev 用户(便于测试不同角色)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
PRESET_DEV_USERS = [
|
||||||
|
{"userid": "dev-user-001", "name": "张三(普通员工)", "role": "user", "department": "财务部"},
|
||||||
|
{"userid": "dev-agent-001", "name": "李四(IT 坐席)", "role": "agent", "department": "信息技术部"},
|
||||||
|
{"userid": "dev-supervisor-001", "name": "王五(部门主管)", "role": "supervisor", "department": "信息技术部"},
|
||||||
|
{"userid": "dev-security-001", "name": "赵六(安全团队)", "role": "security", "department": "信息安全部"},
|
||||||
|
{"userid": "dev-admin-001", "name": "钱七(系统管理员)", "role": "admin", "department": "信息技术部"},
|
||||||
|
{"userid": "dev-multi-001", "name": "周八(多角色测试)", "role": "user,agent,supervisor", "department": "测试部"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# GET /api/dev/login — Mock 登录(返回 token)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@router.get("/login")
|
||||||
|
async def dev_login(
|
||||||
|
userid: str = Query("dev-user-001", description="用户 ID(模拟企微 userid)"),
|
||||||
|
name: str = Query("开发测试用户", description="用户姓名"),
|
||||||
|
role: str = Query("user", description="角色:user/agent/admin/supervisor/security,多个用逗号分隔"),
|
||||||
|
department: str = Query("信息技术部", description="部门"),
|
||||||
|
avatar: Optional[str] = Query(None, description="头像 URL(可选)"),
|
||||||
|
redis: aioredis.Redis = Depends(get_redis),
|
||||||
|
):
|
||||||
|
"""开发模式 Mock 登录。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
GET /api/dev/login?userid=dev-agent-001&name=李四&role=agent
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"token": "abc123...",
|
||||||
|
"user": { "userid": "...", "name": "...", "roles": [...] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not _dev_mode_enabled():
|
||||||
|
logger.warning("🚨 /api/dev/login 被调用但 DEV_MODE 未启用,返回 403")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="DEV_MODE not enabled. Set DEV_MODE=true in .env.dev to use this endpoint."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析多角色
|
||||||
|
roles = [r.strip() for r in role.split(",") if r.strip()]
|
||||||
|
if not roles:
|
||||||
|
roles = ["user"]
|
||||||
|
|
||||||
|
# 调 TokenService 创建 token(走完全真实的 token 流程)
|
||||||
|
token_service = TokenService(redis)
|
||||||
|
token = await token_service.create_token(
|
||||||
|
employee_id=userid,
|
||||||
|
name=name,
|
||||||
|
roles=roles,
|
||||||
|
department=department,
|
||||||
|
avatar=avatar or "",
|
||||||
|
login_source="dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"🧪 [DEV] Mock 登录成功: userid={userid}, roles={roles}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"token": token,
|
||||||
|
"user": {
|
||||||
|
"userid": userid,
|
||||||
|
"name": name,
|
||||||
|
"department": department,
|
||||||
|
"avatar": avatar or "",
|
||||||
|
"roles": roles,
|
||||||
|
"login_source": "dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# GET /api/dev/users — 列出所有预设 dev 用户
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@router.get("/users")
|
||||||
|
async def dev_list_users():
|
||||||
|
"""列出所有预设 dev 用户(便于前端测试用)。"""
|
||||||
|
if not _dev_mode_enabled():
|
||||||
|
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": PRESET_DEV_USERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# GET /api/dev/health — 检查 dev 模式状态
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
@router.get("/health")
|
||||||
|
async def dev_health():
|
||||||
|
"""检查 dev 模式是否启用 + 关键依赖。"""
|
||||||
|
if not _dev_mode_enabled():
|
||||||
|
raise HTTPException(status_code=403, detail="DEV_MODE not enabled")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"dev_mode": True,
|
||||||
|
"env": os.getenv("APP_ENV", "unknown"),
|
||||||
|
"database_url": os.getenv("DATABASE_URL", "not set")[:50] + "...",
|
||||||
|
"redis_url": os.getenv("REDIS_URL", "not set"),
|
||||||
|
"preset_users": len(PRESET_DEV_USERS),
|
||||||
|
},
|
||||||
|
}
|
||||||
+17
-13
@@ -200,23 +200,27 @@ async def send_message(
|
|||||||
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
|
# image/file 等非文本消息暂不通过企微推送(仅存储消息记录供坐席查看)
|
||||||
# 跳过 Redis 连��可避免无谓的网络开销,减少截图发送超时
|
# 跳过 Redis 连��可避免无谓的网络开销,减少截图发送超时
|
||||||
if body.msg_type == "text":
|
if body.msg_type == "text":
|
||||||
try:
|
# dev 模式短路:直接跳过企微推送,避免 invalid corpid 噪音
|
||||||
import redis.asyncio as aioredis
|
from app.config import settings
|
||||||
from app.config import settings
|
if getattr(settings, 'dev_mode', False):
|
||||||
|
logger.debug(f"[DEV] 跳过企微推送: msg_id={message.id}")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
redis_client = settings.create_redis_client()
|
redis_client = settings.create_redis_client()
|
||||||
wecom_service = WecomService(redis_client)
|
wecom_service = WecomService(redis_client)
|
||||||
|
|
||||||
await wecom_service.send_text_message(
|
await wecom_service.send_text_message(
|
||||||
conversation.employee_id, body.content
|
conversation.employee_id, body.content
|
||||||
)
|
)
|
||||||
|
|
||||||
await wecom_service.close()
|
await wecom_service.close()
|
||||||
await redis_client.close()
|
await redis_client.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 企微 API 调用失败不阻塞消息存储
|
# 企微 API 调用失败不阻塞消息存储
|
||||||
logger.warning(f"企微消息发送失败(消息已存储): {e}")
|
logger.warning(f"企微消息发送失败(消息已存储): {e}")
|
||||||
|
|
||||||
# 5. 更新消息状态为已发送
|
# 5. 更新消息状态为已发送
|
||||||
message.status = "sent"
|
message.status = "sent"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from app.api.todo_items import router as todo_items_router
|
|||||||
from app.api.troubleshooting_templates import router as troubleshooting_templates_router
|
from app.api.troubleshooting_templates import router as troubleshooting_templates_router
|
||||||
from app.api.employees import router as employees_router
|
from app.api.employees import router as employees_router
|
||||||
from app.api.upload import router as upload_router
|
from app.api.upload import router as upload_router
|
||||||
from app.api.admin import router as admin_router
|
from app.api.admin_api import router as admin_router
|
||||||
from app.api.portal import router as portal_router
|
from app.api.portal import router as portal_router
|
||||||
from app.api.admin_roles import router as admin_roles_router
|
from app.api.admin_roles import router as admin_roles_router
|
||||||
from app.api.admin.security_comparison import router as security_comparison_router
|
from app.api.admin.security_comparison import router as security_comparison_router
|
||||||
|
|||||||
@@ -99,6 +99,23 @@ class Settings(BaseSettings):
|
|||||||
# 是否启用 Mock 登录(默认 false,生产环境必须关闭)
|
# 是否启用 Mock 登录(默认 false,生产环境必须关闭)
|
||||||
mock_login_enabled: bool = False
|
mock_login_enabled: bool = False
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 开发模式配置(本地 docker-compose.dev.yml 用)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 是否启用开发模式(本地开发环境,启用后挂载 /api/dev/* Mock OAuth 路由)
|
||||||
|
# ⚠️ 生产环境必须为 false / 不设置
|
||||||
|
# 启用的副作用:
|
||||||
|
# 1. 后端启动时挂载 /api/dev/login /users /health 三个 Mock 端点
|
||||||
|
# 2. /api/dev/login 跳过企微 OAuth 直接生成 token
|
||||||
|
# 3. 启动日志会大声警告 "🧪 DEV_MODE enabled"
|
||||||
|
dev_mode: bool = False
|
||||||
|
# 开发模式默认 userid(本地前端兜底用,实际由前端 /api/dev/login 传入)
|
||||||
|
dev_default_userid: str = "dev-user-001"
|
||||||
|
# 开发模式默认姓名
|
||||||
|
dev_default_name: str = "开发测试用户"
|
||||||
|
# 开发模式默认部门
|
||||||
|
dev_default_dept: str = "信息技术部"
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# 审批模板配置(企微审批应用)
|
# 审批模板配置(企微审批应用)
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# app.db — 兼容层:把 app.database 的 _get_session_factory 暴露为 public 名称
|
||||||
|
# =============================================================================
|
||||||
|
# 背景:main.py 在 lifespan 里写的是 `from app.db import get_session_factory`,
|
||||||
|
# 但 session_factory 实际定义在 app/database.py(私有下划线 `_get_session_factory`)。
|
||||||
|
# 引入本模块,让 main.py 的 import 不需要改。
|
||||||
|
#
|
||||||
|
# 改动记录:
|
||||||
|
# - v0.7.0-alpha:新建此兼容层,用于生产环境热修复
|
||||||
|
# (无需改 main.py 也无需 rebuild 镜像)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
from app.database import _get_session_factory
|
||||||
|
|
||||||
|
# 公开别名,让 `from app.db import get_session_factory` 工作
|
||||||
|
get_session_factory = _get_session_factory
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
# 3. require_admin: 管理员权限验证
|
# 3. require_admin: 管理员权限验证
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -225,12 +226,26 @@ def require_role(*required_roles: str):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
|
# 合并 func 签名 + current_user 参数,让 FastAPI 能正确解析 Depends
|
||||||
|
# (v0.5.6 修复:之前用 @wraps,FastAPI 看到的是 __wrapped__ 的签名,
|
||||||
|
# 没有 current_user,导致 Depends 默认值未被解析,current_user 实际是 Depends 对象)
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
params = list(sig.parameters.values())
|
||||||
|
params.append(
|
||||||
|
inspect.Parameter(
|
||||||
|
'current_user',
|
||||||
|
inspect.Parameter.KEYWORD_ONLY,
|
||||||
|
annotation=UserInfo,
|
||||||
|
default=Depends(get_current_user),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
new_sig = sig.replace(parameters=params)
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def wrapper(
|
async def wrapper(*args, **kwargs):
|
||||||
*args,
|
# FastAPI 已经把 current_user 注入了 kwargs
|
||||||
current_user: UserInfo = Depends(get_current_user),
|
current_user = kwargs.pop('current_user')
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
# 检查用户是否有任一所需角色
|
# 检查用户是否有任一所需角色
|
||||||
user_roles = set(current_user.roles)
|
user_roles = set(current_user.roles)
|
||||||
required = set(required_roles)
|
required = set(required_roles)
|
||||||
@@ -247,6 +262,8 @@ def require_role(*required_roles: str):
|
|||||||
|
|
||||||
return await func(*args, current_user=current_user, **kwargs)
|
return await func(*args, current_user=current_user, **kwargs)
|
||||||
|
|
||||||
|
# 关键:让 FastAPI 用合并后的签名,这样它能看到 current_user 这个 Depends 参数
|
||||||
|
wrapper.__signature__ = new_sig
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
+213
-1
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
# 导入配置(读取环境变量)
|
# 导入配置(读取环境变量)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -37,6 +38,30 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 开发模式判定(模块级 helper,避免在 create_app 内每次重复 import)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _is_dev_mode() -> bool:
|
||||||
|
"""检查是否启用了开发模式(DEV_MODE=true)。
|
||||||
|
|
||||||
|
三个检查源(任一为 true 即启用):
|
||||||
|
1. 环境变量 DEV_MODE=true(最高优先级,Docker 注入)
|
||||||
|
2. settings.dev_mode(从 .env.dev 读)
|
||||||
|
3. DEBUG 模式 + 本地主机(最严格)
|
||||||
|
|
||||||
|
注意:此函数与 backend/app/api/dev_auth.py 内的 _dev_mode_enabled() 逻辑一致,
|
||||||
|
这里用于"是否挂载 dev_auth 路由",那里用于"端点内是否放行"。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
env_val = os.getenv("DEV_MODE", "").lower() == "true"
|
||||||
|
if env_val:
|
||||||
|
return True
|
||||||
|
if getattr(settings, "dev_mode", False):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# 应用生命周期管理(启动和关闭事件)
|
# 应用生命周期管理(启动和关闭事件)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -155,6 +180,7 @@ async def _init_default_data():
|
|||||||
3. quick_reply_templates — 快速回复模板
|
3. quick_reply_templates — 快速回复模板
|
||||||
4. approval_links — 审批流程链接
|
4. approval_links — 审批流程链接
|
||||||
5. software_downloads — 软件下载入口
|
5. software_downloads — 软件下载入口
|
||||||
|
6. (dev 模式)demo_conversations — 演示用会话,让前端有数据可发
|
||||||
|
|
||||||
只在表为空时插入,避免重复插入。
|
只在表为空时插入,避免重复插入。
|
||||||
"""
|
"""
|
||||||
@@ -164,6 +190,7 @@ async def _init_default_data():
|
|||||||
from app.models.quick_reply_template import QuickReplyTemplate
|
from app.models.quick_reply_template import QuickReplyTemplate
|
||||||
from app.models.approval_link import ApprovalLink
|
from app.models.approval_link import ApprovalLink
|
||||||
from app.models.software_download import SoftwareDownload
|
from app.models.software_download import SoftwareDownload
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
async_session_factory = _get_session_factory()
|
async_session_factory = _get_session_factory()
|
||||||
async with async_session_factory() as db:
|
async with async_session_factory() as db:
|
||||||
@@ -183,6 +210,11 @@ async def _init_default_data():
|
|||||||
# 5. 初始化软件下载入口
|
# 5. 初始化软件下载入口
|
||||||
await _init_software_downloads(db, SoftwareDownload)
|
await _init_software_downloads(db, SoftwareDownload)
|
||||||
|
|
||||||
|
# 6. (dev 模式)初始化 demo 会话,让前端有数据可发
|
||||||
|
# 真因:之前没建,前端硬编码的 conv-001 调 POST /messages 返 "会话不存在" 3003
|
||||||
|
if getattr(settings, 'dev_mode', False) or os.getenv('DEV_MODE', '').lower() == 'true':
|
||||||
|
await _init_demo_conversations(db)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("默认数据初始化完成")
|
logger.info("默认数据初始化完成")
|
||||||
|
|
||||||
@@ -191,6 +223,162 @@ async def _init_default_data():
|
|||||||
logger.error(f"默认数据初始化失败: {e}")
|
logger.error(f"默认数据初始化失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_demo_conversations(db):
|
||||||
|
"""(dev 模式专用)建 5 条 demo 会话,让前端有数据可测。
|
||||||
|
|
||||||
|
涵盖各种状态:
|
||||||
|
- ai_handling: AI 正在处理(2 条,不同员工)
|
||||||
|
- queued: 等坐席接手
|
||||||
|
- serving: 坐席服务中
|
||||||
|
- resolved: 已结单
|
||||||
|
|
||||||
|
只在 conversations 表为空时建,避免重复。
|
||||||
|
"""
|
||||||
|
from app.models.conversation import Conversation
|
||||||
|
|
||||||
|
existing = (await db.execute(select(Conversation).limit(1))).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
logger.info("demo 会话已存在,跳过")
|
||||||
|
return
|
||||||
|
|
||||||
|
import uuid as _uuid
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
demo_convs = [
|
||||||
|
{
|
||||||
|
"id": "conv-001",
|
||||||
|
"corp_id": "wwa8c87970b2011f41",
|
||||||
|
"employee_id": "dev-user-001",
|
||||||
|
"employee_name": "张三(普通员工)",
|
||||||
|
"department": "财务部",
|
||||||
|
"position": "会计",
|
||||||
|
"level": "P5",
|
||||||
|
"status": "ai_handling",
|
||||||
|
"is_vip": False,
|
||||||
|
"is_pinned": False,
|
||||||
|
"is_todo": False,
|
||||||
|
"urgency_score": 30,
|
||||||
|
"tags": ["财务", "IT"],
|
||||||
|
"assigned_agent_id": None,
|
||||||
|
"collaborating_agent_ids": [],
|
||||||
|
"participants": [],
|
||||||
|
"ai_substantive_reply_count": 0,
|
||||||
|
"impact_scope": 1,
|
||||||
|
"is_blocking": False,
|
||||||
|
"emotion_state": "normal",
|
||||||
|
"dify_conversation_id": None,
|
||||||
|
"last_message_at": now - timedelta(minutes=2),
|
||||||
|
"last_message_summary": "想问下 VPN 怎么连",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-002",
|
||||||
|
"corp_id": "wwa8c87970b2011f41",
|
||||||
|
"employee_id": "dev-user-001",
|
||||||
|
"employee_name": "张三(普通员工)",
|
||||||
|
"department": "财务部",
|
||||||
|
"position": "会计",
|
||||||
|
"level": "P5",
|
||||||
|
"status": "queued",
|
||||||
|
"is_vip": False,
|
||||||
|
"is_pinned": True,
|
||||||
|
"is_todo": True,
|
||||||
|
"urgency_score": 70,
|
||||||
|
"tags": ["紧急", "VPN"],
|
||||||
|
"assigned_agent_id": None,
|
||||||
|
"collaborating_agent_ids": [],
|
||||||
|
"participants": [],
|
||||||
|
"ai_substantive_reply_count": 2,
|
||||||
|
"impact_scope": 3,
|
||||||
|
"is_blocking": True,
|
||||||
|
"emotion_state": "worried",
|
||||||
|
"dify_conversation_id": "dify-conv-002",
|
||||||
|
"last_message_at": now - timedelta(minutes=5),
|
||||||
|
"last_message_summary": "VPN 连不上,影响工作",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-003",
|
||||||
|
"corp_id": "wwa8c87970b2011f41",
|
||||||
|
"employee_id": "dev-multi-001",
|
||||||
|
"employee_name": "周八(多角色测试)",
|
||||||
|
"department": "测试部",
|
||||||
|
"position": "测试工程师",
|
||||||
|
"level": "P6",
|
||||||
|
"status": "serving",
|
||||||
|
"is_vip": True,
|
||||||
|
"is_pinned": False,
|
||||||
|
"is_todo": False,
|
||||||
|
"urgency_score": 50,
|
||||||
|
"tags": ["软件安装"],
|
||||||
|
"assigned_agent_id": "dev-agent-001",
|
||||||
|
"collaborating_agent_ids": [],
|
||||||
|
"participants": [],
|
||||||
|
"ai_substantive_reply_count": 1,
|
||||||
|
"impact_scope": 1,
|
||||||
|
"is_blocking": False,
|
||||||
|
"emotion_state": "normal",
|
||||||
|
"dify_conversation_id": "dify-conv-003",
|
||||||
|
"last_message_at": now - timedelta(minutes=10),
|
||||||
|
"last_message_summary": "需要装 WPS 专业版",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-004",
|
||||||
|
"corp_id": "wwa8c87970b2011f41",
|
||||||
|
"employee_id": "dev-supervisor-001",
|
||||||
|
"employee_name": "王五(部门主管)",
|
||||||
|
"department": "信息技术部",
|
||||||
|
"position": "主管",
|
||||||
|
"level": "M3",
|
||||||
|
"status": "serving",
|
||||||
|
"is_vip": True,
|
||||||
|
"is_pinned": True,
|
||||||
|
"is_todo": False,
|
||||||
|
"urgency_score": 80,
|
||||||
|
"tags": ["系统升级"],
|
||||||
|
"assigned_agent_id": "dev-agent-001",
|
||||||
|
"collaborating_agent_ids": ["dev-admin-001"],
|
||||||
|
"participants": [],
|
||||||
|
"ai_substantive_reply_count": 3,
|
||||||
|
"impact_scope": 50,
|
||||||
|
"is_blocking": True,
|
||||||
|
"emotion_state": "urgent",
|
||||||
|
"dify_conversation_id": "dify-conv-004",
|
||||||
|
"last_message_at": now - timedelta(minutes=15),
|
||||||
|
"last_message_summary": "ERP 系统升级咨询",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "conv-005",
|
||||||
|
"corp_id": "wwa8c87970b2011f41",
|
||||||
|
"employee_id": "dev-security-001",
|
||||||
|
"employee_name": "赵六(安全团队)",
|
||||||
|
"department": "信息安全部",
|
||||||
|
"position": "安全工程师",
|
||||||
|
"level": "P7",
|
||||||
|
"status": "resolved",
|
||||||
|
"is_vip": False,
|
||||||
|
"is_pinned": False,
|
||||||
|
"is_todo": False,
|
||||||
|
"urgency_score": 20,
|
||||||
|
"tags": ["安全"],
|
||||||
|
"assigned_agent_id": "dev-agent-001",
|
||||||
|
"collaborating_agent_ids": [],
|
||||||
|
"participants": [],
|
||||||
|
"ai_substantive_reply_count": 5,
|
||||||
|
"impact_scope": 1,
|
||||||
|
"is_blocking": False,
|
||||||
|
"emotion_state": "normal",
|
||||||
|
"dify_conversation_id": "dify-conv-005",
|
||||||
|
"last_message_at": now - timedelta(hours=2),
|
||||||
|
"last_message_summary": "已处理:密码策略咨询",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for data in demo_convs:
|
||||||
|
db.add(Conversation(**data))
|
||||||
|
|
||||||
|
logger.info(f"已初始化 {len(demo_convs)} 条 demo 会话(仅 dev 模式)")
|
||||||
|
|
||||||
|
|
||||||
async def _init_system_configs(db, SystemConfig):
|
async def _init_system_configs(db, SystemConfig):
|
||||||
"""初始化系统配置项。"""
|
"""初始化系统配置项。"""
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
@@ -492,6 +680,30 @@ def create_app() -> FastAPI:
|
|||||||
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
|
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 开发模式 Mock OAuth(仅 DEV_MODE=true 时挂载)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# ⚠️ 生产环境严禁启用(DEV_MODE=false 或不设置)
|
||||||
|
# 挂载的端点:
|
||||||
|
# GET /api/dev/login — Mock 登录,跳过企微 OAuth 直接返回 token
|
||||||
|
# GET /api/dev/users — 列出预设 dev 用户
|
||||||
|
# GET /api/dev/health — dev 模式状态自检
|
||||||
|
# 即使挂载了,每个端点内部也会再 _dev_mode_enabled() 二次校验
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
if _is_dev_mode():
|
||||||
|
from app.api.dev_auth import router as dev_auth_router
|
||||||
|
app.include_router(dev_auth_router)
|
||||||
|
logger.warning(
|
||||||
|
"🧪 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"🧪 DEV_MODE 已启用 - Mock OAuth 端点已挂载\n"
|
||||||
|
"🧪 仅供本地开发测试使用,生产环境必须关闭!\n"
|
||||||
|
"🧪 端点列表:\n"
|
||||||
|
"🧪 GET /api/dev/login - Mock 登录\n"
|
||||||
|
"🧪 GET /api/dev/users - 列出预设用户\n"
|
||||||
|
"🧪 GET /api/dev/health - dev 模式状态\n"
|
||||||
|
"🧪 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# 挂载 WebSocket 路由
|
# 挂载 WebSocket 路由
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ redis==5.0.7
|
|||||||
# 数据验证
|
# 数据验证
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# pydantic: 数据验证和设置管理,FastAPI 的核心依赖
|
# pydantic: 数据验证和设置管理,FastAPI 的核心依赖
|
||||||
pydantic==2.7.5
|
# 注意:必须用 2.7.4 或 2.8.0+,2.7.5 被 PyPI yank(清华源/官方源都没有)
|
||||||
|
pydantic==2.7.4
|
||||||
# pydantic-settings: 从环境变量读取配置,支持 .env 文件
|
# pydantic-settings: 从环境变量读取配置,支持 .env 文件
|
||||||
pydantic-settings==2.3.4
|
pydantic-settings==2.3.4
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# 部署后健康检查脚本 — 跑 deploy.sh 后调用
|
||||||
|
# =============================================================================
|
||||||
|
# 用法:bash backend/scripts/post-deploy-healthcheck.sh
|
||||||
|
# 关联:memory/v070-alpha-env-override-bug.md
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONTAINER="${1:-wecom_it_backend}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Post-deploy health check for $CONTAINER"
|
||||||
|
echo "=========================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 1. 容器状态
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "--- 1. 容器状态 ---"
|
||||||
|
STATUS=$(sudo docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>&1 || echo "NOT_FOUND")
|
||||||
|
RESTARTING=$(sudo docker inspect -f '{{.State.Restarting}}' "$CONTAINER" 2>&1 || echo "?")
|
||||||
|
echo " status=$STATUS restarting=$RESTARTING"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. 启动日志(最近 30 行,看有没有错误)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "--- 2. 最近 30 行日志 ---"
|
||||||
|
sudo docker logs --tail 30 "$CONTAINER" 2>&1
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 3. 关键检查:DATABASE_URL 不能含 sqlite
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "--- 3. 容器内 DATABASE_URL 检查(不能含 sqlite) ---"
|
||||||
|
DB_URL=$(sudo docker exec "$CONTAINER" printenv DATABASE_URL 2>&1 || echo "EXEC_FAILED")
|
||||||
|
if echo "$DB_URL" | grep -qi "sqlite"; then
|
||||||
|
echo " ❌ 检测到 sqlite!DATABASE_URL=$DB_URL"
|
||||||
|
echo " 这会导致 backend 启动失败,需要修 .env 或 compose"
|
||||||
|
exit 1
|
||||||
|
elif echo "$DB_URL" | grep -qi "postgresql"; then
|
||||||
|
echo " ✅ DATABASE_URL 是 PostgreSQL:$DB_URL"
|
||||||
|
else
|
||||||
|
echo " ⚠️ DATABASE_URL 不寻常:$DB_URL"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 4. /health 端点
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "--- 4. /health 端点 ---"
|
||||||
|
HEALTH=$(curl -s -w "HTTP %{http_code}" http://127.0.0.1:8000/health 2>&1 || echo "CURL_FAILED")
|
||||||
|
echo " $HEALTH"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 5. alembic 版本号
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "--- 5. alembic 版本号 ---"
|
||||||
|
sudo docker exec -e PGPASSWORD=wecom_secret wecom_it_postgres psql -U wecom -d wecom_it_desk -c "SELECT version_num FROM alembic_version;" 2>&1 | head -5
|
||||||
|
echo
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 6. /version 端点
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
echo "--- 6. /version 端点 ---"
|
||||||
|
curl -s http://127.0.0.1:8000/version 2>&1
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
if [ "$STATUS" = "running" ] && ! echo "$HEALTH" | grep -q "CURL_FAILED"; then
|
||||||
|
echo "✅ 所有检查通过,backend 健康"
|
||||||
|
else
|
||||||
|
echo "❌ 有问题,看上面输出定位"
|
||||||
|
fi
|
||||||
|
echo "=========================================="
|
||||||
@@ -44,7 +44,7 @@ async def h5_client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncCli
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
app.dependency_overrides[get_db] = _override_get_db
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
|
||||||
with patch("app.api.h5._get_redis", return_value=mock_redis):
|
with patch("app.api.h5._get_redis", return_value=mock_redis, create=True):
|
||||||
with patch("redis.asyncio.from_url", return_value=mock_redis):
|
with patch("redis.asyncio.from_url", return_value=mock_redis):
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — Message.id VARCHAR=UUID 500 错误回归测试
|
||||||
|
# =============================================================================
|
||||||
|
# 背景(2026-06-15 事故):
|
||||||
|
# messages.id 在 DB 里是 String(36)/VARCHAR(存的是 UUID 字符串),
|
||||||
|
# 但代码里有几处用 UUID 对象直接比较,导致 PostgreSQL 报
|
||||||
|
# "operator does not exist: character varying = uuid" → 500
|
||||||
|
# 涉及 endpoint:
|
||||||
|
# - h5.py:843 H5 轮询 (after_message_id)
|
||||||
|
# - messages.py:87 坐席端轮询 (before_message_id)
|
||||||
|
# - messages.py:263 坐席端轮询 (after_message_id)
|
||||||
|
# - messages.py:319 撤回消息
|
||||||
|
# - messages.py:371 编辑消息
|
||||||
|
#
|
||||||
|
# 修复方式:所有 Message.id 比较前 str() 包装
|
||||||
|
#
|
||||||
|
# 此测试文件的目的:防止以后改回 UUID 比较(回归保护)
|
||||||
|
#
|
||||||
|
# 验证策略:
|
||||||
|
# - 200 = 修复成功(没崩)
|
||||||
|
# - 500 = 500 bug 回归
|
||||||
|
# - 401/403 = 鉴权被拒(不是 500,也通过)
|
||||||
|
# - 200 但 body code != 0 = 业务错误,只要不是 500 就算过
|
||||||
|
#
|
||||||
|
# 路径前缀说明:
|
||||||
|
# h5.py: router = APIRouter() → endpoint 真实路径是 /h5/...
|
||||||
|
# messages.py: router = APIRouter() → endpoint 真实路径是 /conversations/...
|
||||||
|
# 都不带 /api 前缀(nginx 部署时再 strip)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.conversation import Conversation
|
||||||
|
from app.models.message import Message
|
||||||
|
from tests.conftest import create_test_conversation
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 共享 fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def conversation_in_db(db_session: AsyncSession):
|
||||||
|
"""创建一个会话 + 3 条消息(为防止 nested transaction 不可见,显式 commit)。"""
|
||||||
|
conv = create_test_conversation(employee_id="emp_500_bug", status="serving")
|
||||||
|
db_session.add(conv)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
base_time = datetime(2026, 6, 15, 10, 0, 0)
|
||||||
|
messages = []
|
||||||
|
for i in range(3):
|
||||||
|
m = Message(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
conversation_id=conv.id,
|
||||||
|
sender_type="agent",
|
||||||
|
sender_id=f"agent_{i}",
|
||||||
|
sender_name=f"坐席{i}",
|
||||||
|
content=f"消息{i}",
|
||||||
|
msg_type="text",
|
||||||
|
created_at=base_time,
|
||||||
|
)
|
||||||
|
db_session.add(m)
|
||||||
|
messages.append(m)
|
||||||
|
await db_session.flush()
|
||||||
|
return conv, messages
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def override_employee(client, conversation_in_db):
|
||||||
|
"""覆盖 _get_current_employee 依赖。
|
||||||
|
|
||||||
|
h5.py:139 _get_current_employee 是 async def,所以 dependency_overrides
|
||||||
|
接受 async 函数(会被 FastAPI await)。
|
||||||
|
"""
|
||||||
|
from app.api.h5 import _get_current_employee
|
||||||
|
|
||||||
|
conv, _ = conversation_in_db
|
||||||
|
app = client._transport.app
|
||||||
|
|
||||||
|
async def fake_employee():
|
||||||
|
return conv.employee_id
|
||||||
|
|
||||||
|
app.dependency_overrides[_get_current_employee] = fake_employee
|
||||||
|
yield conv
|
||||||
|
app.dependency_overrides.pop(_get_current_employee, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def override_agent(client):
|
||||||
|
"""覆盖 get_current_agent 依赖,返回一个测试坐席对象。"""
|
||||||
|
from app.api.agents import get_current_agent
|
||||||
|
from app.models.agent import Agent
|
||||||
|
|
||||||
|
app = client._transport.app
|
||||||
|
agent = Agent(user_id="test_agent_500", name="测试坐席", status="online")
|
||||||
|
|
||||||
|
async def fake_agent():
|
||||||
|
return agent
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_agent] = fake_agent
|
||||||
|
yield agent
|
||||||
|
app.dependency_overrides.pop(get_current_agent, None)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_not_500(response, msg=""):
|
||||||
|
"""断言不是 500(防 500 bug 回归)。
|
||||||
|
|
||||||
|
500 才是真 bug。401/403/404/422 都不是 500 bug,只是测试 fixture 不全。
|
||||||
|
"""
|
||||||
|
assert response.status_code != 500, (
|
||||||
|
f"500 bug 回归!status={response.status_code} body={response.text} {msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 回归测试
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestH5MessagePoll:
|
||||||
|
"""H5 端员工轮询 — 验证 after_message_id 类型不会触发 500。
|
||||||
|
|
||||||
|
endpoint: GET /h5/conversations/current/messages/poll?after_message_id=xxx
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_with_str_uuid(self, client, override_employee, conversation_in_db):
|
||||||
|
"""传 str 形式的 UUID(主要场景),不触发 500。"""
|
||||||
|
_, msgs = conversation_in_db
|
||||||
|
response = await client.get(
|
||||||
|
f"/h5/conversations/current/messages/poll?after_message_id={msgs[0].id}"
|
||||||
|
)
|
||||||
|
assert_not_500(response, "str UUID 触发 500")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_with_uuid_object(self, client, override_employee, conversation_in_db):
|
||||||
|
"""传 UUID 对象(不是 str)— 修复前会 500,修复后 str() 包装正常。"""
|
||||||
|
from uuid import UUID as UUIDType
|
||||||
|
|
||||||
|
_, msgs = conversation_in_db
|
||||||
|
uuid_obj = UUIDType(msgs[0].id)
|
||||||
|
response = await client.get(
|
||||||
|
f"/h5/conversations/current/messages/poll?after_message_id={uuid_obj}"
|
||||||
|
)
|
||||||
|
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_with_invalid_uuid(self, client, override_employee):
|
||||||
|
"""传无效 UUID,优雅降级(不应 500)。"""
|
||||||
|
response = await client.get(
|
||||||
|
"/h5/conversations/current/messages/poll?after_message_id=invalid-uuid-format"
|
||||||
|
)
|
||||||
|
assert_not_500(response, "无效 UUID 触发 500")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_without_after(self, client, override_employee):
|
||||||
|
"""不传 after_message_id,正常返回(不应 500)。"""
|
||||||
|
response = await client.get("/h5/conversations/current/messages/poll")
|
||||||
|
assert_not_500(response, "无参数触发 500")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentMessagePoll:
|
||||||
|
"""坐席端轮询 — 验证 after_message_id 类型不会触发 500。
|
||||||
|
|
||||||
|
endpoint: GET /conversations/{id}/messages/poll?after_message_id=xxx
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_poll_with_str_uuid(self, client, override_agent, conversation_in_db):
|
||||||
|
"""坐席端轮询 str UUID,不触发 500。"""
|
||||||
|
conv, msgs = conversation_in_db
|
||||||
|
response = await client.get(
|
||||||
|
f"/conversations/{conv.id}/messages/poll?after_message_id={msgs[0].id}"
|
||||||
|
)
|
||||||
|
assert_not_500(response, "str UUID 触发 500")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_poll_with_uuid_object(self, client, override_agent, conversation_in_db):
|
||||||
|
"""坐席端轮询 UUID 对象,不触发 500(防回归)。"""
|
||||||
|
from uuid import UUID as UUIDType
|
||||||
|
|
||||||
|
conv, msgs = conversation_in_db
|
||||||
|
uuid_obj = UUIDType(msgs[0].id)
|
||||||
|
response = await client.get(
|
||||||
|
f"/conversations/{conv.id}/messages/poll?after_message_id={uuid_obj}"
|
||||||
|
)
|
||||||
|
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecallMessage:
|
||||||
|
"""撤回消息 — message_id 类型不会触发 500。"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recall_with_str_uuid(self, client, override_agent, conversation_in_db):
|
||||||
|
"""撤回消息传 str UUID,不触发 500。"""
|
||||||
|
_, msgs = conversation_in_db
|
||||||
|
msgs[0].sender_id = override_agent.user_id
|
||||||
|
msgs[0].sender_type = "agent"
|
||||||
|
msgs[0].recallable_until = datetime(2099, 12, 31)
|
||||||
|
|
||||||
|
response = await client.post(f"/messages/{msgs[0].id}/recall")
|
||||||
|
assert_not_500(response, "str UUID 触发 500")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recall_with_uuid_object(self, client, override_agent, conversation_in_db):
|
||||||
|
"""撤回消息传 UUID 对象,不触发 500(防回归)。"""
|
||||||
|
from uuid import UUID as UUIDType
|
||||||
|
|
||||||
|
_, msgs = conversation_in_db
|
||||||
|
msgs[0].sender_id = override_agent.user_id
|
||||||
|
msgs[0].sender_type = "agent"
|
||||||
|
msgs[0].recallable_until = datetime(2099, 12, 31)
|
||||||
|
|
||||||
|
uuid_obj = UUIDType(msgs[0].id)
|
||||||
|
response = await client.post(f"/messages/{uuid_obj}/recall")
|
||||||
|
assert_not_500(response, "UUID 对象触发 500,str 包装回归!")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageIdStrRequirement:
|
||||||
|
"""单元测试:验证 Message.id 列必须是 String,以及 str 比较能工作。"""
|
||||||
|
|
||||||
|
def test_message_id_column_is_string_type(self):
|
||||||
|
"""Message.id 列类型必须是 String,不是 UUID(防止改回 UUID 类型)。"""
|
||||||
|
col_type = Message.__table__.c.id.type
|
||||||
|
assert isinstance(col_type, String), (
|
||||||
|
f"Message.id 必须是 String 类型,实际是 {type(col_type).__name__},"
|
||||||
|
"改回 UUID 类型会导致 PostgreSQL 报 'character varying = uuid'"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_query_with_str_id_succeeds(self, db_session: AsyncSession, conversation_in_db):
|
||||||
|
"""直接查 Message(id='uuid-string') 应成功。"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
_, msgs = conversation_in_db
|
||||||
|
stmt = select(Message).where(Message.id == str(msgs[0].id))
|
||||||
|
result = await db_session.execute(stmt)
|
||||||
|
found = result.scalars().first()
|
||||||
|
assert found is not None
|
||||||
|
assert found.id == msgs[0].id
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"name": "账号密码 / SSO 登录故障排查",
|
||||||
|
"category": "account",
|
||||||
|
"description": "员工忘记密码、账号被锁、SSO 单点登录失败、AD 域账号同步异常",
|
||||||
|
"estimated_time": 6,
|
||||||
|
"difficulty": 1,
|
||||||
|
"tags": ["账号", "密码", "SSO", "AD域", "登录"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-acct-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认员工使用哪种登录方式(域账号/企微SSO/邮箱SSO)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-acct-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "是否提示账号已锁定?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-acct-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "AD 管理控制台解锁账号 + 重置临时密码",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-acct-4",
|
||||||
|
"type": "step",
|
||||||
|
"label": "通知员工首次登录需修改密码",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-acct-5",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "员工能正常登录?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-acct-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访确认 + 提醒密码保管"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-acct-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:信息安全团队"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-acct-8",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认密码是否过期(>90天)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-acct-9",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "SSO 登录页能打开?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-acct-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "引导员工走自助密码重置流程",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-acct-11",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "重置邮件是否收到?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-acct-12",
|
||||||
|
"type": "step",
|
||||||
|
"label": "按邮件链接重置 + 回访"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-acct-13",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查邮箱/反垃圾/电话二次验证"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-acct-14",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查浏览器代理 + 缓存 + 尝试无痕模式",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-acct-15",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "换浏览器/无痕能打开?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-acct-16",
|
||||||
|
"type": "step",
|
||||||
|
"label": "指导员工清除原浏览器缓存"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-acct-17",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:检查 SSO 网关状态"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"name": "电脑 / Windows 系统故障排查",
|
||||||
|
"category": "system",
|
||||||
|
"description": "员工电脑蓝屏、死机、卡顿、开机黑屏、Windows 更新失败",
|
||||||
|
"estimated_time": 15,
|
||||||
|
"difficulty": 3,
|
||||||
|
"tags": ["电脑", "Windows", "蓝屏", "系统更新", "卡顿"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-sys-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认故障现象(蓝屏代码/卡顿/黑屏/无法开机)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "电脑能正常开机进入桌面?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-sys-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "引导员工打开任务管理器查看资源占用",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-4",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "CPU/内存/磁盘哪项占用高?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-sys-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "按占用类型分别处理:",
|
||||||
|
"status": "current",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "CPU高:结束异常进程,查启动项",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-sys-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "内存高:检查泄漏进程,加内存条",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-sys-8",
|
||||||
|
"type": "step",
|
||||||
|
"label": "磁盘100%:查大文件/重做系统考虑",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-sys-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查最近安装的软件/驱动/更新",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-10",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "回滚后是否恢复?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-sys-11",
|
||||||
|
"type": "step",
|
||||||
|
"label": "标记该软件/更新为不兼容,记录案例"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-sys-12",
|
||||||
|
"type": "step",
|
||||||
|
"label": "进入安全模式进一步排查"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-sys-13",
|
||||||
|
"type": "step",
|
||||||
|
"label": "判断开机阶段(BIOS/启动管理器/登录界面)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-14",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "能进安全模式?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-sys-15",
|
||||||
|
"type": "step",
|
||||||
|
"label": "在安全模式卸载最近驱动/更新",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-16",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "重启后正常?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-sys-17",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访确认 + 记录故障点"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-sys-18",
|
||||||
|
"type": "step",
|
||||||
|
"label": "备份数据后考虑重装系统"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-sys-19",
|
||||||
|
"type": "step",
|
||||||
|
"label": "硬件层故障:硬盘/内存条/主板",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-sys-20",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "外接显示器/拔内存条有变化?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-sys-21",
|
||||||
|
"type": "step",
|
||||||
|
"label": "对症更换硬件(联系硬件供应商)"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-sys-22",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:送修 / 申请备用机"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"name": "企业微信 / 协作工具故障排查",
|
||||||
|
"category": "wecom",
|
||||||
|
"description": "企微登录失败、消息发不出、群文件无法下载、视频会议卡顿、审批打不开",
|
||||||
|
"estimated_time": 8,
|
||||||
|
"difficulty": 2,
|
||||||
|
"tags": ["企微", "WeCom", "消息", "视频会议", "审批", "协作"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-wc-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认故障模块(消息/会议/审批/通讯录/文件)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-wc-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "能否登录企微(手机/电脑端)?",
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-wc-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "引导员工:重新扫码登录/更新企微版本",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-wc-4",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "重新登录成功?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-wc-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访确认其他功能也正常"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-wc-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查公司是否全员断网/账号是否离职"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-wc-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "按故障模块分别处理:",
|
||||||
|
"status": "current",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-wc-8",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【消息】发不出/收不到:检查网络 + 退出重登 + 清缓存",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-wc-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【视频会议】卡顿/掉线:检查带宽(>2Mbps) + 关闭其他视频",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-wc-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【审批】打不开:确认审批权限 + 联系审批管理员",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-wc-11",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【文件】下载失败:检查存储空间 + 重新下载",
|
||||||
|
"status": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-wc-12",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【通讯录】看不到新同事:引导同步通讯录",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-wc-13",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "处理后是否解决?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-wc-14",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访 + 记录案例到知识库"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-wc-15",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:企微企业管理员 / 厂商支持",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-wc-16",
|
||||||
|
"type": "step",
|
||||||
|
"label": "提供工单截图 + 故障时间 + 员工 userid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "VPN / 远程办公故障排查",
|
||||||
|
"category": "vpn",
|
||||||
|
"description": "员工无法连接公司 VPN,或连接后访问内网失败,或频繁掉线",
|
||||||
|
"estimated_time": 8,
|
||||||
|
"difficulty": 2,
|
||||||
|
"tags": ["VPN", "远程办公", "aTrust", "网络"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-vpn-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认员工当前网络环境(在家/出差/咖啡厅)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-vpn-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "VPN 客户端能否打开登录页?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-vpn-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查账号密码 + 二次认证",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-vpn-4",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "是否连接成功?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-vpn-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访确认可访问内网系统"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-vpn-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "清除 DNS 缓存 + 重连 aTrust"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-vpn-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级 VPN 客户端到最新版",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-vpn-8",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "重试能否登录?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-vpn-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访确认"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-vpn-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:信息安全团队(提供 userid + 时间)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "企业邮箱故障排查",
|
||||||
|
"category": "email",
|
||||||
|
"description": "员工邮箱登录失败、收发异常、附件打不开、签名问题",
|
||||||
|
"estimated_time": 7,
|
||||||
|
"difficulty": 2,
|
||||||
|
"tags": ["邮箱", "Outlook", "Foxmail", "登录", "附件"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-email-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认邮箱客户端(Outlook/Foxmail/网页/手机)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-email-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "能否登录网页邮箱?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-email-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "说明账号本身可用,问题在客户端",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-email-4",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "是否收不到新邮件?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-email-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查反垃圾设置 + 邮件规则 + 邮箱配额"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-email-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查 Outlook 缓存 + 重建索引 + 检查 PST 文件大小"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-email-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查账号是否锁定 + 密码是否过期",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-email-8",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "重置密码后能否登录?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-email-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访 + 通知修改其他系统密码"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-email-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:邮件管理员(提供 userid + 错误截图)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "网络 / WiFi 故障排查",
|
||||||
|
"category": "network",
|
||||||
|
"description": "员工连不上公司 WiFi、有线网慢、IP 冲突、WiFi 认证失败、丢包",
|
||||||
|
"estimated_time": 10,
|
||||||
|
"difficulty": 2,
|
||||||
|
"tags": ["网络", "WiFi", "有线", "IP冲突", "丢包"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-net-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认故障范围(单个员工/同楼层/全公司)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-net-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "影响范围多大?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-net-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【全公司/楼层】立即升级二线:网络团队",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-net-4",
|
||||||
|
"type": "step",
|
||||||
|
"label": "同时记录:故障时间 + 影响人数 + 现场照片"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-net-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【单个员工】继续单点排查",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-net-6",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "有线网 or WiFi?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-net-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查网线 + 换端口 + 重新拨号",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-net-8",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "换端口能用?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-net-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "原端口硬件故障,工单报修"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-net-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "检查 IP 冲突:ipconfig /all + 释放续租"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-net-11",
|
||||||
|
"type": "step",
|
||||||
|
"label": "WiFi 排查:重连 + 忘记网络 + 检查 SSID",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-net-12",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "其他员工同位置能用 WiFi?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-net-13",
|
||||||
|
"type": "step",
|
||||||
|
"label": "员工设备问题:重装网卡驱动 + 升级系统"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-net-14",
|
||||||
|
"type": "step",
|
||||||
|
"label": "AP 信号弱:升级二线查 AP 部署"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "打印机 / 外设故障排查",
|
||||||
|
"category": "printer",
|
||||||
|
"description": "员工打印失败、卡纸、驱动问题、扫描仪、U盘识别",
|
||||||
|
"estimated_time": 6,
|
||||||
|
"difficulty": 1,
|
||||||
|
"tags": ["打印", "扫描", "U盘", "外设", "驱动"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-print-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认外设类型(打印/扫描/U盘/其他)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-print-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "打印机型号?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-print-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【打印】按故障现象分流:",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-print-4",
|
||||||
|
"type": "step",
|
||||||
|
"label": "卡纸:打开盖板 + 按箭头方向抽纸 + 检查纸槽"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-print-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "脱机:重新添加打印机 + 检查网络(IP 直连 or 服务器共享)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-print-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "驱动异常:卸载重装 + 选对型号 + 重启打印服务"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-print-7",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "其他员工同打印机能用?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-print-8",
|
||||||
|
"type": "step",
|
||||||
|
"label": "员工电脑问题:换电脑测试确认"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-print-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:硬件供应商(联系信息见公告)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-print-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【扫描仪/其他外设】:",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-print-11",
|
||||||
|
"type": "step",
|
||||||
|
"label": "扫描仪:检查 USB 连接 + 重新装驱动 + 测试扫描"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-print-12",
|
||||||
|
"type": "step",
|
||||||
|
"label": "U盘:插入其他电脑测试 + 检查文件系统(ExFAT 兼容性)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-print-13",
|
||||||
|
"type": "step",
|
||||||
|
"label": "其他外设:走通用流程(查线/换口/换电脑/重装驱动)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "软件 / 应用故障排查",
|
||||||
|
"category": "software",
|
||||||
|
"description": "员工软件装不上、闪退、license 过期、版本不兼容、Office/PS/财务软件等",
|
||||||
|
"estimated_time": 8,
|
||||||
|
"difficulty": 2,
|
||||||
|
"tags": ["软件", "Office", "安装", "闪退", "license", "财务"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-soft-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认软件名 + 版本(让员工截图)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-soft-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "员工是否有管理员权限安装?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-soft-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【管理员】继续自助排查:",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-soft-4",
|
||||||
|
"type": "step",
|
||||||
|
"label": "装不上:检查系统版本兼容性 + 关杀毒软件 + 管理员运行"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-soft-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "闪退:看 Windows 事件日志 + 找 crash dump"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-soft-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "license 过期:走 IT 资产流程申请续期(申请单见知识库)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-soft-7",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【普通员工】坐席远程协助安装:",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-soft-8",
|
||||||
|
"type": "step",
|
||||||
|
"label": "常用软件清单(从软件中心/SCCM):Office、Adobe、Foxmail、企微"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-soft-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "非常用软件:需走软件申请流程(部门主管审批 → IT 评估)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-soft-10",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "远程能否解决?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-soft-11",
|
||||||
|
"type": "step",
|
||||||
|
"label": "回访确认"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-soft-12",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:对应软件负责人"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "硬件 / 桌面设备故障排查",
|
||||||
|
"category": "hardware",
|
||||||
|
"description": "员工显示器、键盘鼠标、耳机、视频会议摄像头、笔记本电池等",
|
||||||
|
"estimated_time": 10,
|
||||||
|
"difficulty": 2,
|
||||||
|
"tags": ["硬件", "显示器", "键盘", "鼠标", "耳机", "摄像头"],
|
||||||
|
"root_node": {
|
||||||
|
"id": "fc-hw-1",
|
||||||
|
"type": "step",
|
||||||
|
"label": "确认设备类型(显示器/键鼠/耳机/摄像头/其他)",
|
||||||
|
"status": "pending",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-hw-2",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "故障设备能换一台测试吗?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-hw-3",
|
||||||
|
"type": "step",
|
||||||
|
"label": "换设备测试,确认是设备本身问题:",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-hw-4",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【显示器】:换视频线(HDMI/DP/VGA) + 检查分辨率"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-hw-5",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【键鼠】:换 USB 口 + 换电池 + 蓝牙重新配对"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-hw-6",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【耳机/摄像头】:检查 USB/3.5mm + 隐私盖 + 系统权限"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-hw-7",
|
||||||
|
"type": "decision",
|
||||||
|
"label": "换设备后正常?",
|
||||||
|
"yes_branch": {
|
||||||
|
"id": "fc-hw-8",
|
||||||
|
"type": "step",
|
||||||
|
"label": "原设备故障:走 IT 资产报废/更换流程"
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-hw-9",
|
||||||
|
"type": "step",
|
||||||
|
"label": "电脑端问题:检查驱动 + 系统设置"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"no_branch": {
|
||||||
|
"id": "fc-hw-10",
|
||||||
|
"type": "step",
|
||||||
|
"label": "【笔记本内嵌设备】:屏幕/键盘/电池/CPU 风扇",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "fc-hw-11",
|
||||||
|
"type": "step",
|
||||||
|
"label": "走送修流程(备份数据 → IT 出具送修单 → 厂商维修)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-hw-12",
|
||||||
|
"type": "step",
|
||||||
|
"label": "需要备用机:走 IT 资产借用流程(最长 2 周)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fc-hw-13",
|
||||||
|
"type": "step",
|
||||||
|
"label": "升级二线:硬件供应商(联系信息见公告)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
把 9 套排查流程图 JSON 合并到一个数组,输出 00-all.json(便于一次性 import)。
|
||||||
|
用法:python build_all.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 1. 找 9 个单文件(排除 00-all.json 和 build_all.py)
|
||||||
|
files = sorted(HERE.glob("[0-9][0-9]-*.json"))
|
||||||
|
if not files:
|
||||||
|
print("❌ 没找到任何 0X-*.json 文件")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"📦 找到 {len(files)} 个模板文件:")
|
||||||
|
for f in files:
|
||||||
|
print(f" - {f.name}")
|
||||||
|
|
||||||
|
# 2. 逐个读 + 校验
|
||||||
|
templates = []
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
with open(f, "r", encoding="utf-8") as fp:
|
||||||
|
tpl = json.load(fp)
|
||||||
|
# 简单校验
|
||||||
|
for required in ("name", "category", "root_node"):
|
||||||
|
if required not in tpl:
|
||||||
|
raise ValueError(f"缺少必要字段: {required}")
|
||||||
|
templates.append(tpl)
|
||||||
|
print(f" ✅ {f.name}: {tpl['name']} ({len(json.dumps(tpl, ensure_ascii=False))} 字符)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ {f.name}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 3. 输出汇总文件
|
||||||
|
out = HERE / "00-all.json"
|
||||||
|
with open(out, "w", encoding="utf-8") as fp:
|
||||||
|
json.dump(templates, fp, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
print(f"\n✅ 已生成 {out.name} (共 {len(templates)} 套)")
|
||||||
|
print(f"\n💡 接下来你可以:")
|
||||||
|
print(f" 1. 打开 {out.name} 预览 9 套完整内容")
|
||||||
|
print(f" 2. 在 Admin 后台的「排查流程图」页 → 「导入 JSON」选择此文件")
|
||||||
|
print(f" 3. 或调用后端 API:")
|
||||||
|
print(f" for tpl in templates: POST /api/troubleshooting-templates")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 本地开发环境 Docker Compose
|
||||||
|
# =============================================================================
|
||||||
|
# 目标:本地电脑(Windows + Docker Desktop)
|
||||||
|
# 用途:开发 + 测试,不依赖企微 OAuth,代码 volume mount 自动 reload
|
||||||
|
# 用法:
|
||||||
|
# 1. cp .env.example .env.dev (编辑填值,或直接用 .env.dev 模板)
|
||||||
|
# 2. docker compose -f docker-compose.dev.yml up -d
|
||||||
|
# 3. 前端 4 端各跑 pnpm dev(Vite proxy /api → backend:8000)
|
||||||
|
# 启动后:
|
||||||
|
# - Backend: http://localhost:8000 (Swagger: /docs)
|
||||||
|
# - Postgres: localhost:5432
|
||||||
|
# - Redis: localhost:6379
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# PostgreSQL 16 — 开发数据库
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: dev_wecom_postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-wecom}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wecom_dev}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-wecom_it_desk_dev}
|
||||||
|
ports:
|
||||||
|
- "5432:5432" # 暴露到宿主机,方便用 Navicat/psql 连
|
||||||
|
volumes:
|
||||||
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wecom}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- dev-net
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Redis 7 — 开发缓存
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: dev_wecom_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --save 900 1 --save 300 10
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_dev_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- dev-net
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Backend — 开发模式(代码 volume mount + uvicorn --reload)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile.dev # dev 版(无需 apt 装 gcc,快)
|
||||||
|
image: wecom-it-desk-backend:dev
|
||||||
|
container_name: dev_wecom_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env.dev
|
||||||
|
environment:
|
||||||
|
# 容器内用 service name(host 是 localhost,容器内是 postgres/redis)
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-wecom}:${POSTGRES_PASSWORD:-wecom_dev}@postgres:5432/${POSTGRES_DB:-wecom_it_desk_dev}
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- DEV_MODE=true # 开启 Mock 企微 OAuth
|
||||||
|
- CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176
|
||||||
|
# PYTHONPATH 必须含 /app,否则 alembic upgrade head 跑 env.py 时
|
||||||
|
# `from app.config import settings` 会 ModuleNotFoundError
|
||||||
|
# (alembic 1.13+ 不再默认 prepend cwd 到 sys.path)
|
||||||
|
- PYTHONPATH=/app
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # 暴露到宿主机
|
||||||
|
volumes:
|
||||||
|
# 关键:volume mount 源码,改代码自动 reload
|
||||||
|
- ./backend/app:/app/app
|
||||||
|
- ./backend/alembic:/app/alembic
|
||||||
|
- ./backend/scripts:/app/scripts
|
||||||
|
command: >
|
||||||
|
sh -c "alembic upgrade head &&
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- dev-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_dev_data:
|
||||||
|
redis_dev_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dev-net:
|
||||||
|
driver: bridge
|
||||||
@@ -13,11 +13,18 @@
|
|||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/state": "^6.4.1",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
|
"@codemirror/view": "^6.26.3",
|
||||||
"@element-plus/icons-vue": "^2.3.0",
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"element-plus": "^2.7.0",
|
"element-plus": "^2.7.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
|
"vue-codemirror": "^6.0.1",
|
||||||
|
"vue-json-pretty": "^2.2.4",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 排查模板 API 客户端
|
||||||
|
// =============================================================================
|
||||||
|
// 对接后端 /api/troubleshooting-templates 5 个 REST 端点
|
||||||
|
// 5 个端点:GET 列表 / GET 详情 / POST 新建 / PUT 更新 / DELETE 删除
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 类型定义
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 步骤节点(顺序执行) */
|
||||||
|
export interface PathStepNode {
|
||||||
|
id: string
|
||||||
|
type: 'step'
|
||||||
|
label: string
|
||||||
|
status?: 'done' | 'current' | 'pending'
|
||||||
|
children?: FlowchartNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 决策节点(yes/no 分支) */
|
||||||
|
export interface DecisionNode {
|
||||||
|
id: string
|
||||||
|
type: 'decision'
|
||||||
|
label: string
|
||||||
|
status?: 'done' | 'current' | 'pending'
|
||||||
|
yes_branch?: FlowchartNode
|
||||||
|
no_branch?: FlowchartNode
|
||||||
|
children?: FlowchartNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流程图节点(递归) */
|
||||||
|
export type FlowchartNode = PathStepNode | DecisionNode
|
||||||
|
|
||||||
|
/** 排查模板 */
|
||||||
|
export interface TroubleshootingTemplate {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
description?: string
|
||||||
|
estimated_time?: number
|
||||||
|
difficulty?: number
|
||||||
|
tags?: string[]
|
||||||
|
root_node: FlowchartNode
|
||||||
|
version?: string
|
||||||
|
status?: 'draft' | 'published'
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
// 后端可能附加的统计字段
|
||||||
|
nodeCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API 响应通用结构 */
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Axios 实例(继承全局 baseURL)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const http = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5 个端点
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** GET /api/troubleshooting-templates — 获取模板列表 */
|
||||||
|
export async function listTemplates(): Promise<TroubleshootingTemplate[]> {
|
||||||
|
const res = await http.get<ApiResponse<TroubleshootingTemplate[]>>(
|
||||||
|
'/troubleshooting-templates'
|
||||||
|
)
|
||||||
|
return res.data.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/troubleshooting-templates/{id} — 获取模板详情 */
|
||||||
|
export async function getTemplate(id: string): Promise<TroubleshootingTemplate> {
|
||||||
|
const res = await http.get<ApiResponse<TroubleshootingTemplate>>(
|
||||||
|
`/troubleshooting-templates/${id}`
|
||||||
|
)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/troubleshooting-templates — 新建模板 */
|
||||||
|
export async function createTemplate(
|
||||||
|
data: TroubleshootingTemplate
|
||||||
|
): Promise<TroubleshootingTemplate> {
|
||||||
|
const res = await http.post<ApiResponse<TroubleshootingTemplate>>(
|
||||||
|
'/troubleshooting-templates',
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /api/troubleshooting-templates/{id} — 更新模板 */
|
||||||
|
export async function updateTemplate(
|
||||||
|
id: string,
|
||||||
|
data: TroubleshootingTemplate
|
||||||
|
): Promise<TroubleshootingTemplate> {
|
||||||
|
const res = await http.put<ApiResponse<TroubleshootingTemplate>>(
|
||||||
|
`/troubleshooting-templates/${id}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/troubleshooting-templates/{id} — 删除模板 */
|
||||||
|
export async function deleteTemplate(id: string): Promise<void> {
|
||||||
|
await http.delete(`/troubleshooting-templates/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具:把对象格式化成 JSON 字符串(带缩进) */
|
||||||
|
export function formatJson(obj: unknown): string {
|
||||||
|
return JSON.stringify(obj, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具:校验 JSON 字符串是否合法,返回 {ok, data, error} */
|
||||||
|
export function validateJson(
|
||||||
|
text: string
|
||||||
|
): { ok: true; data: TroubleshootingTemplate } | { ok: false; error: string } {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text) as TroubleshootingTemplate
|
||||||
|
return { ok: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error
|
||||||
|
return { ok: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具:统计节点数(递归) */
|
||||||
|
export function countNodes(node: FlowchartNode | undefined): number {
|
||||||
|
if (!node) return 0
|
||||||
|
let count = 1
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
count += countNodes(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 决策节点的 yes/no 分支
|
||||||
|
if ('yes_branch' in node && node.yes_branch) {
|
||||||
|
count += countNodes(node.yes_branch)
|
||||||
|
}
|
||||||
|
if ('no_branch' in node && node.no_branch) {
|
||||||
|
count += countNodes(node.no_branch)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具:统计决策节点数 */
|
||||||
|
export function countDecisions(node: FlowchartNode | undefined): number {
|
||||||
|
if (!node) return 0
|
||||||
|
let count = node.type === 'decision' ? 1 : 0
|
||||||
|
if (node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
count += countDecisions(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('yes_branch' in node && node.yes_branch) {
|
||||||
|
count += countDecisions(node.yes_branch)
|
||||||
|
}
|
||||||
|
if ('no_branch' in node && node.no_branch) {
|
||||||
|
count += countDecisions(node.no_branch)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 排查流程图 — 在线编辑器对话框
|
||||||
|
// =============================================================================
|
||||||
|
// 双栏布局(用户已确认 A 方案):
|
||||||
|
// - 左 50%:CodeMirror JSON 源码(语法高亮 + 行号 + oneDark 主题)
|
||||||
|
// - 右 50%:vue-json-pretty 树形预览(只读)
|
||||||
|
// - 顶部:基本信息(名称/分类/标签/时间/难度)
|
||||||
|
// - 底栏:格式化/复制/导出/取消/保存
|
||||||
|
// ============================================================================= -->
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="(v) => $emit('update:modelValue', v)"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="80%"
|
||||||
|
top="5vh"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
>
|
||||||
|
<!-- ===== 顶部基本信息 ===== -->
|
||||||
|
<div class="basic-info">
|
||||||
|
<el-form :inline="true" size="small" label-width="70px">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="form.name" placeholder="VPN 远程办公故障排查" style="width: 240px" :disabled="readonly" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="form.category" placeholder="选择分类" style="width: 130px" :disabled="readonly">
|
||||||
|
<el-option v-for="c in CATEGORY_OPTIONS" :key="c" :label="c" :value="c" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预估时间">
|
||||||
|
<el-input-number v-model="form.estimated_time" :min="1" :max="120" size="small" :disabled="readonly" />
|
||||||
|
<span class="suffix">分钟</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="难度">
|
||||||
|
<el-rate v-model="form.difficulty" :max="5" :disabled="readonly" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签">
|
||||||
|
<el-select
|
||||||
|
v-model="form.tags"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
placeholder="按 Enter 添加"
|
||||||
|
style="width: 240px"
|
||||||
|
:disabled="readonly"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 双栏编辑区 ===== -->
|
||||||
|
<div class="dual-pane">
|
||||||
|
<!-- 左:JSON 源码编辑器 -->
|
||||||
|
<div class="pane left-pane">
|
||||||
|
<div class="pane-header">
|
||||||
|
<span class="pane-title">📝 JSON 源码</span>
|
||||||
|
<span class="pane-stats">
|
||||||
|
节点 {{ stats.nodes }} · 决策 {{ stats.decisions }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pane-body">
|
||||||
|
<codemirror
|
||||||
|
v-model="jsonText"
|
||||||
|
:options="cmOptions"
|
||||||
|
:height="`${editorHeight}px`"
|
||||||
|
:style="{ height: `${editorHeight}px` }"
|
||||||
|
@change="onCodeChange"
|
||||||
|
:readonly="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右:树形预览 -->
|
||||||
|
<div class="pane right-pane">
|
||||||
|
<div class="pane-header">
|
||||||
|
<span class="pane-title">🌳 树形预览</span>
|
||||||
|
<span class="pane-stats">
|
||||||
|
<el-tag v-if="parseOk" type="success" size="small">✅ JSON 有效</el-tag>
|
||||||
|
<el-tag v-else type="danger" size="small">❌ {{ parseError }}</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pane-body">
|
||||||
|
<div v-if="parseOk" class="tree-wrapper">
|
||||||
|
<vue-json-pretty
|
||||||
|
:data="parsedData"
|
||||||
|
:show-length="true"
|
||||||
|
:show-line="true"
|
||||||
|
:path="rootPath"
|
||||||
|
:deep="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="tree-error">
|
||||||
|
<p>❌ JSON 解析失败</p>
|
||||||
|
<pre>{{ parseError }}</pre>
|
||||||
|
<p class="hint">请检查左栏 JSON 语法,例如:</p>
|
||||||
|
<ul>
|
||||||
|
<li>末尾的逗号</li>
|
||||||
|
<li>未闭合的引号或大括号</li>
|
||||||
|
<li>未转义的字符</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 底栏按钮 ===== -->
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<div class="footer-left">
|
||||||
|
<el-button size="small" @click="handleFormat" :disabled="readonly">
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
格式化
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="handleCopy" :disabled="!parseOk">
|
||||||
|
<el-icon><CopyDocument /></el-icon>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="handleExport" :disabled="!parseOk">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出此条
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="footer-right">
|
||||||
|
<el-button size="small" @click="handleCancel">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!parseOk || readonly"
|
||||||
|
:loading="saving"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
<el-icon><Check /></el-icon>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// =============================================================================
|
||||||
|
// imports
|
||||||
|
// =============================================================================
|
||||||
|
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { MagicStick, CopyDocument, Download, Check } from '@element-plus/icons-vue'
|
||||||
|
import { Codemirror } from 'vue-codemirror'
|
||||||
|
import { json } from '@codemirror/lang-json'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
import VueJsonPretty from 'vue-json-pretty'
|
||||||
|
import 'vue-json-pretty/lib/styles.css'
|
||||||
|
import {
|
||||||
|
formatJson,
|
||||||
|
validateJson,
|
||||||
|
countNodes,
|
||||||
|
countDecisions,
|
||||||
|
type TroubleshootingTemplate,
|
||||||
|
type FlowchartNode,
|
||||||
|
} from '@/api/troubleshooting'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// props & emits
|
||||||
|
// =============================================================================
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
template: TroubleshootingTemplate | null
|
||||||
|
readonly?: boolean
|
||||||
|
saving?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
save: [template: TroubleshootingTemplate]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 常量
|
||||||
|
// =============================================================================
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
'vpn', 'email', 'account', 'system', 'network',
|
||||||
|
'printer', 'software', 'hardware', 'wecom', 'other',
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY_TEMPLATE: TroubleshootingTemplate = {
|
||||||
|
name: '',
|
||||||
|
category: 'system',
|
||||||
|
description: '',
|
||||||
|
estimated_time: 5,
|
||||||
|
difficulty: 2,
|
||||||
|
tags: [],
|
||||||
|
root_node: {
|
||||||
|
id: 'fc-new-1',
|
||||||
|
type: 'step',
|
||||||
|
label: '请修改此步骤',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 响应式状态
|
||||||
|
// =============================================================================
|
||||||
|
const form = reactive<TroubleshootingTemplate>({ ...EMPTY_TEMPLATE })
|
||||||
|
const jsonText = ref<string>('')
|
||||||
|
const parseOk = ref<boolean>(true)
|
||||||
|
const parseError = ref<string>('')
|
||||||
|
const parsedData = ref<TroubleshootingTemplate | null>(null)
|
||||||
|
const editorHeight = ref<number>(500)
|
||||||
|
|
||||||
|
// 编辑器配置
|
||||||
|
const cmOptions = {
|
||||||
|
mode: 'application/json',
|
||||||
|
theme: 'oneDark',
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: true,
|
||||||
|
tabSize: 2,
|
||||||
|
indentUnit: 2,
|
||||||
|
smartIndent: true,
|
||||||
|
matchBrackets: true,
|
||||||
|
autoCloseBrackets: true,
|
||||||
|
foldGutter: true,
|
||||||
|
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
|
||||||
|
extensions: [json(), oneDark],
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 计算属性
|
||||||
|
// =============================================================================
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (props.readonly) return `👁 预览: ${form.name || '未命名'}`
|
||||||
|
return props.template?.id ? `✎ 编辑: ${form.name || '未命名'}` : '+ 新建流程图'
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = computed(() => ({
|
||||||
|
nodes: parseOk.value && parsedData.value ? countNodes(parsedData.value.root_node) : 0,
|
||||||
|
decisions: parseOk.value && parsedData.value ? countDecisions(parsedData.value.root_node) : 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rootPath = computed(() => 'root')
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// watch
|
||||||
|
// =============================================================================
|
||||||
|
watch(
|
||||||
|
() => props.template,
|
||||||
|
(newTpl) => {
|
||||||
|
if (newTpl) {
|
||||||
|
Object.assign(form, newTpl)
|
||||||
|
jsonText.value = formatJson(newTpl)
|
||||||
|
onCodeChange()
|
||||||
|
} else {
|
||||||
|
Object.assign(form, EMPTY_TEMPLATE)
|
||||||
|
jsonText.value = formatJson(EMPTY_TEMPLATE)
|
||||||
|
onCodeChange()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(jsonText, () => {
|
||||||
|
// 实时同步到 form(让顶部表单跟着 JSON 变)
|
||||||
|
if (parseOk.value && parsedData.value) {
|
||||||
|
form.name = parsedData.value.name || form.name
|
||||||
|
form.category = parsedData.value.category || form.category
|
||||||
|
form.estimated_time = parsedData.value.estimated_time ?? form.estimated_time
|
||||||
|
form.difficulty = parsedData.value.difficulty ?? form.difficulty
|
||||||
|
form.tags = parsedData.value.tags || form.tags
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 生命周期
|
||||||
|
// =============================================================================
|
||||||
|
function updateEditorHeight() {
|
||||||
|
editorHeight.value = Math.max(window.innerHeight - 380, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateEditorHeight()
|
||||||
|
window.addEventListener('resize', updateEditorHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateEditorHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 方法
|
||||||
|
// =============================================================================
|
||||||
|
function onCodeChange() {
|
||||||
|
const result = validateJson(jsonText.value)
|
||||||
|
if (result.ok) {
|
||||||
|
parseOk.value = true
|
||||||
|
parseError.value = ''
|
||||||
|
parsedData.value = result.data
|
||||||
|
} else {
|
||||||
|
parseOk.value = false
|
||||||
|
parseError.value = result.error
|
||||||
|
parsedData.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormat() {
|
||||||
|
if (parseOk.value && parsedData.value) {
|
||||||
|
jsonText.value = formatJson(parsedData.value)
|
||||||
|
ElMessage.success('JSON 已格式化')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(jsonText.value)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动 Ctrl+C')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
if (!parseOk.value || !parsedData.value) return
|
||||||
|
const blob = new Blob([jsonText.value], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${(form.name || 'flowchart').replace(/\s+/g, '-')}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('已导出')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
if (!parseOk.value || !parsedData.value) {
|
||||||
|
ElMessage.error('JSON 无效,无法保存')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 合并:form 基本信息 + parsedData JSON 内容
|
||||||
|
const finalTpl: TroubleshootingTemplate = {
|
||||||
|
...parsedData.value,
|
||||||
|
name: form.name,
|
||||||
|
category: form.category,
|
||||||
|
description: form.description ?? parsedData.value.description,
|
||||||
|
estimated_time: form.estimated_time,
|
||||||
|
difficulty: form.difficulty,
|
||||||
|
tags: form.tags,
|
||||||
|
}
|
||||||
|
emit('save', finalTpl)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.basic-info {
|
||||||
|
padding: 12px 0;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basic-info :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.basic-info .suffix {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-pane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
height: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-bottom: 1px solid #e4e7ed;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-pane .pane-body {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-wrapper {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-error {
|
||||||
|
color: #f56c6c;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-error pre {
|
||||||
|
background: #fef0f0;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-error .hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-error ul {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left,
|
||||||
|
.footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.CodeMirror) {
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,230 +2,382 @@
|
|||||||
=============================================================================
|
=============================================================================
|
||||||
企微IT智能服务台 — 排查流程图管理页
|
企微IT智能服务台 — 排查流程图管理页
|
||||||
=============================================================================
|
=============================================================================
|
||||||
说明:JSON 导入导出 + 预览 + 版本管理
|
说明:JSON 导入导出 + 在线编辑 + 树形预览
|
||||||
阶段三开始实现,当前为占位功能
|
阶段三实现 - 用户已选 B 方案(双栏 CodeMirror + vue-json-pretty)
|
||||||
显示模板列表 + 灰化的导入/导出/新建按钮
|
功能:
|
||||||
底部展示实现路径
|
- 列表(从 /api/troubleshooting-templates 拉取)
|
||||||
|
- 预览/编辑/删除(单条)
|
||||||
|
- 导入 JSON(文件)
|
||||||
|
- 导出全部(批量下载)
|
||||||
|
- 新建(空模板)
|
||||||
|
=============================================================================
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="flowcharts-page">
|
<div class="flowcharts-page">
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="page-title">排查流程图管理</div>
|
<div class="page-header">
|
||||||
<div class="page-desc">JSON 导入导出 + 预览 + 版本管理。阶段三开始实现,后续升级为可视化拖拽编辑。</div>
|
<div>
|
||||||
|
<div class="page-title">排查流程图管理</div>
|
||||||
|
<div class="page-desc">JSON 导入导出 + 在线编辑 + 树形预览。共 {{ flowcharts.length }} 套模板</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮(灰化占位) -->
|
<!-- 操作按钮 -->
|
||||||
<div class="flowchart-actions">
|
<div class="flowchart-actions">
|
||||||
<el-button type="primary" disabled>
|
<el-button type="primary" @click="handleImport">
|
||||||
<el-icon><Upload /></el-icon>
|
<el-icon><Upload /></el-icon>
|
||||||
导入 JSON
|
导入 JSON
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button disabled>
|
<el-button @click="handleExportAll" :disabled="flowcharts.length === 0">
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download /></el-icon>
|
||||||
导出全部
|
导出全部
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button disabled>
|
<el-button @click="handleNew">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-icon><Plus /></el-icon>
|
||||||
新建流程图
|
新建流程图
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button @click="loadList" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 流程图模板表格 -->
|
<!-- 流程图模板表格 -->
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<el-table
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
:data="flowcharts"
|
:data="flowcharts"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
|
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
|
||||||
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
|
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
|
||||||
row-class-name="flowchart-table-row"
|
row-class-name="flowchart-table-row"
|
||||||
|
empty-text="暂无流程图,点击「新建流程图」开始"
|
||||||
>
|
>
|
||||||
<el-table-column label="流程图名称" min-width="200">
|
<el-table-column label="流程图名称" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="flowchart-name">
|
<div class="flowchart-name">
|
||||||
<el-icon :size="16" style="color: var(--accent); margin-right: 6px">
|
<el-icon :size="16" style="color: var(--accent); margin-right: 6px">
|
||||||
<Share />
|
<Share />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
{{ row.name }}
|
<span>{{ row.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="分类" width="80">
|
<el-table-column label="分类" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" effect="plain">{{ row.category }}</el-tag>
|
<el-tag size="small" effect="plain">{{ row.category }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="节点数" width="80" align="center" prop="nodeCount" />
|
<el-table-column label="节点数" width="80" align="center" prop="nodeCount" />
|
||||||
<el-table-column label="版本" width="70" align="center" prop="version" />
|
<el-table-column label="预估时间" width="90" align="center">
|
||||||
<el-table-column label="最后更新" width="110" prop="updatedAt" />
|
<template #default="{ row }">{{ row.estimated_time ?? '-' }} 分钟</template>
|
||||||
<el-table-column label="状态" width="90">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.statusType" size="small">
|
|
||||||
{{ row.statusText }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" align="center">
|
<el-table-column label="版本" width="70" align="center">
|
||||||
<template #default>
|
<template #default="{ row }">{{ row.version || 'v1.0' }}</template>
|
||||||
<el-button size="small" text type="primary" disabled>预览</el-button>
|
</el-table-column>
|
||||||
<el-button size="small" text disabled>编辑</el-button>
|
<el-table-column label="最后更新" width="120" align="center">
|
||||||
|
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" text type="primary" @click="handlePreview(row)">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" text type="primary" @click="handleEdit(row)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" text type="danger" @click="handleDelete(row)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 实现路径 -->
|
<!-- 在线编辑器对话框 -->
|
||||||
<div class="roadmap-section">
|
<FlowchartEditorDialog
|
||||||
<div class="roadmap-title">
|
v-model="dialogVisible"
|
||||||
<el-icon :size="16" style="color: var(--accent); margin-right: 6px"><Flag /></el-icon>
|
:template="currentTemplate"
|
||||||
实现路径
|
:readonly="dialogMode === 'preview'"
|
||||||
</div>
|
:saving="saving"
|
||||||
<div class="roadmap-steps">
|
@save="handleSave"
|
||||||
<div class="roadmap-step active">
|
@cancel="handleDialogCancel"
|
||||||
<div class="step-number">Step 1</div>
|
/>
|
||||||
<div class="step-title">JSON 导入导出 + 预览</div>
|
|
||||||
<div class="step-phase">阶段三 3B</div>
|
<!-- 隐藏的文件选择器(导入 JSON) -->
|
||||||
</div>
|
<input
|
||||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
ref="fileInputRef"
|
||||||
<div class="roadmap-step">
|
type="file"
|
||||||
<div class="step-number">Step 2</div>
|
accept=".json,application/json"
|
||||||
<div class="step-title">导出为 Dify 变量</div>
|
style="display: none"
|
||||||
<div class="step-phase">阶段四 4A</div>
|
@change="handleFileSelected"
|
||||||
</div>
|
/>
|
||||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
|
||||||
<div class="roadmap-step">
|
|
||||||
<div class="step-number">Step 3</div>
|
|
||||||
<div class="step-title">Dify HTTP 回调</div>
|
|
||||||
<div class="step-phase">阶段四</div>
|
|
||||||
</div>
|
|
||||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
|
||||||
<div class="roadmap-step">
|
|
||||||
<div class="step-number">Step 4</div>
|
|
||||||
<div class="step-title">可视化拖拽编辑</div>
|
|
||||||
<div class="step-phase">远景</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// ==========================================================================
|
// =============================================================================
|
||||||
// Demo 数据
|
// imports
|
||||||
// ==========================================================================
|
// =============================================================================
|
||||||
const flowcharts = [
|
import { ref, onMounted } from 'vue'
|
||||||
{
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
name: 'VPN连接故障排查',
|
import {
|
||||||
category: '网络',
|
Upload, Download, Plus, Refresh,
|
||||||
nodeCount: 12,
|
Share, View, Edit, Delete,
|
||||||
version: 'v2.1',
|
} from '@element-plus/icons-vue'
|
||||||
updatedAt: '2026-06-10',
|
import FlowchartEditorDialog from '@/components/flowchart/FlowchartEditorDialog.vue'
|
||||||
statusType: 'success',
|
import {
|
||||||
statusText: '已发布',
|
listTemplates,
|
||||||
},
|
getTemplate,
|
||||||
{
|
createTemplate,
|
||||||
name: '打印机脱机排查',
|
updateTemplate,
|
||||||
category: '外设',
|
deleteTemplate,
|
||||||
nodeCount: 8,
|
formatJson,
|
||||||
version: 'v1.3',
|
countNodes,
|
||||||
updatedAt: '2026-06-08',
|
type TroubleshootingTemplate,
|
||||||
statusType: 'success',
|
} from '@/api/troubleshooting'
|
||||||
statusText: '已发布',
|
|
||||||
},
|
// =============================================================================
|
||||||
{
|
// 响应式状态
|
||||||
name: '邮箱登录失败排查',
|
// =============================================================================
|
||||||
category: '软件',
|
const flowcharts = ref<TroubleshootingTemplate[]>([])
|
||||||
nodeCount: 10,
|
const loading = ref<boolean>(false)
|
||||||
version: 'v1.0',
|
const saving = ref<boolean>(false)
|
||||||
updatedAt: '2026-06-06',
|
|
||||||
statusType: 'warning',
|
const dialogVisible = ref<boolean>(false)
|
||||||
statusText: '草稿',
|
const dialogMode = ref<'preview' | 'edit' | 'create'>('preview')
|
||||||
},
|
const currentTemplate = ref<TroubleshootingTemplate | null>(null)
|
||||||
]
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 加载列表
|
||||||
|
// =============================================================================
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const list = await listTemplates()
|
||||||
|
// 附加节点数(便于表格展示)
|
||||||
|
flowcharts.value = list.map((t) => ({
|
||||||
|
...t,
|
||||||
|
nodeCount: countNodes(t.root_node),
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载流程图列表失败')
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 操作
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
async function handlePreview(row: TroubleshootingTemplate) {
|
||||||
|
try {
|
||||||
|
// 重新拉详情(确保数据最新)
|
||||||
|
const tpl = await getTemplate(row.id!)
|
||||||
|
currentTemplate.value = tpl
|
||||||
|
dialogMode.value = 'preview'
|
||||||
|
dialogVisible.value = true
|
||||||
|
} catch {
|
||||||
|
// 拉失败就用列表里那条
|
||||||
|
currentTemplate.value = row
|
||||||
|
dialogMode.value = 'preview'
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
async function handleEdit(row: TroubleshootingTemplate) {
|
||||||
|
try {
|
||||||
|
const tpl = await getTemplate(row.id!)
|
||||||
|
currentTemplate.value = tpl
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
dialogVisible.value = true
|
||||||
|
} catch {
|
||||||
|
currentTemplate.value = row
|
||||||
|
dialogMode.value = 'edit'
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建
|
||||||
|
function handleNew() {
|
||||||
|
currentTemplate.value = null
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function handleDelete(row: TroubleshootingTemplate) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除流程图「${row.name}」吗?此操作不可恢复。`,
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
confirmButtonClass: 'el-button--danger',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return // 用户取消
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTemplate(row.id!)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('删除失败')
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
async function handleSave(tpl: TroubleshootingTemplate) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (dialogMode.value === 'create' || !tpl.id) {
|
||||||
|
await createTemplate(tpl)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
} else {
|
||||||
|
await updateTemplate(tpl.id, tpl)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('保存失败,请检查 JSON 格式')
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消
|
||||||
|
function handleDialogCancel() {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 导入 / 导出
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelected(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
const result = JSON.parse(text) as TroubleshootingTemplate
|
||||||
|
|
||||||
|
// 简单校验
|
||||||
|
if (!result.name || !result.category || !result.root_node) {
|
||||||
|
throw new Error('JSON 缺少必要字段(name/category/root_node)')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把导入的 JSON 当作"新建"打开,让用户确认/编辑
|
||||||
|
currentTemplate.value = result
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
dialogVisible.value = true
|
||||||
|
ElMessage.success('JSON 解析成功,请确认后保存')
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error
|
||||||
|
ElMessage.error(`JSON 解析失败: ${err.message}`)
|
||||||
|
} finally {
|
||||||
|
// 清 input 以便下次能选同一文件
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExportAll() {
|
||||||
|
const data = flowcharts.value.map((t) => ({
|
||||||
|
...t,
|
||||||
|
// 去掉统计字段,只导出核心数据
|
||||||
|
nodeCount: undefined,
|
||||||
|
}))
|
||||||
|
const json = formatJson(data)
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `troubleshooting-templates-all-${Date.now()}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success(`已导出 ${data.length} 条流程图`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工具
|
||||||
|
// =============================================================================
|
||||||
|
function formatDate(iso?: string): string {
|
||||||
|
if (!iso) return '-'
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).replace(/\//g, '-')
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 操作按钮 */
|
.page-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.flowchart-actions {
|
.flowchart-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 流程图名称 */
|
|
||||||
.flowchart-name {
|
.flowchart-name {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
|
||||||
|
|
||||||
/* 实现路径区域 */
|
|
||||||
.roadmap-section {
|
|
||||||
margin-top: 24px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap-steps {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap-step {
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 12px 16px;
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.roadmap-step.active {
|
|
||||||
background: var(--accent-light);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-number {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.roadmap-step.active .step-number {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-title {
|
.table-wrapper {
|
||||||
font-size: 13px;
|
background: white;
|
||||||
margin-top: 4px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--text-secondary);
|
padding: 4px;
|
||||||
}
|
|
||||||
.roadmap-step.active .step-title {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-phase {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.roadmap-arrow {
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 流程图表格行悬停 */
|
|
||||||
.flowchart-table-row:hover td {
|
.flowchart-table-row:hover td {
|
||||||
background-color: var(--bg-tertiary) !important;
|
background-color: var(--bg-tertiary) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,12 +140,16 @@ const selectedRole = ref<string | null>(null)
|
|||||||
|
|
||||||
// ==================== 生命周期 ====================
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
onMounted(async () => {
|
/**
|
||||||
|
* 初始化门户会话(可重入)
|
||||||
|
* 流程:OAuth2 回调 → 缓存 token → 没登录就尝试 Mock(OAuth2 失败时)→ 加载用户信息
|
||||||
|
*/
|
||||||
|
async function initPortalSession(): Promise<boolean> {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const token = urlParams.get('token')
|
const token = urlParams.get('token')
|
||||||
const code = urlParams.get('code')
|
const code = urlParams.get('code')
|
||||||
|
|
||||||
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
// 1. 企微 OAuth2 回调:URL 中有 code 参数
|
||||||
if (code && !token) {
|
if (code && !token) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -197,6 +201,30 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 无 corpId 或获取失败:显示错误(开发环境可 Mock 登录)
|
// 无 corpId 或获取失败:显示错误(开发环境可 Mock 登录)
|
||||||
|
// DEV_MODE 兜底:本地 dev 环境自动调 /api/dev/login 拿 token
|
||||||
|
if (import.meta.env.DEV || import.meta.env.VITE_DEV_MODE === 'true') {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const devResp = await apiClient.get('/dev/login', {
|
||||||
|
params: {
|
||||||
|
userid: 'dev-multi-001', // 多角色用户,可以看到完整角色选择
|
||||||
|
name: 'Dev User',
|
||||||
|
dept: '信息技术部',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const devToken = devResp.data?.data?.token
|
||||||
|
if (devToken) {
|
||||||
|
portalStore.setToken(devToken)
|
||||||
|
// 直接刷新页面,onMounted 重跑,这时 token 已在 localStorage 里
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (devErr) {
|
||||||
|
console.error('DEV_MODE Mock 登录失败:', devErr)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
error.value = '未登录,请通过企业微信工作台访问'
|
error.value = '未登录,请通过企业微信工作台访问'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -218,6 +246,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 否则:显示角色选择页面(让用户选择)
|
// 否则:显示角色选择页面(让用户选择)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钩到 Vue 生命周期:挂载时执行一次
|
||||||
|
*/
|
||||||
|
onMounted(() => {
|
||||||
|
initPortalSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== 方法 ====================
|
// ==================== 方法 ====================
|
||||||
@@ -237,11 +272,20 @@ function enterRole(role: string) {
|
|||||||
localStorage.setItem('portal_selected_role', role)
|
localStorage.setItem('portal_selected_role', role)
|
||||||
|
|
||||||
// 跳转到对应的工作台
|
// 跳转到对应的工作台
|
||||||
const roleUrls: Record<string, string> = {
|
// dev 模式:4 个前端在不同端口,要用完整 URL(从 import.meta.env 读)
|
||||||
user: '/itdesk/',
|
// 生产模式:nnginx 同一域名不同子路径,直接用相对路径
|
||||||
agent: '/itagent/',
|
const isDev = import.meta.env.DEV
|
||||||
admin: '/itadmin/',
|
const roleUrls: Record<string, string> = isDev
|
||||||
}
|
? {
|
||||||
|
user: import.meta.env.VITE_DESK_URL_DEV || 'http://localhost:5174/itdesk/',
|
||||||
|
agent: import.meta.env.VITE_AGENT_URL_DEV || 'http://localhost:5173/itagent/',
|
||||||
|
admin: import.meta.env.VITE_ADMIN_URL_DEV || 'http://localhost:5175/itadmin/',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
user: '/itdesk/',
|
||||||
|
agent: '/itagent/',
|
||||||
|
admin: '/itadmin/',
|
||||||
|
}
|
||||||
|
|
||||||
const url = roleUrls[role]
|
const url = roleUrls[role]
|
||||||
if (url) {
|
if (url) {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — dev 模式 schema 漂移检测 (PowerShell 版)
|
||||||
|
# =============================================================================
|
||||||
|
# 作用:对比 SQLAlchemy 模型 vs 实际 DB 字段,防止"模型加了字段忘写 migration"
|
||||||
|
# 历史踩坑:
|
||||||
|
# - 010: agents.otp_secret (修了一次)
|
||||||
|
# - 011: conversations.impact_scope/is_blocking/emotion_state (又踩一次)
|
||||||
|
# 用法:.\scripts\dev-check-schema-drift.ps1
|
||||||
|
# 前置:dev 后端跑着(docker compose -f docker-compose.dev.yml up)
|
||||||
|
# 实现:
|
||||||
|
# 1. 在 backend 容器里跑 Python 一行反射模型 metadata
|
||||||
|
# 2. 在 db 容器里跑 psql 拿 information_schema
|
||||||
|
# 3. PowerShell 做差集对比输出
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProjectRoot = $PSScriptRoot | Split-Path -Parent
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
|
||||||
|
function Write-Step($msg) { Write-Host "`n$msg" -ForegroundColor Cyan }
|
||||||
|
function Write-OK($msg) { Write-Host " ✓ $msg" -ForegroundColor Green }
|
||||||
|
function Write-Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow }
|
||||||
|
function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 拿 backend 容器 ID
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$BkId = docker compose --env-file .env.dev -f docker-compose.dev.yml ps -q backend
|
||||||
|
$DbId = docker compose --env-file .env.dev -f docker-compose.dev.yml ps -q postgres
|
||||||
|
if (-not $BkId -or -not $DbId) {
|
||||||
|
Write-Err "dev 后端或 db 容器没跑。先跑 dev-start.ps1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "═══ Schema 漂移检测 ═══"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 1) 拿模型字段(在 backend 容器里跑 Python)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$ModelJson = docker exec $BkId python -c @'
|
||||||
|
import json
|
||||||
|
from app.database import Base
|
||||||
|
import app.models # 触发模型注册到 Base.metadata
|
||||||
|
out = {}
|
||||||
|
for tname, tbl in Base.metadata.tables.items():
|
||||||
|
out[tname] = sorted([c.name for c in tbl.columns])
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
'@ 2>&1
|
||||||
|
|
||||||
|
# docker exec 输出会带 status warning,过滤掉
|
||||||
|
$ModelJson = ($ModelJson | Where-Object { $_ -notmatch '^\s*$' -and $_ -notmatch 'WARN|INFO' } | Select-Object -Last 1).Trim()
|
||||||
|
if (-not $ModelJson) {
|
||||||
|
Write-Err "拿不到模型字段。backend 容器里 Python 跑挂了"
|
||||||
|
docker logs $BkId --tail 10
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ModelTables = $ModelJson | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
Write-Err "模型 JSON 解析失败: $ModelJson"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-OK "模型字段: $($ModelTables.PSObject.Properties.Name.Count) 张表"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 2) 拿 DB 字段(在 db 容器里跑 psql)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$DbSql = "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position;"
|
||||||
|
$DbRaw = @(docker exec $DbId psql -U wecom -d wecom_it_desk_dev -A -t -F "`t" -c $DbSql 2>&1 | Where-Object { $_ -is [string] })
|
||||||
|
|
||||||
|
$DbColumns = @{}
|
||||||
|
foreach ($line in $DbRaw) {
|
||||||
|
if ($line -isnot [string]) { continue }
|
||||||
|
$line = $line.Trim()
|
||||||
|
if (-not $line) { continue }
|
||||||
|
$parts = $line -split "`t"
|
||||||
|
if ($parts.Count -lt 2) { continue }
|
||||||
|
$tname = $parts[0]
|
||||||
|
$cname = $parts[1]
|
||||||
|
if (-not $DbColumns.ContainsKey($tname)) {
|
||||||
|
$DbColumns[$tname] = New-Object System.Collections.Generic.List[string]
|
||||||
|
}
|
||||||
|
$DbColumns[$tname].Add($cname)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 拿 alembic version
|
||||||
|
$VersionRaw = @(docker exec $DbId psql -U wecom -d wecom_it_desk_dev -A -t -c "SELECT version_num FROM alembic_version;" 2>&1 | Where-Object { $_ -is [string] })
|
||||||
|
$Version = ($VersionRaw | Where-Object { $_ -match '^\d|_' } | Select-Object -First 1)
|
||||||
|
if ($Version) { $Version = $Version.Trim() }
|
||||||
|
Write-OK "DB 字段: $($DbColumns.Keys.Count) 张表 | alembic = $Version"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 3) 差集对比
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$Drift = $false
|
||||||
|
$OnlyInModel = @()
|
||||||
|
$OnlyInDb = @()
|
||||||
|
|
||||||
|
foreach ($tname in $ModelTables.PSObject.Properties.Name) {
|
||||||
|
$mcols = $ModelTables.$tname
|
||||||
|
$dcols = if ($DbColumns.ContainsKey($tname)) { $DbColumns[$tname] } else { @() }
|
||||||
|
|
||||||
|
foreach ($c in $mcols) {
|
||||||
|
if ($null -eq $dcols -or -not $dcols.Contains($c)) {
|
||||||
|
$OnlyInModel += [PSCustomObject]@{ Table = $tname; Column = $c }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($c in $dcols) {
|
||||||
|
if ($null -ne $c -and -not $mcols.Contains($c) -and $tname -ne 'alembic_version') {
|
||||||
|
$OnlyInDb += [PSCustomObject]@{ Table = $tname; Column = $c }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4) 输出报告
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "════════════════════════════════════════════════════════════════════" -ForegroundColor White
|
||||||
|
Write-Host " alembic current = $Version" -ForegroundColor Gray
|
||||||
|
Write-Host " 模型表数 = $($ModelTables.PSObject.Properties.Name.Count) DB 表数 = $($DbColumns.Keys.Count)" -ForegroundColor Gray
|
||||||
|
Write-Host "════════════════════════════════════════════════════════════════════" -ForegroundColor White
|
||||||
|
|
||||||
|
if ($OnlyInModel.Count -gt 0) {
|
||||||
|
$Drift = $true
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " ❌ 模型有,DB 缺($($OnlyInModel.Count) 个):" -ForegroundColor Red
|
||||||
|
foreach ($x in $OnlyInModel) {
|
||||||
|
Write-Host " - $($x.Table).$($x.Column)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($OnlyInDb.Count -gt 0) {
|
||||||
|
$Drift = $true
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " ⚠️ DB 有,模型没($($OnlyInDb.Count) 个,可能遗留):" -ForegroundColor Yellow
|
||||||
|
foreach ($x in $OnlyInDb) {
|
||||||
|
Write-Host " - $($x.Table).$($x.Column)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Drift) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 💡 修法:" -ForegroundColor Cyan
|
||||||
|
Write-Host " 1. cd backend" -ForegroundColor Gray
|
||||||
|
Write-Host " 2. alembic revision --autogenerate -m 'sync xxx fields'" -ForegroundColor Gray
|
||||||
|
Write-Host " 3. 检查生成的 migration 文件(review 一下,不要直接用)" -ForegroundColor Gray
|
||||||
|
Write-Host " 4. docker exec $BkId alembic upgrade head" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " ✅ schema 一致,无漂移" -ForegroundColor Green
|
||||||
|
Write-Host " $($ModelTables.PSObject.Properties.Name.Count) 张表全部对齐" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 💡 建议:加到 Git pre-commit / dev 启动检查里" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 前端 dev 环境一键安装脚本
|
||||||
|
# =============================================================================
|
||||||
|
# 作用:一次性给 4 个前端项目装 pnpm 依赖
|
||||||
|
# 用法:.\scripts\dev-frontend-install.ps1
|
||||||
|
# 前置:已装 pnpm (npm install -g pnpm)
|
||||||
|
# 输出:每个前端在它自己的目录下创建 node_modules
|
||||||
|
# 注意:首次安装 3-8 分钟,后续 pnpm install 会很快(增量)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProjectRoot = $PSScriptRoot | Split-Path -Parent
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 自动把 npm 全局 bin 加进 PATH(只对本脚本生效,不污染系统)
|
||||||
|
# 原因:某些用户(如 NVM 用户)系统 PATH 缺 npm global bin,pnpm 找不到
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$NpmGlobalBin = npm config get prefix 2>$null
|
||||||
|
if ($NpmGlobalBin -and (Test-Path $NpmGlobalBin)) {
|
||||||
|
if ($env:PATH -notlike "*$NpmGlobalBin*") {
|
||||||
|
$env:PATH = "$env:PATH;$NpmGlobalBin"
|
||||||
|
Write-Host " ℹ 已临时把 $NpmGlobalBin 加进 PATH(只对本次脚本生效)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$Frontends = @('frontend-h5', 'frontend-agent', 'frontend-admin', 'frontend-portal')
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 颜色函数
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
function Write-Step($msg) { Write-Host "`n$msg" -ForegroundColor Cyan }
|
||||||
|
function Write-OK($msg) { Write-Host " ✓ $msg" -ForegroundColor Green }
|
||||||
|
function Write-Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow }
|
||||||
|
function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 前置检查
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
Write-Step "═══ 企微IT智能服务台 — 前端依赖安装 ═══"
|
||||||
|
|
||||||
|
# 检查 pnpm(包含 npm global bin 路径)
|
||||||
|
$pnpmCmd = Get-Command pnpm -ErrorAction SilentlyContinue
|
||||||
|
if (-not $pnpmCmd) {
|
||||||
|
Write-Err "pnpm 没装,请先跑: npm install -g pnpm"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$pnpmVer = & $pnpmCmd.Source --version 2>&1
|
||||||
|
Write-OK "pnpm 已装: $pnpmVer"
|
||||||
|
|
||||||
|
# 检查 Node 版本(警告,不阻塞)
|
||||||
|
$nodeVer = node --version
|
||||||
|
Write-Host " Node 版本: $nodeVer"
|
||||||
|
if ($nodeVer -notmatch '^v(20\.|21\.|22\.)') {
|
||||||
|
Write-Warn "前端要求 Node 20.x,你是 $nodeVer。多数情况能跑,出问题再装 Node 20。"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查后端 dev 是否在跑(可选)
|
||||||
|
try {
|
||||||
|
$null = Invoke-WebRequest -Uri 'http://localhost:8000/health' -UseBasicParsing -TimeoutSec 3
|
||||||
|
Write-OK "后端 dev 在跑 (http://localhost:8000)"
|
||||||
|
} catch {
|
||||||
|
Write-Warn "后端 dev 没跑,Vite 反代会连不上后端。先跑 dev-start.ps1 起后端。"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 顺序装 4 个前端
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
foreach ($name in $Frontends) {
|
||||||
|
Write-Step "─── $name ───"
|
||||||
|
$dir = Join-Path $ProjectRoot $name
|
||||||
|
if (-not (Test-Path "$dir/package.json")) {
|
||||||
|
Write-Err "找不到 $dir/package.json,跳过"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Set-Location $dir
|
||||||
|
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
try {
|
||||||
|
pnpm install 2>&1 | Tee-Object -FilePath "$ProjectRoot\.pnpm-install-$name.log" | Out-Host
|
||||||
|
$sw.Stop()
|
||||||
|
Write-OK "$name 装完 ($([math]::Round($sw.Elapsed.TotalSeconds))s)"
|
||||||
|
} catch {
|
||||||
|
$sw.Stop()
|
||||||
|
Write-Err "$name 装失败 ($([math]::Round($sw.Elapsed.TotalSeconds))s)"
|
||||||
|
Write-Host " 详细日志: $ProjectRoot\.pnpm-install-$name.log" -ForegroundColor Yellow
|
||||||
|
Write-Host " 常见原因:网络抖动 / 镜像源不通 / Node 版本太新" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 汇总
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
Write-Step "═══ 装完 ═══"
|
||||||
|
foreach ($name in $Frontends) {
|
||||||
|
$log = "$ProjectRoot\.pnpm-install-$name.log"
|
||||||
|
if ((Test-Path "$ProjectRoot/$name/node_modules") -and (Test-Path "$ProjectRoot/$name/package.json")) {
|
||||||
|
Write-OK "$name 已就绪 (node_modules 存在)"
|
||||||
|
} else {
|
||||||
|
Write-Err "$name 没装好,日志: $log"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n下一步:启动前端 dev server" -ForegroundColor Cyan
|
||||||
|
Write-Host " 单独启动: cd frontend-h5 ; pnpm dev" -ForegroundColor Gray
|
||||||
|
Write-Host " 一起启动: d:/资料/03-项目开发/wecom_it_smart_desk-claude/scripts/dev-frontend-start.ps1" -ForegroundColor Gray
|
||||||
|
Write-Host " 全部停止: ...\scripts\dev-frontend-start.ps1 -Stop" -ForegroundColor Gray
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 前端 dev server 一键启动脚本
|
||||||
|
# =============================================================================
|
||||||
|
# 作用:一次性后台启动 4 个前端 dev server(每个独立窗口 + 独立日志)
|
||||||
|
# 用法:.\scripts\dev-frontend-start.ps1
|
||||||
|
# .\scripts\dev-frontend-start.ps1 -Frontend h5 # 只起一个
|
||||||
|
# .\scripts\dev-frontend-start.ps1 -Frontend h5,admin # 起多个
|
||||||
|
# .\scripts\dev-frontend-start.ps1 -Stop # 停掉所有
|
||||||
|
# 前置:已跑过 dev-frontend-install.ps1(每个前端有 node_modules)
|
||||||
|
# 输出:日志写到 .pnpm-dev-<name>.log;进程名 pnpm.exe(用 Get-Process pnpm 查)
|
||||||
|
# 注意:启动顺序 h5 → agent → admin → portal,每个隔 4 秒
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Frontend = '', # 空 = 全部 4 个;逗号分隔可多选: h5,agent,admin,portal
|
||||||
|
[switch]$Stop # 切到停止模式
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProjectRoot = $PSScriptRoot | Split-Path -Parent
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 自动把 npm 全局 bin 加进 PATH(只对本脚本生效)
|
||||||
|
# 原因:某些用户(如 NVM 用户)系统 PATH 缺 npm global bin,pnpm 找不到
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$NpmGlobalBin = npm config get prefix 2>$null
|
||||||
|
if ($NpmGlobalBin -and (Test-Path $NpmGlobalBin)) {
|
||||||
|
if ($env:PATH -notlike "*$NpmGlobalBin*") {
|
||||||
|
$env:PATH = "$env:PATH;$NpmGlobalBin"
|
||||||
|
Write-Host " ℹ 已临时把 $NpmGlobalBin 加进 PATH(只对本次脚本生效)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 前置检查 pnpm
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$pnpmCmd = Get-Command pnpm -ErrorAction SilentlyContinue
|
||||||
|
if (-not $pnpmCmd) {
|
||||||
|
Write-Host " ✗ pnpm 没装或不在 PATH,请先跑 scripts\dev-frontend-install.ps1" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 4 个前端清单(name -> port)
|
||||||
|
# 端口必须跟每个前端 vite.config.ts 的 server.port 一致
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$AllFrontends = @(
|
||||||
|
@{ Name = 'frontend-h5'; Port = 5174; Label = 'h5' },
|
||||||
|
@{ Name = 'frontend-agent'; Port = 5173; Label = 'agent' },
|
||||||
|
@{ Name = 'frontend-admin'; Port = 5175; Label = 'admin' },
|
||||||
|
@{ Name = 'frontend-portal'; Port = 5176; Label = 'portal' }
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 颜色函数(跟 dev-frontend-install.ps1 一致)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
function Write-Step($msg) { Write-Host "`n$msg" -ForegroundColor Cyan }
|
||||||
|
function Write-OK($msg) { Write-Host " ✓ $msg" -ForegroundColor Green }
|
||||||
|
function Write-Warn($msg) { Write-Host " ⚠ $msg" -ForegroundColor Yellow }
|
||||||
|
function Write-Err($msg) { Write-Host " ✗ $msg" -ForegroundColor Red }
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 工具函数
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
function Get-FrontendByLabel($label) {
|
||||||
|
foreach ($f in $AllFrontends) {
|
||||||
|
if ($f.Label -eq $label -or $f.Name -eq $label) { return $f }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PortListening($port) {
|
||||||
|
# 优先用 Get-NetTCPConnection(快);失败回退 Test-NetConnection
|
||||||
|
try {
|
||||||
|
$conn = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction Stop
|
||||||
|
return ($null -ne $conn)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
$tnc = Test-NetConnection -ComputerName 'localhost' -Port $port -InformationLevel Quiet -WarningAction SilentlyContinue
|
||||||
|
return $tnc
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PnpmPids() {
|
||||||
|
# 拿到所有 pnpm 进程 PID(用作"停止所有")
|
||||||
|
$procs = Get-Process -Name 'pnpm' -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $procs) { return @() }
|
||||||
|
return @($procs | Select-Object -ExpandProperty Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-NodePidsByCwd($dir) {
|
||||||
|
# 拿到工作目录在 $dir 下的 node.exe 进程(Vite dev server)
|
||||||
|
$procs = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue
|
||||||
|
$pids = @()
|
||||||
|
foreach ($p in $procs) {
|
||||||
|
if ($p.CommandLine -and $p.CommandLine.Contains($dir)) {
|
||||||
|
$pids += $p.ProcessId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pids
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 模式:停掉所有前端
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
if ($Stop) {
|
||||||
|
Write-Step "═══ 停掉所有前端 dev server ═══"
|
||||||
|
|
||||||
|
# 先停 node.exe(它们是 Vite dev server 真正的工作进程)
|
||||||
|
$killed = 0
|
||||||
|
foreach ($f in $AllFrontends) {
|
||||||
|
$dir = Join-Path $ProjectRoot $f.Name
|
||||||
|
$pids = Get-NodePidsByCwd $dir
|
||||||
|
foreach ($pid in $pids) {
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $pid -Force -ErrorAction Stop
|
||||||
|
$killed++
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 再停 pnpm 父进程(它会再起 node,所以得反过来先杀子再杀父?实际先父后子都行,这里杀剩的 pnpm)
|
||||||
|
$pnpmPids = Get-PnpmPids
|
||||||
|
foreach ($pid in $pnpmPids) {
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $pid -Force -ErrorAction Stop
|
||||||
|
$killed++
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-OK "已停掉 $killed 个进程"
|
||||||
|
|
||||||
|
# 兜底:再扫一遍端口确认空
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
foreach ($f in $AllFrontends) {
|
||||||
|
if (Test-PortListening $f.Port) {
|
||||||
|
Write-Warn "$($f.Label) 端口 $($f.Port) 还占着,可能不是 pnpm 起的"
|
||||||
|
} else {
|
||||||
|
Write-OK "$($f.Label) 端口 $($f.Port) 已释放"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "`n下一步: .\scripts\dev-frontend-start.ps1 重启" -ForegroundColor Gray
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 解析 -Frontend 参数
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Frontend)) {
|
||||||
|
$Selected = $AllFrontends
|
||||||
|
} else {
|
||||||
|
$Selected = @()
|
||||||
|
foreach ($label in ($Frontend -split ',')) {
|
||||||
|
$label = $label.Trim()
|
||||||
|
$f = Get-FrontendByLabel $label
|
||||||
|
if ($null -eq $f) {
|
||||||
|
Write-Err "未知前端: $label(可选: h5, agent, admin, portal)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$Selected += $f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 前置检查
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
Write-Step "═══ 企微IT智能服务台 — 前端 dev server 启动 ═══"
|
||||||
|
|
||||||
|
# 检查 pnpm
|
||||||
|
try {
|
||||||
|
$pnpmVer = pnpm --version
|
||||||
|
Write-OK "pnpm 已装: $pnpmVer"
|
||||||
|
} catch {
|
||||||
|
Write-Err "pnpm 没装,请先跑: npm install -g pnpm"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 Node 版本(警告,不阻塞)
|
||||||
|
$nodeVer = node --version
|
||||||
|
Write-Host " Node 版本: $nodeVer"
|
||||||
|
if ($nodeVer -notmatch '^v(20\.|21\.|22\.)') {
|
||||||
|
Write-Warn "前端要求 Node 20.x,你是 $nodeVer。多数情况能跑,出问题再装 Node 20。"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查每个选中的前端是否有 node_modules
|
||||||
|
foreach ($f in $Selected) {
|
||||||
|
$dir = Join-Path $ProjectRoot $f.Name
|
||||||
|
if (-not (Test-Path "$dir/package.json")) {
|
||||||
|
Write-Err "找不到 $dir/package.json,跳过 $($f.Label)"
|
||||||
|
# 用占位标记一下,在后面的循环里跳过
|
||||||
|
$f | Add-Member -NotePropertyName 'Skip' -NotePropertyValue $true -Force
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "$dir/node_modules")) {
|
||||||
|
Write-Warn "$($f.Label) 没有 node_modules,先跑 dev-frontend-install.ps1"
|
||||||
|
$f | Add-Member -NotePropertyName 'Skip' -NotePropertyValue $true -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 逐个后台启动
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
$Started = @()
|
||||||
|
|
||||||
|
foreach ($f in $Selected) {
|
||||||
|
if ($f.Skip) { continue }
|
||||||
|
|
||||||
|
$dir = Join-Path $ProjectRoot $f.Name
|
||||||
|
$log = Join-Path $ProjectRoot ".pnpm-dev-$($f.Label).log"
|
||||||
|
$port = $f.Port
|
||||||
|
$label = $f.Label
|
||||||
|
|
||||||
|
Write-Step "─── $label (port $port) ───"
|
||||||
|
|
||||||
|
# 检查端口是否已被占(被占就跳过,不报错)
|
||||||
|
if (Test-PortListening $port) {
|
||||||
|
Write-Warn "端口 $port 已被占,$label 跳过(可能别人/上次没退干净在跑)"
|
||||||
|
Write-Host " 看进程: Get-NetTCPConnection -LocalPort $port -State Listen" -ForegroundColor Gray
|
||||||
|
Write-Host " 强制停: .\scripts\dev-frontend-start.ps1 -Stop" -ForegroundColor Gray
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清空旧日志(每次启动都是干净的一页)
|
||||||
|
if (Test-Path $log) { Remove-Item $log -Force }
|
||||||
|
|
||||||
|
# 后台启动 Start-Process,WindowStyle Hidden(不弹黑窗)
|
||||||
|
# 关键:必须用 cmd.exe /c 调 pnpm,因为 pnpm 是 .cmd 不是 .exe
|
||||||
|
# 直接 Start-Process 'pnpm' 会报 "%1 不是有效的 Win32 应用程序"
|
||||||
|
try {
|
||||||
|
$proc = Start-Process -FilePath 'cmd.exe' `
|
||||||
|
-ArgumentList '/c', 'pnpm', 'dev' `
|
||||||
|
-WorkingDirectory $dir `
|
||||||
|
-WindowStyle Hidden `
|
||||||
|
-RedirectStandardOutput $log `
|
||||||
|
-RedirectStandardError "$log.err" `
|
||||||
|
-PassThru `
|
||||||
|
-ErrorAction Stop
|
||||||
|
Write-OK "已起进程 PID=$($proc.Id),日志: $log"
|
||||||
|
} catch {
|
||||||
|
Write-Err "启动失败: $_"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 等 3-5 秒,看端口起来没
|
||||||
|
$up = $false
|
||||||
|
for ($i = 1; $i -le 8; $i++) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
if (Test-PortListening $port) {
|
||||||
|
$up = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($up) {
|
||||||
|
Write-OK "$label 端口 $port 已监听"
|
||||||
|
$Started += $f
|
||||||
|
} else {
|
||||||
|
Write-Warn "$label 8 秒内未监听,看日志: $log"
|
||||||
|
Write-Host " 常见原因: 依赖缺/Node 版本/端口冲突/语法错" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 汇总表格
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
Write-Step "═══ 启动汇总 ═══"
|
||||||
|
|
||||||
|
if ($Started.Count -eq 0) {
|
||||||
|
Write-Warn "没前端成功起来"
|
||||||
|
} else {
|
||||||
|
# 算每行最大宽度
|
||||||
|
$maxLabel = ($Started | ForEach-Object { $_.Label.Length } | Measure-Object -Maximum).Maximum
|
||||||
|
$maxLabel = [Math]::Max($maxLabel, 5)
|
||||||
|
|
||||||
|
Write-Host (" {0,-$maxLabel} {1,-8} {2}" -f 'NAME', 'PORT', 'URL') -ForegroundColor White
|
||||||
|
Write-Host (" {0,-$maxLabel} {1,-8} {2}" -f ($maxLabel | ForEach-Object { '-' * $_ }), '----', '---') -ForegroundColor DarkGray
|
||||||
|
|
||||||
|
foreach ($f in $Started) {
|
||||||
|
$url = "http://localhost:$($f.Port)"
|
||||||
|
Write-Host (" {0,-$maxLabel} {1,-8} {2}" -f $f.Label, $f.Port, $url) -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n 日志位置:" -ForegroundColor Cyan
|
||||||
|
foreach ($f in $Started) {
|
||||||
|
$log = Join-Path $ProjectRoot ".pnpm-dev-$($f.Label).log"
|
||||||
|
Write-Host " $log" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n 实时跟日志(另开窗口跑):" -ForegroundColor Cyan
|
||||||
|
Write-Host " Get-Content .pnpm-dev-h5.log -Wait" -ForegroundColor Gray
|
||||||
|
Write-Host " Get-Content .pnpm-dev-agent.log -Wait" -ForegroundColor Gray
|
||||||
|
|
||||||
|
Write-Host "`n 停掉所有前端:" -ForegroundColor Cyan
|
||||||
|
Write-Host " .\scripts\dev-frontend-start.ps1 -Stop" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "`n下一步:浏览器开上面的 URL 就能用了" -ForegroundColor Cyan
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 本地开发环境 停止脚本
|
||||||
|
# =============================================================================
|
||||||
|
# 作用:停止 docker-compose.dev.yml 启动的所有容器(数据保留)
|
||||||
|
# 用法:.\scripts\dev-stop.ps1
|
||||||
|
# 数据会保留在 postgres_dev_data / redis_dev_data 卷里
|
||||||
|
# 如需完全清空,加 -v 参数:.\scripts\dev-stop.ps1 -RemoveVolumes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$RemoveVolumes # 加这个参数会删除数据卷(慎用!)
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Yellow
|
||||||
|
Write-Host " 停止本地开发环境" -ForegroundColor Yellow
|
||||||
|
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if ($RemoveVolumes) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ -v 参数已指定,将删除所有数据卷!" -ForegroundColor Red
|
||||||
|
Write-Host " (postgres_dev_data / redis_dev_data 会被清空)" -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
$Confirm = Read-Host "确认删除?输入 yes 继续,其他键取消"
|
||||||
|
if ($Confirm -ne "yes") {
|
||||||
|
Write-Host "已取消" -ForegroundColor Gray
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
docker compose -f docker-compose.dev.yml down -v
|
||||||
|
} else {
|
||||||
|
docker compose -f docker-compose.dev.yml down
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "✅ 容器已停止" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📌 数据保留在卷里,下次 .\scripts\dev-start.ps1 自动恢复" -ForegroundColor Cyan
|
||||||
|
Write-Host "📌 完全清理:.\scripts\dev-stop.ps1 -RemoveVolumes" -ForegroundColor Cyan
|
||||||
|
} else {
|
||||||
|
Write-Host "❌ 停止失败" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 本地开发环境 一键测试脚本
|
||||||
|
# =============================================================================
|
||||||
|
# 作用:跑后端 pytest + 前端 vitest(可选)
|
||||||
|
# 用法:在 PowerShell 中执行
|
||||||
|
# .\scripts\dev-test.ps1 # 跑后端 pytest
|
||||||
|
# .\scripts\dev-test.ps1 -Frontend # 也跑前端 vitest
|
||||||
|
# .\scripts\dev-test.ps1 -BackendOnly # 只跑后端
|
||||||
|
# 前置:docker compose -f docker-compose.dev.yml up -d 已运行
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$Frontend, # 加这个参数同时跑前端 vitest
|
||||||
|
[switch]$BackendOnly, # 只跑后端
|
||||||
|
[switch]$FrontendOnly, # 只跑前端
|
||||||
|
[switch]$SkipBuild, # 跳过 backend build check
|
||||||
|
[switch]$Verbose # 详细输出
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue' # 测试失败不中断,继续跑其他
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$ProjectRoot = Split-Path -Parent $ScriptDir
|
||||||
|
Set-Location $ProjectRoot
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||||
|
Write-Host " 企微IT智能服务台 — 本地测试套件" -ForegroundColor Cyan
|
||||||
|
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||||
|
Write-Host "模式:" -ForegroundColor Gray -NoNewline
|
||||||
|
if ($FrontendOnly) { Write-Host " 仅前端 vitest" -ForegroundColor Magenta }
|
||||||
|
elseif ($BackendOnly) { Write-Host " 仅后端 pytest" -ForegroundColor Magenta }
|
||||||
|
elseif ($Frontend) { Write-Host " 后端 pytest + 前端 vitest" -ForegroundColor Magenta }
|
||||||
|
else { Write-Host " 仅后端 pytest(默认)" -ForegroundColor Magenta }
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$Script:TotalPassed = 0
|
||||||
|
$Script:TotalFailed = 0
|
||||||
|
$Script:TotalError = @()
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 第一步:环境检查
|
||||||
|
# ==========================================================================
|
||||||
|
if (-not $FrontendOnly) {
|
||||||
|
Write-Host "[1/3] 检查后端依赖..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# 检查 backend 目录
|
||||||
|
if (-not (Test-Path "backend/pytest.ini")) {
|
||||||
|
# 后端可能没 pytest.ini,检查是否有 tests/ 目录
|
||||||
|
if (-not (Test-Path "backend/tests")) {
|
||||||
|
Write-Host " ⚠️ backend/tests 目录不存在,跳过 pytest" -ForegroundColor Yellow
|
||||||
|
$Script:TotalError += "后端无测试目录"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查 docker 容器是否在跑
|
||||||
|
$BackendStatus = docker ps --filter "name=dev_wecom_backend" --format "{{.Status}}" 2>$null
|
||||||
|
if (-not $BackendStatus) {
|
||||||
|
Write-Host " ❌ backend 容器未运行!" -ForegroundColor Red
|
||||||
|
Write-Host " 请先执行:.\scripts\dev-start.ps1" -ForegroundColor Gray
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " ✅ backend 容器运行中: $BackendStatus" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 第二步:跑后端 pytest
|
||||||
|
# ==========================================================================
|
||||||
|
if (-not $FrontendOnly) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[2/3] 跑后端 pytest..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
if (Test-Path "backend/tests") {
|
||||||
|
$PytestArgs = @("pytest", "-v", "--tb=short", "--color=yes")
|
||||||
|
if ($Verbose) { $PytestArgs += "-s" }
|
||||||
|
|
||||||
|
docker exec dev_wecom_backend @PytestArgs
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host " ✅ pytest 通过" -ForegroundColor Green
|
||||||
|
$Script:TotalPassed++
|
||||||
|
} else {
|
||||||
|
Write-Host " ❌ pytest 失败(退出码 $LASTEXITCODE)" -ForegroundColor Red
|
||||||
|
$Script:TotalFailed++
|
||||||
|
$Script:TotalError += "后端 pytest 失败"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host " ⏭️ 跳过(无 backend/tests)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 第三步:跑前端 vitest
|
||||||
|
# ==========================================================================
|
||||||
|
if ($Frontend -or $FrontendOnly) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[3/3] 跑前端 vitest..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$FrontendDirs = @("frontend-h5", "frontend-agent", "frontend-admin", "frontend-portal")
|
||||||
|
foreach ($Dir in $FrontendDirs) {
|
||||||
|
if (-not (Test-Path "$Dir/node_modules")) {
|
||||||
|
Write-Host " ⏭️ 跳过 $Dir (未安装依赖)" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "$Dir/vitest.config.ts") -and -not (Test-Path "$Dir/vitest.config.js")) {
|
||||||
|
Write-Host " ⏭️ 跳过 $Dir (无 vitest.config)" -ForegroundColor Yellow
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host " ▶ $Dir" -ForegroundColor Cyan
|
||||||
|
Push-Location $Dir
|
||||||
|
try {
|
||||||
|
if ($Verbose) {
|
||||||
|
pnpm test:run 2>&1 | Tee-Object -Variable VitestOutput
|
||||||
|
} else {
|
||||||
|
pnpm test:run 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host " ✅ $Dir 通过" -ForegroundColor Green
|
||||||
|
$Script:TotalPassed++
|
||||||
|
} else {
|
||||||
|
Write-Host " ❌ $Dir 失败" -ForegroundColor Red
|
||||||
|
$Script:TotalFailed++
|
||||||
|
$Script:TotalError += "前端 $Dir vitest 失败"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[3/3] 跳过前端 vitest(加 -Frontend 参数启用)" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 总结
|
||||||
|
# ==========================================================================
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||||
|
Write-Host " 测试结果汇总" -ForegroundColor Cyan
|
||||||
|
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
||||||
|
Write-Host " 通过模块: " -NoNewline -ForegroundColor White
|
||||||
|
Write-Host $Script:TotalPassed -ForegroundColor Green
|
||||||
|
Write-Host " 失败模块: " -NoNewline -ForegroundColor White
|
||||||
|
if ($Script:TotalFailed -eq 0) {
|
||||||
|
Write-Host $Script:TotalFailed -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host $Script:TotalFailed -ForegroundColor Red
|
||||||
|
}
|
||||||
|
if ($Script:TotalError.Count -gt 0) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " 失败详情:" -ForegroundColor Yellow
|
||||||
|
foreach ($Err in $Script:TotalError) {
|
||||||
|
Write-Host " • $Err" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($Script:TotalFailed -eq 0) {
|
||||||
|
Write-Host "🎉 全部测试通过!" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
Write-Host "⚠️ 有测试失败,请查看上方输出" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user