Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cec5607c45 | |||
| caf9b7ed85 | |||
| 68ce1dbab9 | |||
| 60e67b0681 |
@@ -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=信息技术部
|
||||
+1074
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
+13
-5
@@ -1,4 +1,4 @@
|
||||
"""admin extension — 管理后台数据库扩展迁移
|
||||
"""admin ext — 管理后台数据库扩展迁移
|
||||
|
||||
新增 config_change_logs 表(配置变更日志)。
|
||||
扩展 agents 表:新增 role(角色)和 skill_tags(技能标签)字段。
|
||||
@@ -8,16 +8,23 @@ submitted_by(提交人)字段。
|
||||
Revision ID: 006_admin_ext
|
||||
Revises: 005_reply_to_id
|
||||
Create Date: 2026-07-15 10:00:00.000000
|
||||
|
||||
注:filename 与 revision 字符串一致(v0.5.1 修复)
|
||||
原 filename `006_admin_extension.py` 改名为 `006_admin_ext.py`,
|
||||
revision 字符串保持 `006_admin_ext` 不变(DB alembic_version 表已存此值,
|
||||
改 revision 会破坏 chain)。
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '006_admin_ext'
|
||||
down_revision = '005_reply_to_id'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
revision: str = '006_admin_ext'
|
||||
down_revision: Union[str, None] = '005_reply_to_id'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
@@ -113,4 +120,5 @@ def downgrade() -> None:
|
||||
# 删除 config_change_logs 表索引和表
|
||||
op.drop_index('idx_ccl_changed_at', table_name='config_change_logs')
|
||||
op.drop_index('idx_ccl_config_key', table_name='config_change_logs')
|
||||
op.table('config_change_logs')
|
||||
op.drop_table('config_change_logs')
|
||||
@@ -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 文件
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
终端安全对比 API
|
||||
|
||||
路径: /api/admin/security/comparison
|
||||
鉴权: require_admin
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.admin_api import require_admin
|
||||
from app.services.security_comparison import (
|
||||
TerminalSecurityComparison,
|
||||
comparison_task_config,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/security/comparison", tags=["终端安全对比"])
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
class CompareRequest(BaseModel):
|
||||
"""手动触发比对请求"""
|
||||
pass # 无参数,手动触发
|
||||
|
||||
|
||||
class CompareSummaryResponse(BaseModel):
|
||||
"""比对汇总响应"""
|
||||
lianruan_count: int
|
||||
huorong_count: int
|
||||
no_huorong_count: int
|
||||
compliance_rate: str
|
||||
generated_at: str
|
||||
|
||||
|
||||
class NoHuorongDevice(BaseModel):
|
||||
"""未安装火绒设备"""
|
||||
hostname: str
|
||||
ip: str
|
||||
useraccount: Optional[str] = None
|
||||
dept: Optional[str] = None
|
||||
last_login: Optional[str] = None
|
||||
osver: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class TaskConfigRequest(BaseModel):
|
||||
"""任务配置请求"""
|
||||
name: str # 任务名称
|
||||
cron: str # Cron 表达式,如 "0 9 * * 1" 每周一9点
|
||||
recipients: list[str] # 企微接收人user_id列表
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TaskConfigResponse(BaseModel):
|
||||
"""任务配置响应"""
|
||||
task_id: str
|
||||
name: str
|
||||
cron: str
|
||||
recipients: list[str]
|
||||
enabled: bool
|
||||
last_run: Optional[str] = None
|
||||
next_run: Optional[str] = None
|
||||
|
||||
|
||||
# --- API Endpoints ---
|
||||
@router.get("/summary", response_model=CompareSummaryResponse)
|
||||
async def get_comparison_summary(current_user=Depends(require_admin)):
|
||||
"""获取比对汇总数据"""
|
||||
service = TerminalSecurityComparison()
|
||||
try:
|
||||
summary = await service.compare_summary()
|
||||
return summary
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
@router.get("/no-huorong", response_model=list[NoHuorongDevice])
|
||||
async def get_no_huorong_devices(current_user=Depends(require_admin)):
|
||||
"""获取未安装火绒的电脑清单"""
|
||||
service = TerminalSecurityComparison()
|
||||
try:
|
||||
devices = await service.get_no_huorong_devices()
|
||||
return devices
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
@router.post("/trigger")
|
||||
async def trigger_comparison(current_user=Depends(require_admin)):
|
||||
"""手动触发比对并推送企微消息"""
|
||||
service = TerminalSecurityComparison()
|
||||
try:
|
||||
# 1. 执行比对
|
||||
no_huorong = await service.get_no_huorong_devices()
|
||||
|
||||
# 2. 生成消息
|
||||
if no_huorong:
|
||||
msg = f"⚠️ 终端安全检查:发现 {len(no_huorong)} 台电脑未安装火绒\n\n"
|
||||
for dev in no_huorong[:10]: # 只显示前10条
|
||||
msg += f"• {dev.get('hostname')} ({dev.get('ip')})\n"
|
||||
if len(no_huorong) > 10:
|
||||
msg += f"... 还有 {len(no_huorong)-10} 台"
|
||||
else:
|
||||
msg = "✅ 终端安全检查:所有电脑已安装火绒"
|
||||
|
||||
# 3. TODO: 推送到企微(需要企微消息API)
|
||||
logger.info(f"比对结果: {msg}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"no_huorong_count": len(no_huorong),
|
||||
"message": msg,
|
||||
}
|
||||
finally:
|
||||
await service.close()
|
||||
|
||||
|
||||
# --- 任务配置 API ---
|
||||
@router.get("/tasks", response_model=list[TaskConfigResponse])
|
||||
async def list_tasks(current_user=Depends(require_admin)):
|
||||
"""列出所有定时任务"""
|
||||
tasks = comparison_task_config.list_tasks()
|
||||
return tasks
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=TaskConfigResponse)
|
||||
async def create_task(
|
||||
config: TaskConfigRequest,
|
||||
current_user=Depends(require_admin)
|
||||
):
|
||||
"""创建定时任务"""
|
||||
task_id = str(uuid4())[:8]
|
||||
|
||||
comparison_task_config.add_task(task_id, {
|
||||
"name": config.name,
|
||||
"cron": config.cron,
|
||||
"recipients": config.recipients,
|
||||
"enabled": config.enabled,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
return TaskConfigResponse(
|
||||
task_id=task_id,
|
||||
**config.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
async def delete_task(
|
||||
task_id: str,
|
||||
current_user=Depends(require_admin)
|
||||
):
|
||||
"""删除定时任务"""
|
||||
success = comparison_task_config.delete_task(task_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# 日志记录
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -212,7 +212,7 @@ async def agent_login(
|
||||
if not existing_agent:
|
||||
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
|
||||
raise AppException(
|
||||
1003,
|
||||
ErrorCode.AUTH_TOKEN_INVALID,
|
||||
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
|
||||
)
|
||||
logger.warning(
|
||||
@@ -223,7 +223,7 @@ async def agent_login(
|
||||
if existing_agent.password_hash is None:
|
||||
# 已注册坐席但未设置密码,要求先设置密码
|
||||
raise AppException(
|
||||
1012,
|
||||
ErrorCode.AUTH_PASSWORD_REQUIRED,
|
||||
"首次登录请先设置密码。管理后台 → 坐席管理 → 设置本地密码"
|
||||
)
|
||||
if not body.password:
|
||||
|
||||
@@ -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="/api/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),
|
||||
},
|
||||
}
|
||||
@@ -829,18 +829,21 @@ async def h5_poll_messages(
|
||||
).order_by(Message.created_at.asc())
|
||||
|
||||
if after_message_id:
|
||||
# 转换为UUID类型查询,确保和数据库UUID字段类型匹配
|
||||
# 校验 UUID 格式,然后转字符串(兼容 SQLite/PG 的 String(36) 列,避免类型不匹配)
|
||||
from uuid import UUID as UUIDType
|
||||
|
||||
try:
|
||||
msg_uuid = UUIDType(after_message_id)
|
||||
UUIDType(after_message_id) # 仅校验
|
||||
except ValueError:
|
||||
# 无效的UUID格式,返回空列表
|
||||
# 无效的UUID格式,返回空列表
|
||||
items = []
|
||||
return success_response(data={"items": items, "has_more": False})
|
||||
|
||||
# 必须用字符串比较,Message.id 在 DB 里是 String(36)/VARCHAR,
|
||||
# 传 UUID 对象会被 SQLAlchemy 推断成 UUID 类型 → PostgreSQL 报
|
||||
# "operator does not exist: character varying = uuid"
|
||||
after_stmt = select(Message.created_at).where(
|
||||
Message.id == msg_uuid
|
||||
Message.id == str(after_message_id)
|
||||
)
|
||||
after_result = await db.execute(after_stmt)
|
||||
after_time = after_result.scalar_one_or_none()
|
||||
|
||||
@@ -21,10 +21,12 @@ 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.employees import router as employees_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.admin_roles import router as admin_roles_router
|
||||
from app.api.admin.security_comparison import router as security_comparison_router
|
||||
from app.api.approval import router as approval_router
|
||||
from app.api.wecom_jsapi import router as wecom_jsapi_router # v0.5.4 应急页 JS-SDK 签名
|
||||
|
||||
# 创建 API 路由器
|
||||
# 所有子路由都会挂载到这个路由器上
|
||||
@@ -157,6 +159,14 @@ api_router.include_router(portal_router, tags=["统一入口"])
|
||||
# DELETE /api/admin/roles/mapping-rules/{id} — 删除映射规则
|
||||
api_router.include_router(admin_roles_router, tags=["角色管理"])
|
||||
|
||||
# 终端安全对比 API
|
||||
# GET /api/admin/security/comparison/summary — 比对汇总
|
||||
# GET /api/admin/security/comparison/no-huorong — 未安装火绒清单
|
||||
# POST /api/admin/security/comparison/trigger — 手动触发
|
||||
# GET /api/admin/security/comparison/tasks — 任务列表
|
||||
# POST /api/admin/security/comparison/tasks — 创建定时任务
|
||||
api_router.include_router(security_comparison_router, tags=["终端安全对比"])
|
||||
|
||||
# 审批流程 API
|
||||
# GET /api/approval/templates — 获取审批模板列表
|
||||
# GET /api/approval/templates/{id} — 获取审批模板详情
|
||||
@@ -164,3 +174,7 @@ api_router.include_router(admin_roles_router, tags=["角色管理"])
|
||||
# POST /api/approval/submit — API提交审批
|
||||
# GET /api/approval/keywords — 获取审批关键词
|
||||
api_router.include_router(approval_router, tags=["审批流程"])
|
||||
|
||||
# 企微 JS-SDK 签名 API (v0.5.4 应急页身份检测用)
|
||||
# GET /api/wecom/jsapi-config?url=xxx — 返回 corp_id/agent_id/timestamp/nonce_str/signature
|
||||
api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微 JS-SDK 签名 API (v0.5.4 应急页用)
|
||||
# =============================================================================
|
||||
# 说明:提供前端 wx.config / wx.agentConfig 所需的鉴权签名。
|
||||
# 对应企微文档:https://developer.work.weixin.qq.com/document/path/90506
|
||||
#
|
||||
# 流程:
|
||||
# 1. 前端调 GET /api/wecom/jsapi-config?url=xxx 拿签名
|
||||
# 2. 后端用 jsapi_ticket + url 算 sha1 签名
|
||||
# 3. 前端用 wx.config({...}) 鉴权后,即可调企微 JS-SDK(如 wx.agentConfig)
|
||||
#
|
||||
# BC/DR 设计:不依赖 session/auth,公开访问(只返回签名,不返回敏感数据)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_shared_wecom_service
|
||||
from app.utils.response import AppException, success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/wecom/jsapi-config")
|
||||
async def get_jsapi_config(
|
||||
url: str = Query(..., description="当前页面 URL(不含 # 及其后)"),
|
||||
):
|
||||
"""获取企微 JS-SDK 鉴权配置。
|
||||
|
||||
供前端 wx.config 和 wx.agentConfig 使用。
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"corp_id": "wwa8c87970b2011f41",
|
||||
"agent_id": "1000133",
|
||||
"timestamp": 1718500000,
|
||||
"nonce_str": "5K8264ILTKCH...",
|
||||
"signature": "f7c8e9..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
wecom_service = get_shared_wecom_service()
|
||||
|
||||
# 1. 获取 jsapi_ticket
|
||||
ticket = await wecom_service.get_jsapi_ticket()
|
||||
|
||||
# 2. 生成时间戳和随机串
|
||||
timestamp = int(time.time())
|
||||
nonce_str = secrets.token_hex(8) # 16 字符
|
||||
|
||||
# 3. 计算签名
|
||||
signature = wecom_service.generate_jsapi_signature(
|
||||
ticket=ticket,
|
||||
nonce_str=nonce_str,
|
||||
timestamp=timestamp,
|
||||
url=url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"生成 JS-SDK 签名: url={url[:80]}... timestamp={timestamp}"
|
||||
)
|
||||
|
||||
return success_response(
|
||||
{
|
||||
"corp_id": settings.wecom_corp_id,
|
||||
"agent_id": str(settings.wecom_agent_id),
|
||||
"timestamp": timestamp,
|
||||
"nonce_str": nonce_str,
|
||||
"signature": signature,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成 JS-SDK 签名失败: {e}", exc_info=True)
|
||||
raise AppException(
|
||||
code=5001,
|
||||
message=f"生成 JS-SDK 签名失败: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 应急页身份检测 (v0.5.4)
|
||||
# =============================================================================
|
||||
# 流程:
|
||||
# 1. 前端用 wx.agentConfig 拿到当前 userid
|
||||
# 2. 前端调 GET /api/wecom/check-role?userid=xxx
|
||||
# 3. 后端用企微通讯录 API 查 userid 是否在"IT支持-咨询坐席"标签里
|
||||
# 4. 返回 "user" 或 "agent"
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/wecom/check-role")
|
||||
async def check_emergency_role(
|
||||
userid: str = Query(..., description="企微 userid"),
|
||||
):
|
||||
"""检测当前账号在应急页场景下的角色。
|
||||
|
||||
实现方式(优先级递减):
|
||||
1. 企微通讯录标签检测(若配置 WECOM_AGENT_TAG_ID)
|
||||
2. 后台硬编码名单(若配置 WECOM_AGENT_USERIDS 环境变量)
|
||||
3. 默认 "user" (兜底)
|
||||
|
||||
Args:
|
||||
userid: 企微 userid(从 wx.agentConfig 拿)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"role": "user" | "agent",
|
||||
"userid": "...",
|
||||
"method": "tag" | "hardcoded" | "default"
|
||||
}
|
||||
}
|
||||
"""
|
||||
wecom_service = get_shared_wecom_service()
|
||||
|
||||
# 方式 1:企微标签检测
|
||||
tag_id = getattr(settings, "wecom_agent_tag_id", None)
|
||||
if tag_id:
|
||||
try:
|
||||
access_token = await wecom_service.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/tag/get?access_token={access_token}&tagid={tag_id}"
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode", 0) == 0:
|
||||
user_list = result.get("userlist", [])
|
||||
# userlist 元素可能是 str(老版)或 dict(新版带 name)
|
||||
user_ids = [
|
||||
u if isinstance(u, str) else u.get("userid", "")
|
||||
for u in user_list
|
||||
]
|
||||
if userid in user_ids:
|
||||
logger.info(f"标签检测: userid={userid} 是坐席")
|
||||
return success_response(
|
||||
{"role": "agent", "userid": userid, "method": "tag"}
|
||||
)
|
||||
else:
|
||||
logger.info(f"标签检测: userid={userid} 是员工")
|
||||
return success_response(
|
||||
{"role": "user", "userid": userid, "method": "tag"}
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"标签 API 失败: errcode={result.get('errcode')}, "
|
||||
f"errmsg={result.get('errmsg')}, 降级到硬编码"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"标签检测失败(降级): {e}")
|
||||
|
||||
# 方式 2:硬编码名单
|
||||
hardcoded = getattr(settings, "wecom_agent_userids", None)
|
||||
if hardcoded:
|
||||
agent_ids = [x.strip() for x in hardcoded.split(",") if x.strip()]
|
||||
if userid in agent_ids:
|
||||
logger.info(f"硬编码名单: userid={userid} 是坐席")
|
||||
return success_response(
|
||||
{"role": "agent", "userid": userid, "method": "hardcoded"}
|
||||
)
|
||||
else:
|
||||
return success_response(
|
||||
{"role": "user", "userid": userid, "method": "hardcoded"}
|
||||
)
|
||||
|
||||
# 方式 3:默认 user
|
||||
logger.info(f"未配置检测方式, userid={userid} 默认 user")
|
||||
return success_response(
|
||||
{"role": "user", "userid": userid, "method": "default"}
|
||||
)
|
||||
+12
-11
@@ -20,7 +20,6 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.cache_service import cache_service
|
||||
@@ -39,7 +38,6 @@ WS_CLOSE_UNAUTHORIZED = 4001
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
agent_id: str,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""坐席 WebSocket 端点主循环(含 WS-01 token 认证)。
|
||||
|
||||
@@ -61,10 +59,12 @@ async def websocket_endpoint(
|
||||
- 兼容从 ?token= URL 参数获取(向后兼容)
|
||||
- 不再将 token 暴露在 URL 中,避免 access_log 泄露
|
||||
|
||||
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
|
||||
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
|
||||
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
agent_id: 坐席ID(从 URL 路径参数获取)
|
||||
request: Starlette Request(用于获取 header)
|
||||
"""
|
||||
# ======================================================================
|
||||
# WS-01: Token 认证(从 subprotocol / header / query 获取)
|
||||
@@ -74,17 +74,17 @@ async def websocket_endpoint(
|
||||
# 格式: Sec-WebSocket-Protocol: bearer.{token}
|
||||
# 说明: 浏览器原生 WebSocket API 不支持 headers 参数,但支持 subprotocols (第2参数数组)
|
||||
# 前端用 new WebSocket(url, ["bearer.{token}"]) 传递,服务端从 sec-websocket-protocol 头读取
|
||||
subprotocol = request.headers.get("sec-websocket-protocol", "")
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:] # 去掉 "bearer." 前缀
|
||||
else:
|
||||
# 其次从 Authorization header 获取
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
auth_header = websocket.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:] # 去掉 "Bearer " 前缀
|
||||
else:
|
||||
# 向后兼容:从 query param 获取(即将废弃)
|
||||
token = request.query_params.get("token", "")
|
||||
token = websocket.query_params.get("token", "")
|
||||
|
||||
# 步骤2: 检查 token 是否为空
|
||||
if not token:
|
||||
@@ -197,7 +197,6 @@ async def websocket_endpoint(
|
||||
async def h5_websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
employee_id: str,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""H5员工 WebSocket 端点主循环(含 token 认证)。
|
||||
|
||||
@@ -223,10 +222,12 @@ async def h5_websocket_endpoint(
|
||||
- (与H5登录 API /api/h5/mock-login 存储格式一致)
|
||||
- token 缺失、无效、过期、与 employee_id 不匹配均拒绝连接
|
||||
|
||||
v0.5.1 修复:移除 `request: Request` 参数(部分 Starlette 版本注入 Request 失败,
|
||||
改用 `websocket.headers` 和 `websocket.query_params` 读取 header/query)
|
||||
|
||||
Args:
|
||||
websocket: FastAPI WebSocket 对象(框架自动注入)
|
||||
employee_id: 员工企微 UserID(从 URL 路径参数获取)
|
||||
request: Starlette Request(用于获取 header)
|
||||
"""
|
||||
# ======================================================================
|
||||
# Token 认证(从 subprotocol / header / query 获取)
|
||||
@@ -234,17 +235,17 @@ async def h5_websocket_endpoint(
|
||||
|
||||
# 步骤1: 优先从 Sec-WebSocket-Protocol (subprotocol) 获取 token,其次从 Authorization header,最后从 query(向后兼容)
|
||||
# 格式: Sec-WebSocket-Protocol: bearer.{token}
|
||||
subprotocol = request.headers.get("sec-websocket-protocol", "")
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "")
|
||||
if subprotocol.startswith("bearer."):
|
||||
token = subprotocol[7:] # 去掉 "bearer." 前缀
|
||||
else:
|
||||
# 其次从 Authorization header 获取
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
auth_header = websocket.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:] # 去掉 "Bearer " 前缀
|
||||
else:
|
||||
# 向后兼容:从 query param 获取(即将废弃)
|
||||
token = request.query_params.get("token", "")
|
||||
token = websocket.query_params.get("token", "")
|
||||
|
||||
# 步骤2: 检查 token 是否为空
|
||||
if not token:
|
||||
|
||||
@@ -99,6 +99,23 @@ class Settings(BaseSettings):
|
||||
# 是否启用 Mock 登录(默认 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 = "信息技术部"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 审批模板配置(企微审批应用)
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -107,6 +124,25 @@ class Settings(BaseSettings):
|
||||
# 设备申请审批模板ID(在企微审批应用设置中获取)
|
||||
approval_template_device: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.5.4 应急页身份检测配置
|
||||
# ----------------------------------------------------------------------
|
||||
# IT支持-咨询坐席 通讯录标签 ID(在企微管理后台 > 通讯录管理 > 标签管理 中查看)
|
||||
# 配置后,应急页会通过此标签判断当前用户是否为坐席
|
||||
# 留空则降级到下面的硬编码名单
|
||||
wecom_agent_tag_id: str = ""
|
||||
# 硬编码坐席 userid 列表(逗号分隔),作为标签检测的降级方案
|
||||
# 例:"zhangsan,lisi,wangwu"(生产环境建议用标签方案)
|
||||
wecom_agent_userids: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# v0.6.0 内容审核报警配置(占位,后续完善)
|
||||
# ----------------------------------------------------------------------
|
||||
# 合规通知企微群机器人 webhook
|
||||
content_audit_webhook: str = ""
|
||||
# 主管接收报警的 userid(多个用逗号分隔)
|
||||
content_audit_supervisor_userids: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Pydantic-settings 配置
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
+71
-8
@@ -37,6 +37,30 @@ logging.basicConfig(
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 应用生命周期管理(启动和关闭事件)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -290,14 +314,29 @@ async def _init_approval_links(db, ApprovalLink):
|
||||
return
|
||||
|
||||
links = [
|
||||
ApprovalLink(category="IT", title="软件安装申请", url="https://审批系统地址/software-install", sort_order=1),
|
||||
ApprovalLink(category="IT", title="设备报修工单", url="https://审批系统地址/device-repair", sort_order=2),
|
||||
ApprovalLink(category="IT", title="VPN开通申请", url="https://审批系统地址/vpn-apply", sort_order=3),
|
||||
ApprovalLink(category="IT", title="权限申请", url="https://审批系统地址/permission-apply", sort_order=4),
|
||||
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=5),
|
||||
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=6),
|
||||
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=7),
|
||||
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=8),
|
||||
# v0.5.2:一站式运维平台真实工单链接(域名 devops.dc.servyou-it.com,已实现企微免登录)
|
||||
# v0.5.3 更新:去掉 "IT设备升级与硬件维修" (申请单冲突,后续移除)
|
||||
ApprovalLink(category="IT", title="零信任(原VPN)账号申请",
|
||||
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7IT",
|
||||
sort_order=1),
|
||||
ApprovalLink(category="IT", title="活动与会议技术支持",
|
||||
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81",
|
||||
sort_order=2),
|
||||
# sort_order=3 故意空缺:旧版本是"IT设备升级与硬件维修",已与一站式运维平台冲突,不再提供
|
||||
ApprovalLink(category="IT", title="员工IT支持与故障报修",
|
||||
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE",
|
||||
sort_order=4),
|
||||
ApprovalLink(category="IT", title="终端设备网络准入申请",
|
||||
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7",
|
||||
sort_order=5),
|
||||
ApprovalLink(category="IT", title="公共邮箱账号申请",
|
||||
url="https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7",
|
||||
sort_order=6),
|
||||
# HR / 行政 / 财务 占位(待后续接入真实流程)
|
||||
ApprovalLink(category="HR", title="入职手续", url="https://审批系统地址/onboarding", sort_order=7),
|
||||
ApprovalLink(category="HR", title="离职手续", url="https://审批系统地址/offboarding", sort_order=8),
|
||||
ApprovalLink(category="行政", title="办公用品申领", url="https://审批系统地址/office-supplies", sort_order=9),
|
||||
ApprovalLink(category="财务", title="报销申请", url="https://审批系统地址/reimbursement", sort_order=10),
|
||||
]
|
||||
|
||||
db.add_all(links)
|
||||
@@ -477,6 +516,30 @@ def create_app() -> FastAPI:
|
||||
# 请求到达后端时 /api/ 已被 strip,因此此处不需要再加 /api 前缀
|
||||
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 路由
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
终端安全对比服务 - 火绒 vs 联软
|
||||
|
||||
功能:
|
||||
1. 获取未安装火绒的电脑清单
|
||||
2. 定时任务推送
|
||||
3. 手动触发
|
||||
|
||||
依赖:
|
||||
- 联软 LV7000: get_dev_all_info()
|
||||
- 火绒企业版: list_terminals()
|
||||
|
||||
比对逻辑:按主机名精确匹配
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from app.integrations.huorong.client import HuorongClient
|
||||
from app.integrations.lianruan.client import LianruanClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TerminalSecurityComparison:
|
||||
"""终端安全对比服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.huorong = HuorongClient()
|
||||
self.lianruan = LianruanClient()
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
await self.huorong.close()
|
||||
await self.lianruan.close()
|
||||
|
||||
async def get_no_huorong_devices(self) -> list[dict]:
|
||||
"""获取未安装火绒的电脑清单(按主机名匹配)"""
|
||||
logger.info("开始比对终端安全数据...")
|
||||
|
||||
# 1. 获取联软所有设备
|
||||
lianruan_devices = await self._get_all_lianruan_devices()
|
||||
logger.info(f"联软设备数: {len(lianruan_devices)}")
|
||||
|
||||
# 2. 获取火绒所有终端
|
||||
huorong_devices = await self._get_all_huorong_devices()
|
||||
logger.info(f"火绒终端数: {len(huorong_devices)}")
|
||||
|
||||
# 3. 构建火绒主机名集合(转小写匹配)
|
||||
huorong_hostnames = {
|
||||
dev.get("hostname", "").lower()
|
||||
for dev in huorong_devices
|
||||
if dev.get("hostname")
|
||||
}
|
||||
|
||||
# 4. 比对:联软有,火绒无 = 未安装火绒
|
||||
no_huorong = []
|
||||
for dev in lianruan_devices:
|
||||
# 联软用 strdevname (计算机名)
|
||||
hostname = dev.get("strdevname", "").lower()
|
||||
if hostname and hostname not in huorong_hostnames:
|
||||
no_huorong.append({
|
||||
"hostname": dev.get("strdevname"),
|
||||
"ip": dev.get("strip1"), # 联软IP字段
|
||||
"useraccount": dev.get("strusername"), # 用户名
|
||||
"dept": dev.get("strdeptname"), # 部门
|
||||
"last_login": dev.get("dtlastlogin"),
|
||||
"osver": dev.get("strosver"),
|
||||
"status": dev.get("strstatus"),
|
||||
})
|
||||
|
||||
logger.info(f"未安装火绒设备数: {len(no_huorong)}")
|
||||
return no_huorong
|
||||
|
||||
async def _get_all_lianruan_devices(self) -> list[dict]:
|
||||
"""获取联软所有设备"""
|
||||
# TODO: 分页获取全部设备
|
||||
result = await self.lianruan.get_dev_all_info()
|
||||
if result and hasattr(result, 'devices') and result.devices:
|
||||
# 转换为字典列表
|
||||
return [d.model_dump() if hasattr(d, 'model_dump') else d for d in result.devices]
|
||||
return []
|
||||
|
||||
async def _get_all_huorong_devices(self) -> list[dict]:
|
||||
"""获取火绒所有终端(分页获取)"""
|
||||
all_devices = []
|
||||
page = 1
|
||||
per_page = 200
|
||||
|
||||
while True:
|
||||
result = await self.huorong.list_terminals(page=page, per_page=per_page)
|
||||
clients = result.get("clients", [])
|
||||
if not clients:
|
||||
break
|
||||
|
||||
for c in clients:
|
||||
# 火绒字段:hostname, computer_name, ip_addr, local_ip
|
||||
all_devices.append({
|
||||
"hostname": c.get("hostname") or c.get("computer_name"),
|
||||
"ip": c.get("ip_addr") or c.get("local_ip"),
|
||||
"status": c.get("stat"),
|
||||
})
|
||||
|
||||
# 检查是否还有更多
|
||||
if len(clients) < per_page:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return all_devices
|
||||
|
||||
async def compare_summary(self) -> dict:
|
||||
"""比对汇总数据"""
|
||||
lianruan_devices = await self._get_all_lianruan_devices()
|
||||
huorong_devices = await self._get_all_huorong_devices()
|
||||
no_huorong = await self.get_no_huorong_devices()
|
||||
|
||||
return {
|
||||
"lianruan_count": len(lianruan_devices),
|
||||
"huorong_count": len(huorong_devices),
|
||||
"no_huorong_count": len(no_huorong),
|
||||
"compliance_rate": f"{len(huorong_devices)/len(lianruan_devices)*100:.1f}%" if lianruan_devices else "N/A",
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class ComparisonTaskConfig:
|
||||
"""定时任务配置"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: dict[str, dict] = {}
|
||||
|
||||
def add_task(self, task_id: str, config: dict):
|
||||
self.tasks[task_id] = config
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[dict]:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
def list_tasks(self) -> list[dict]:
|
||||
return [{"task_id": k, **v} for k, v in self.tasks.items()]
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
if task_id in self.tasks:
|
||||
del self.tasks[task_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
comparison_task_config = ComparisonTaskConfig()
|
||||
@@ -463,6 +463,101 @@ class WecomService:
|
||||
logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}")
|
||||
raise Exception(f"获取部门成员网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# JS-SDK 票据 (v0.5.4:应急页身份检测用)
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_jsapi_ticket(self) -> str:
|
||||
"""获取企微 JS-SDK 票据 jsapi_ticket。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=TOKEN
|
||||
|
||||
jsapi_ticket 用于计算 JS-SDK 签名(sha1),让前端 wx.config/wx.agentConfig 鉴权通过。
|
||||
有效期 7200 秒,缓存到 Redis(提前 300 秒刷新)。
|
||||
|
||||
Returns:
|
||||
str: jsapi_ticket 字符串
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
cache_key = "wecom:jsapi_ticket"
|
||||
|
||||
# 1. Redis 缓存
|
||||
if self.redis:
|
||||
try:
|
||||
cached = await self.redis.get(cache_key)
|
||||
if cached:
|
||||
logger.debug("从缓存获取 jsapi_ticket")
|
||||
return cached.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取 jsapi_ticket 失败(降级): {e}")
|
||||
|
||||
# 2. 调用企微 API
|
||||
access_token = await self.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token={access_token}"
|
||||
|
||||
try:
|
||||
response = await self.client.get(url)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取 jsapi_ticket 失败: "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取 jsapi_ticket 失败: {result.get('errmsg')}")
|
||||
|
||||
ticket = result.get("ticket", "")
|
||||
expires_in = result.get("expires_in", 7200)
|
||||
|
||||
# 3. 缓存到 Redis(TTL = expires_in - 300s)
|
||||
cache_ttl = max(expires_in - 300, 60)
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, cache_ttl, ticket)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入 jsapi_ticket 失败(降级): {e}")
|
||||
|
||||
logger.info(f"jsapi_ticket 获取成功,缓存 TTL={cache_ttl}秒")
|
||||
return ticket
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取 jsapi_ticket 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
@staticmethod
|
||||
def generate_jsapi_signature(
|
||||
ticket: str, nonce_str: str, timestamp: int, url: str
|
||||
) -> str:
|
||||
"""生成 JS-SDK 签名(sha1)。
|
||||
|
||||
对应企微JS-SDK签名算法:
|
||||
1. 拼接:jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url}
|
||||
2. sha1(拼接字符串)
|
||||
|
||||
注意:
|
||||
- url 不含 # 及其后面部分
|
||||
- url 不含 ?
|
||||
- url 是前端调用 wx.config 的页面 URL
|
||||
|
||||
Args:
|
||||
ticket: jsapi_ticket
|
||||
nonce_str: 随机字符串(前端生成,16位)
|
||||
timestamp: 当前时间戳(秒)
|
||||
url: 当前页面 URL(不含 # 后面)
|
||||
|
||||
Returns:
|
||||
str: sha1 签名字符串(40 字符)
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
# 拼接签名字符串
|
||||
raw = f"jsapi_ticket={ticket}&noncestr={nonce_str}×tamp={timestamp}&url={url}"
|
||||
# sha1 哈希
|
||||
signature = hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
return signature
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 上传临时素材
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -44,7 +44,7 @@ async def h5_client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncCli
|
||||
app = create_app()
|
||||
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):
|
||||
transport = ASGITransport(app=app)
|
||||
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()
|
||||
Binary file not shown.
+34
-28
@@ -1,6 +1,6 @@
|
||||
# 智能IT支持服务台 — 新服务器部署手册
|
||||
|
||||
> **目标服务器**:`10.80.0.136`(公司内网)
|
||||
> **目标服务器**:`10.90.5.110`(公司内网,**2026-06-15 起替代 10.80.0.136**)
|
||||
> **域名**:`itsupport.servyou.com.cn`
|
||||
> **访问方式**:通过堡垒机 `10.212.189.210:2222`(用户 `sxn`,OTP 动态口令认证)
|
||||
> **Docker**:已安装
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
| 条件 | 状态 | 验证命令 |
|
||||
|------|------|---------|
|
||||
| Linux 服务器 10.80.0.136 | ✅ 已确认 | — |
|
||||
| Linux 服务器 10.90.5.110(替代旧 10.80.0.136) | ✅ 已确认 | 2026-06-15 起使用 |
|
||||
| Docker 已安装 | ✅ 已确认 | `docker --version` |
|
||||
| Docker Compose V2 | 待确认 | `docker compose version` |
|
||||
| 端口 80 未被占用 | 待确认 | `ss -tlnp \| grep :80` |
|
||||
@@ -29,17 +29,22 @@
|
||||
|
||||
### 2.2 连接方式
|
||||
|
||||
```bash
|
||||
# 方式一:ssh -J 一步跳转(推荐)
|
||||
# -J 指定跳板机,ssh 会自动帮你跳转
|
||||
# 堡垒机端口 2222,需要输入 OTP 动态口令
|
||||
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
|
||||
**PuTTY 客户端(用户实际使用)**:
|
||||
- 打开 PuTTY
|
||||
- Host Name(IP 地址):`10.212.189.210`
|
||||
- Port:`2222`
|
||||
- Connection type:SSH
|
||||
- Saved Sessions:起名(如 `wecom-bastion`)→ Save
|
||||
- 点 Open
|
||||
- 用户 `sxn` + 密码
|
||||
- **堡垒机内再跳目标机**:
|
||||
```bash
|
||||
ssh sxn@10.90.5.110
|
||||
```
|
||||
|
||||
# 方式二:先登录堡垒机,再手动跳转
|
||||
ssh -p 2222 sxn@10.212.189.210
|
||||
# 输入 OTP 动态口令
|
||||
> **OpenSSH `ssh -J` 方式不再使用**(用户已确认用 PuTTY,2026-06-15)
|
||||
# 登录成功后:
|
||||
ssh sxn@10.80.0.136
|
||||
ssh sxn@10.90.5.110
|
||||
```
|
||||
|
||||
### 2.3 配置 SSH 快捷方式(推荐)
|
||||
@@ -55,7 +60,7 @@ Host bastion
|
||||
|
||||
# 智能IT支持服务台服务器
|
||||
Host itdesk
|
||||
HostName 10.80.0.136
|
||||
HostName 10.90.5.110
|
||||
User sxn
|
||||
ProxyJump bastion
|
||||
```
|
||||
@@ -78,7 +83,7 @@ scp file itdesk:/opt/ # 文件传输也会自动走堡垒机
|
||||
# 上传单个文件
|
||||
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
|
||||
it-smart-desk-server-deploy.zip \
|
||||
sxn@10.80.0.136:/opt/
|
||||
sxn@10.90.5.110:/opt/
|
||||
|
||||
# 如果已配置 ~/.ssh/config:
|
||||
scp it-smart-desk-server-deploy.zip itdesk:/opt/
|
||||
@@ -96,7 +101,7 @@ scp -P 2222 it-smart-desk-server-deploy.zip sxn@10.212.189.210:/tmp/
|
||||
ssh -p 2222 sxn@10.212.189.210
|
||||
|
||||
# 步骤3:从堡垒机传到目标服务器
|
||||
scp /tmp/it-smart-desk-server-deploy.zip sxn@10.80.0.136:/opt/
|
||||
scp /tmp/it-smart-desk-server-deploy.zip sxn@10.90.5.110:/opt/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -133,17 +138,18 @@ npm install && npm run build
|
||||
# 在开发机上执行
|
||||
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
|
||||
it-smart-desk-server-deploy.zip \
|
||||
sxn@10.80.0.136:/tmp/
|
||||
sxn@10.90.5.110:/tmp/
|
||||
```
|
||||
|
||||
> 上传到 `/tmp/` 而非 `/opt/`,因为普通用户对 `/opt/` 没有写权限
|
||||
|
||||
### 步骤 3:SSH 登录服务器并解压
|
||||
### 步骤 3:登录服务器并解压
|
||||
|
||||
**PuTTY 登录**(见 §2.2):
|
||||
- Host:`10.212.189.210`,Port:`2222`,SSH
|
||||
- 堡垒机内再 `ssh sxn@10.90.5.110`
|
||||
|
||||
```bash
|
||||
# 登录目标服务器
|
||||
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
|
||||
|
||||
# 切换 root(普通用户对 /opt 无写权限)
|
||||
sudo -i
|
||||
|
||||
@@ -237,7 +243,7 @@ docker compose logs --tail 50 postgres
|
||||
需要联系公司 IT 运维,在公司 DNS 上添加 A 记录:
|
||||
|
||||
```
|
||||
itsupport.servyou.com.cn A 10.80.0.136
|
||||
itsupport.servyou.com.cn A 10.90.5.110
|
||||
```
|
||||
|
||||
**DNS 未生效前**,可以通过本地 hosts 文件测试:
|
||||
@@ -246,7 +252,7 @@ itsupport.servyou.com.cn A 10.80.0.136
|
||||
# Windows: C:\Windows\System32\drivers\etc\hosts
|
||||
# macOS/Linux: /etc/hosts
|
||||
# 添加一行:
|
||||
10.80.0.136 itsupport.servyou.com.cn
|
||||
10.90.5.110 itsupport.servyou.com.cn
|
||||
```
|
||||
|
||||
> 注意:修改 hosts 文件后,浏览器可能有 DNS 缓存。Chrome 可访问 `chrome://net-internals/#dns` 清除缓存,或用无痕窗口测试。
|
||||
@@ -324,11 +330,11 @@ cd frontend-agent && npm run build
|
||||
# 2. 上传到服务器(通过堡垒机)
|
||||
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
|
||||
-r frontend-h5/dist/ \
|
||||
sxn@10.80.0.136:/opt/wecom-it-desk/frontend-h5/dist/
|
||||
sxn@10.90.5.110:/opt/wecom-it-desk/frontend-h5/dist/
|
||||
|
||||
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
|
||||
-r frontend-agent/dist/ \
|
||||
sxn@10.80.0.136:/opt/wecom-it-desk/frontend-agent/dist/
|
||||
sxn@10.90.5.110:/opt/wecom-it-desk/frontend-agent/dist/
|
||||
|
||||
# 3. 重载 Nginx(不需要重启整个服务)
|
||||
ssh itdesk # 如果已配置 SSH 快捷方式
|
||||
@@ -344,7 +350,7 @@ docker exec wecom_it_nginx nginx -s reload
|
||||
# 1. 上传新代码到服务器
|
||||
scp -o "ProxyJump=sxn@10.212.189.210:2222" \
|
||||
-r backend/ \
|
||||
sxn@10.80.0.136:/opt/wecom-it-desk/backend/
|
||||
sxn@10.90.5.110:/opt/wecom-it-desk/backend/
|
||||
|
||||
# 2. 重新构建并启动
|
||||
ssh itdesk
|
||||
@@ -400,8 +406,8 @@ docker exec wecom_it_nginx cat /usr/share/nginx/html/itagent/index.html | grep /
|
||||
nslookup itsupport.servyou.com.cn
|
||||
|
||||
# 如果 DNS 未配置,临时用 IP 直接访问
|
||||
curl http://10.80.0.136/itdesk/
|
||||
curl http://10.80.0.136/api/health
|
||||
curl http://10.90.5.110/itdesk/
|
||||
curl http://10.90.5.110/api/health
|
||||
```
|
||||
|
||||
### Mock 登录返回 401
|
||||
@@ -428,7 +434,7 @@ curl -X POST http://localhost/api/h5/mock-login \
|
||||
### 方式一:公司统一 SSL 终端(推荐)
|
||||
|
||||
```
|
||||
客户端 → HTTPS → 公司SSL终端(F5/网关) → HTTP → 10.80.0.136:80
|
||||
客户端 → HTTPS → 公司SSL终端(F5/网关,公网 115.236.188.3) → HTTP → 10.90.5.110:80
|
||||
```
|
||||
|
||||
不需要在本服务器上配置证书。联系运维配置 SSL 终端即可。
|
||||
@@ -441,7 +447,7 @@ curl -X POST http://localhost/api/h5/mock-login \
|
||||
|
||||
## 十一、与 NAS 部署的差异
|
||||
|
||||
| 维度 | NAS 部署(10.80.0.136 旧) | 新服务器部署(10.80.0.136 新) |
|
||||
| 维度 | NAS 部署(10.80.0.136,已下线) | 新服务器部署(10.90.5.110,2026-06-15 起) |
|
||||
|------|---------------------------|-------------------------------|
|
||||
| 容器数量 | 5个(含 cloudflared) | 4个(无 cloudflared) |
|
||||
| 外网访问 | Cloudflare Tunnel | 公司 DNS 直连 |
|
||||
|
||||
@@ -27,6 +27,21 @@ http {
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
|
||||
# ------------------------------------------------------------------
|
||||
# 问题:公司有 WAF/堡垒机/反向代理,nginx 看到的 $remote_addr
|
||||
# 是代理 IP(不在白名单),allow/deny 因此误判 403
|
||||
# 修法:信任内网段代理透传的 X-Forwarded-For 头,用真实 IP 做白名单
|
||||
# 注意:set_real_ip_from 是"我信任的代理",不是"我允许的客户端"
|
||||
# 必须精确,否则攻击者可伪造 X-Forwarded-For 绕过白名单
|
||||
set_real_ip_from 10.0.0.0/8; # 内网 A 类(代理/WAF 出口)
|
||||
set_real_ip_from 172.16.0.0/12; # 内网 B 类
|
||||
set_real_ip_from 192.168.0.0/16; # 内网 C 类
|
||||
set_real_ip_from 10.212.0.0/16; # VPN 网段
|
||||
real_ip_header X-Forwarded-For; # 从 X-Forwarded-For 取最后一个非信任 IP
|
||||
real_ip_recursive on; # 递归剥离已信任代理 IP
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基础配置
|
||||
# ------------------------------------------------------------------
|
||||
@@ -60,29 +75,58 @@ http {
|
||||
# 如果公司有统一 SSL 终端(如 F5/Nginx 反代),此服务器只需监听 80
|
||||
# 如果需要本机 HTTPS,取消下方 server 块注释,并配置证书路径
|
||||
# =================================================================
|
||||
# HTTP — 80 端口强制 301 跳 HTTPS
|
||||
# =================================================================
|
||||
server {
|
||||
listen 80;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
|
||||
# ACME http-01 验证用(如果以后用 Let's Encrypt)
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
# 其他全部 301 跳 https
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# HTTPS — 443 端口(主服务)
|
||||
# =================================================================
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name itsupport.servyou.com.cn;
|
||||
|
||||
# SSL 证书(通配符 *.servyou.com.cn,fullchain 含 leaf+intermediate+root)
|
||||
ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 安全头
|
||||
# ------------------------------------------------------------------
|
||||
# 基础安全头
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
|
||||
# CSP 收紧: 去掉 unsafe-inline(生产不需要,只有 dev HMR 需要)
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*; font-src 'self' data:;" always;
|
||||
|
||||
|
||||
# 隐私与跨域控制
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||
add_header Cross-Origin-Resource-Policy "same-origin" always;
|
||||
|
||||
|
||||
# 隐藏服务器版本
|
||||
server_tokens off;
|
||||
|
||||
@@ -150,7 +194,7 @@ http {
|
||||
allow 10.212.0.0/16;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://backend_api/;
|
||||
proxy_pass http://backend_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -195,29 +239,10 @@ http {
|
||||
# 此路径已包含在 /api/ 的代理规则中,无需单独配置
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 默认路径 — 重定向到 H5 员工端
|
||||
# 默认路径 — 重定向到统一入口
|
||||
# ------------------------------------------------------------------
|
||||
location = / {
|
||||
return 302 /itdesk/;
|
||||
return 302 /itportal/;
|
||||
}
|
||||
}
|
||||
|
||||
# =================================================================
|
||||
# HTTPS 配置(按需启用)
|
||||
# =================================================================
|
||||
# 如果需要本机直接提供 HTTPS(不走公司统一 SSL 终端),
|
||||
# 取消下方注释并配置 SSL 证书路径
|
||||
#
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name itsupport.servyou.com.cn;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/itsupport.servyou.com.cn.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/itsupport.servyou.com.cn.key;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
#
|
||||
# # 其余 location 配置与上方 HTTP server 相同
|
||||
# ...
|
||||
# }
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ INCLUDE_MAP = {
|
||||
"deploy-server/nginx/nginx.conf": f"{PACKAGE_PREFIX}/nginx/nginx.conf",
|
||||
"frontend-h5/dist": f"{PACKAGE_PREFIX}/frontend-h5/dist",
|
||||
"frontend-agent/dist": f"{PACKAGE_PREFIX}/frontend-agent/dist",
|
||||
"frontend-portal/dist": f"{PACKAGE_PREFIX}/frontend-portal/dist",
|
||||
"frontend-admin/dist": f"{PACKAGE_PREFIX}/frontend-admin/dist",
|
||||
"backend": f"{PACKAGE_PREFIX}/backend",
|
||||
}
|
||||
|
||||
@@ -75,6 +77,9 @@ def run_cmd(cmd: str, cwd: Path | None = None) -> bool:
|
||||
|
||||
def should_exclude(path: Path) -> bool:
|
||||
"""判断文件/目录是否应排除"""
|
||||
# 路径中任何一段是 uploads 目录就排除(隐私 P0:真实用户上传文件不进部署包)
|
||||
if "uploads" in path.parts:
|
||||
return True
|
||||
name = path.name
|
||||
if name in {"__pycache__", ".pytest_cache", ".venv", "venv", ".git", ".env", "node_modules"}:
|
||||
return True
|
||||
@@ -121,6 +126,32 @@ def build_frontends():
|
||||
sys.exit(1)
|
||||
print(" ✅ 坐席工作台构建完成")
|
||||
|
||||
# 统一入口 Portal
|
||||
portal_dir = PROJECT_ROOT / "frontend-portal"
|
||||
if (portal_dir / "package.json").exists():
|
||||
print("构建统一入口 Portal...")
|
||||
if not run_cmd("npm install --quiet", cwd=portal_dir):
|
||||
print(" ⚠ npm install 失败,尝试继续...")
|
||||
if not run_cmd("npm run build", cwd=portal_dir):
|
||||
print(" ❌ Portal 端构建失败!")
|
||||
sys.exit(1)
|
||||
print(" ✅ Portal 端构建完成")
|
||||
else:
|
||||
print(" ⏭ Portal 端未实现,跳过")
|
||||
|
||||
# 管理后台 Admin
|
||||
admin_dir = PROJECT_ROOT / "frontend-admin"
|
||||
if (admin_dir / "package.json").exists():
|
||||
print("构建管理后台 Admin...")
|
||||
if not run_cmd("npm install --quiet", cwd=admin_dir):
|
||||
print(" ⚠ npm install 失败,尝试继续...")
|
||||
if not run_cmd("npm run build", cwd=admin_dir):
|
||||
print(" ❌ Admin 端构建失败!")
|
||||
sys.exit(1)
|
||||
print(" ✅ Admin 端构建完成")
|
||||
else:
|
||||
print(" ⏭ Admin 端未实现,跳过")
|
||||
|
||||
|
||||
def create_package():
|
||||
"""创建部署包 zip"""
|
||||
@@ -181,13 +212,13 @@ def main():
|
||||
print(" 后续步骤:")
|
||||
print("=" * 50)
|
||||
print(f"""
|
||||
1. 上传部署包到服务器(通过堡垒机):
|
||||
scp -o "ProxyJump=sxn@10.212.189.210:2222" \\
|
||||
{ZIP_FILENAME} \\
|
||||
sxn@10.80.0.136:/tmp/
|
||||
1. 上传部署包到服务器(通过堡垒机 / PuTTY PSCP):
|
||||
pscp -load wecom-bastion {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
|
||||
# 或堡垒机内 scp:
|
||||
# scp {ZIP_FILENAME} sxn@10.90.5.110:/tmp/
|
||||
|
||||
2. SSH 登录服务器(通过堡垒机):
|
||||
ssh -J sxn@10.212.189.210:2222 sxn@10.80.0.136
|
||||
2. PuTTY 登录服务器:
|
||||
- Host 10.212.189.210 Port 2222 → 用户 sxn → 堡垒机内 ssh sxn@10.90.5.110
|
||||
|
||||
3. 在服务器上执行:
|
||||
sudo cp /tmp/{ZIP_FILENAME} /opt/
|
||||
@@ -201,7 +232,7 @@ def main():
|
||||
./deploy.sh
|
||||
|
||||
4. 配置 DNS(联系 IT 运维):
|
||||
itsupport.servyou.com.cn → 10.80.0.136
|
||||
itsupport.servyou.com.cn → 10.90.5.110
|
||||
|
||||
5. 浏览器验证:
|
||||
http://itsupport.servyou.com.cn/itdesk/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
IyEvYmluL2Jhc2gKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQojIC9pdGRlc2svIDUwMCDplJnor6/or4rmlq3ohJrmnKwKIyDlnKjnlJ/kuqfmnI3liqHlmaggMTAuODAuMC4xMzYg5LiK6LeRKFNTSCDnmbvlvZXlkI4pOgojICAgY2QgL29wdC93ZWNvbS1pdC1kZXNrCiMgICBiYXNoIGRpYWdub3NlLTUwMC5zaCA+IC90bXAvZGlhZy5sb2cgMj4mMQojICAgY2F0IC90bXAvZGlhZy5sb2cKIyA9PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PQoKZWNobyAiPT09PT09PT09PSAxLiDlrrnlmajnirbmgIEgPT09PT09PT09PSIKZG9ja2VyIGNvbXBvc2UgcHMKCmVjaG8gIiIKZWNobyAiPT09PT09PT09PSAyLiAvb3B0L3dlY29tLWl0LWRlc2sg55uu5b2V57uT5p6EID09PT09PT09PT0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gZnJvbnRlbmQtaDUvZGlzdCAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtaDUvZGlzdC8gMj4mMSB8IGhlYWQgLTEwCmVjaG8gIi0tLSBmcm9udGVuZC1oNS9kaXN0L2Fzc2V0cyAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtaDUvZGlzdC9hc3NldHMvIDI+JjEgfCBoZWFkIC0xMAplY2hvICItLS0gZnJvbnRlbmQtYWdlbnQvZGlzdC9hc3NldHMgLS0tIgpscyAtbGEgL29wdC93ZWNvbS1pdC1kZXNrL2Zyb250ZW5kLWFnZW50L2Rpc3QvYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIGZyb250ZW5kLXBvcnRhbC9kaXN0L2Fzc2V0cyAtLS0iCmxzIC1sYSAvb3B0L3dlY29tLWl0LWRlc2svZnJvbnRlbmQtcG9ydGFsL2Rpc3QvYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIGZyb250ZW5kLWFkbWluL2Rpc3QvYXNzZXRzIC0tLSIKbHMgLWxhIC9vcHQvd2Vjb20taXQtZGVzay9mcm9udGVuZC1hZG1pbi9kaXN0L2Fzc2V0cy8gMj4mMSB8IGhlYWQgLTEwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gMy4gbmdpbngg5a655Zmo5YaF5paH5Lu25qOA5p+lID09PT09PT09PT0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC91c3Ivc2hhcmUvbmdpbngvaHRtbC8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSAvdXNyL3NoYXJlL25naW54L2h0bWwvaXRkZXNrIC0tLSIKZG9ja2VyIGNvbXBvc2UgZXhlYyBuZ2lueCBscyAtbGEgL3Vzci9zaGFyZS9uZ2lueC9odG1sL2l0ZGVzay8gMj4mMSB8IGhlYWQgLTEwCmVjaG8gIi0tLSAvdXNyL3NoYXJlL25naW54L2h0bWwvaXRkZXNrL2Fzc2V0cyAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC91c3Ivc2hhcmUvbmdpbngvaHRtbC9pdGRlc2svYXNzZXRzLyAyPiYxIHwgaGVhZCAtMTAKZWNobyAiLS0tIC91c3Ivc2hhcmUvbmdpbngvc3NsLyAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbHMgLWxhIC9ldGMvbmdpbngvc3NsLyAyPiYxIHwgaGVhZCAtMTAKCmVjaG8gIiIKZWNobyAiPT09PT09PT09PSA0LiBuZ2lueCDphY3nva7lrp7pmYXnlJ/mlYjniYjmnKwo5aS06YOoIDUwIOihjCk9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IGNhdCAvZXRjL25naW54L25naW54LmNvbmYgMj4mMSB8IGhlYWQgLTUwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gNS4gbmdpbngg5a655Zmo56uv5Y+j55uR5ZCsID09PT09PT09PT0iCmRvY2tlciBjb21wb3NlIGV4ZWMgbmdpbnggbmV0c3RhdCAtdGxucCAyPiYxIHwgaGVhZCAtMTAKZWNobyAiKOayoSBuZXRzdGF0IOeUqCBzczopIgpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IHNzIC10bG5wIDI+JjEgfCBoZWFkIC0xMAoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDYuIOebtOaOpSBjdXJsIOa1i+ivleWQhOi3r+W+hCA9PT09PT09PT09IgplY2hvICItLS0gL2l0ZGVzay8gKOWuueWZqOWGhSkgLS0tIgpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IGN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdC9pdGRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0ZGVzay8gKOWuueWZqOWkluS4u+acuiA0NDMpIC0tLSIKY3VybCAta3NJIGh0dHBzOi8vbG9jYWxob3N0OjQ0My9pdGRlc2svIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0cG9ydGFsLyAtLS0iCmN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdDo0NDMvaXRwb3J0YWwvIDI+JjEgfCBoZWFkIC0yMAplY2hvICItLS0gL2l0ZGVzay9hc3NldHMvICjmjqIgNDA0KSAtLS0iCmN1cmwgLWtzSSBodHRwczovL2xvY2FsaG9zdDo0NDMvaXRkZXNrL2Fzc2V0cy8gMj4mMSB8IGhlYWQgLTIwCgplY2hvICIiCmVjaG8gIj09PT09PT09PT0gNy4g5Li75py65a6e6ZmFIFVSTCDln5/lkI0gPT09PT09PT09PSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0ZGVzay8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0cG9ydGFsLyAyPiYxIHwgaGVhZCAtMjAKZWNobyAiLS0tIgpjdXJsIC1rc0kgaHR0cHM6Ly9pdHN1cHBvcnQuc2VydnlvdS5jb20uY24vaXRhZ2VudC8gMj4mMSB8IGhlYWQgLTIwCmVjaG8gIi0tLSIKY3VybCAta3NJIGh0dHBzOi8vaXRzdXBwb3J0LnNlcnZ5b3UuY29tLmNuL2l0YWRtaW4vIDI+JjEgfCBoZWFkIC0yMAoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDguIG5naW54IGFjY2VzcyBsb2cg5pyA6L+RIDMwIOihjCjmib4gNTAwIOivt+axgik9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBleGVjIG5naW54IHRhaWwgLTMwIC92YXIvbG9nL25naW54L2FjY2Vzcy5sb2cgMj4mMQplY2hvICIiCmVjaG8gIj09PT09PT09PT0gOS4gbmdpbnggZXJyb3IgbG9nIOacgOi/kSAzMCDooYwgPT09PT09PT09PSIKZG9ja2VyIGNvbXBvc2UgZXhlYyBuZ2lueCB0YWlsIC0zMCAvdmFyL2xvZy9uZ2lueC9lcnJvci5sb2cgMj4mMQoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDEwLiBiYWNrZW5kIOWuueWZqOWBpeW6tyA9PT09PT09PT09Igpkb2NrZXIgY29tcG9zZSBwcyBiYWNrZW5kCmVjaG8gIi0tLSBiYWNrZW5kIGhlYWx0aCBlbmRwb2ludCAtLS0iCmRvY2tlciBjb21wb3NlIGV4ZWMgYmFja2VuZCBjdXJsIC1rcyBodHRwOi8vbG9jYWxob3N0OjgwMDAvYXBpL2hlYWx0aCAyPiYxIHwgaGVhZCAtNQoKZWNobyAiIgplY2hvICI9PT09PT09PT09IDExLiDnnIvkuIDkuIvlkI7nq6/orr/pl64gL2FwaS9oNS9tZSAoSDUg5ZCv5Yqo5pe25Lya6LCDKT09PT09PT09PT0iCmVjaG8gIi0tLSAvYXBpL2g1L21lIOaXoCB0b2tlbiAtLS0iCmN1cmwgLWtzIC1pIC1YIEdFVCBodHRwczovL2l0c3VwcG9ydC5zZXJ2eW91LmNvbS5jbi9hcGkvaDUvbWUgMj4mMSB8IGhlYWQgLTEwCg==
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# /itdesk/ 500 错误诊断脚本
|
||||
# 在生产服务器 10.90.5.110 上跑(PuTTY 登录后):
|
||||
# cd /opt/wecom-it-desk
|
||||
# bash diagnose-500.sh > /tmp/diag.log 2>&1
|
||||
# cat /tmp/diag.log
|
||||
# =============================================================================
|
||||
|
||||
echo "========== 1. 容器状态 =========="
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "========== 2. /opt/wecom-it-desk 目录结构 =========="
|
||||
ls -la /opt/wecom-it-desk/ 2>&1 | head -20
|
||||
echo "--- frontend-h5/dist ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1 | head -10
|
||||
echo "--- frontend-h5/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-agent/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-agent/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-portal/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-portal/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-admin/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-admin/dist/assets/ 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 3. nginx 容器内文件检查 =========="
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/ 2>&1 | head -20
|
||||
echo "--- /usr/share/nginx/html/itdesk ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1 | head -10
|
||||
echo "--- /usr/share/nginx/html/itdesk/assets ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/assets/ 2>&1 | head -10
|
||||
echo "--- /usr/share/nginx/ssl/ ---"
|
||||
docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 4. nginx 配置实际生效版本(头部 50 行)=========="
|
||||
docker compose exec nginx cat /etc/nginx/nginx.conf 2>&1 | head -50
|
||||
|
||||
echo ""
|
||||
echo "========== 5. nginx 容器端口监听 =========="
|
||||
docker compose exec nginx netstat -tlnp 2>&1 | head -10
|
||||
echo "(没 netstat 用 ss:)"
|
||||
docker compose exec nginx ss -tlnp 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 6. 直接 curl 测试各路径 =========="
|
||||
echo "--- /itdesk/ (容器内) ---"
|
||||
docker compose exec nginx curl -ksI https://localhost/itdesk/ 2>&1 | head -20
|
||||
echo "--- /itdesk/ (容器外主机 443) ---"
|
||||
curl -ksI https://localhost:443/itdesk/ 2>&1 | head -20
|
||||
echo "--- /itportal/ ---"
|
||||
curl -ksI https://localhost:443/itportal/ 2>&1 | head -20
|
||||
echo "--- /itdesk/assets/ (探 404) ---"
|
||||
curl -ksI https://localhost:443/itdesk/assets/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "========== 7. 主机实际 URL 域名 =========="
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "========== 8. nginx access log 最近 30 行(找 500 请求)=========="
|
||||
docker compose exec nginx tail -30 /var/log/nginx/access.log 2>&1
|
||||
echo ""
|
||||
echo "========== 9. nginx error log 最近 30 行 =========="
|
||||
docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1
|
||||
|
||||
echo ""
|
||||
echo "========== 10. backend 容器健康 =========="
|
||||
docker compose ps backend
|
||||
echo "--- backend health endpoint ---"
|
||||
docker compose exec backend curl -ks http://localhost:8000/api/health 2>&1 | head -5
|
||||
|
||||
echo ""
|
||||
echo "========== 11. 看一下后端访问 /api/h5/me (H5 启动时会调)=========="
|
||||
echo "--- /api/h5/me 无 token ---"
|
||||
curl -ks -i -X GET https://itsupport.servyou.com.cn/api/h5/me 2>&1 | head -10
|
||||
@@ -0,0 +1,102 @@
|
||||
# =============================================================================
|
||||
# 企微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
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo=
|
||||
@@ -0,0 +1 @@
|
||||
IyEvYmluL2Jhc2gKc2V0ICtlICAgIyBjb2xsZWN0IGV2ZXJ5dGhpbmcsIGRvbid0IGJhaWwKCmVjaG8gJyMjIyMjIyMjIyMjIyBTVEVQIDE6IExvY2F0ZSBwcm9qZWN0IGRpcmVjdG9yeSAjIyMjIyMjIyMjIyMnCmNkIC9vcHQvd2Vjb20taXQtZGVzayAyPiYxCmVjaG8gIkN1cnJlbnQgZGlyOiAkKHB3ZCkiCmxzIC1sYSBkb2NrZXItY29tcG9zZS55bWwgMj4mMQplY2hvICcnCgplY2hvICcjIyMjIyMjIyMjIyMgU1RFUCAyOiBEaWFnbm9zZSAoUkVBRC1PTkxZKSAjIyMjIyMjIyMjIyMnCmVjaG8gJy0tLSBBbGwgd2Vjb21faXRfIGNvbnRhaW5lcnMgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwplY2hvICctLS0gRGlzayBzcGFjZSAtLS0nCmRmIC1oIC9vcHQgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGxhc3QgNjAgbG9nIGxpbmVzIC0tLScKZG9ja2VyIGxvZ3Mgd2Vjb21faXRfYmFja2VuZCAtLXRhaWwgNjAgMj4mMQplY2hvICcnCmVjaG8gJy0tLSBiYWNrZW5kIGludGVybmFsIGhlYWx0aCBjaGVjayAtLS0nCmRvY2tlciBleGVjIHdlY29tX2l0X2JhY2tlbmQgY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoIDI+JjEKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgMzogUmVzdGFydCBmcm9tIGNvcnJlY3QgZGlyZWN0b3J5
|
||||
@@ -0,0 +1 @@
|
||||
ICMjIyMjIyMjIyMjIycKY2QgL29wdC93ZWNvbS1pdC1kZXNrCmRvY2tlciBjb21wb3NlIHVwIC1kIDI+JjEKZWNobyAnJwplY2hvICdXYWl0aW5nIDE1cyBmb3Igc2VydmljZXMgdG8gc3RhYmlsaXplLi4uJwpzbGVlcCAxNQplY2hvICcnCmVjaG8gJy0tLSBDb250YWluZXJzIGFmdGVyIHJlc3RhcnQgLS0tJwpkb2NrZXIgcHMgLWEgLS1mb3JtYXQgInRhYmxlIHt7Lk5hbWVzfX1cdHt7LlN0YXR1c319IiB8IGdyZXAgLUUgIndlY29tX2l0X3xOQU1FUyIKZWNobyAnJwoKZWNobyAnIyMjIyMjIyMjIyMjIFNURVAgNDogRW5kLXRvLWVuZCB2ZXJpZmljYXRpb24gIyMjIyMjIyMjIyMjJwplY2hvICctLS0gYmFja2VuZCAvaGVhbHRoIC0tLScKY3VybCAtcyAtbyAtIC13ICJcbkhUVFBfQ09ERTogJXtodHRwX2NvZGV9XG4iIC0tbWF4LXRpbWUgNSBodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoCmVjaG8gJycKZWNobyAnLS0tIG5naW54IHJvdXRlcyAoZXhwZWN0IDIwMC8zMDEvMzAyKSAtLS0nCmZvciBwYXRoIGluIC8gL2l0YWdlbnQvIC9pdGg1LyAvaXRhZG1pbi87IGRvCiAgY29kZT0kKGN1cmwgLXMgLW8gL2Rldi9udWxsIC13ICIle2h0dHBfY29kZX0iIC0tbWF4LXRpbWUgNSAiaHR0cDovL2xvY2FsaG9zdCR7cGF0aH0iKQogIGVjaG8gIiAgJHBhdGggLT4gSFRUUCAkY29kZSIKZG9uZQplY2hvICcnCmVjaG8gJyMjIyMjIyMjIyMjIyBET05FICMjIyMjIyMjIyMjIycKZWNobyAnUGFzdGUgQUxMIG91dHB1dCBhYm92ZSBiYWNrIHRvIENsYXVkZSBmb3IgZGlhZ25vc2lzJwo=
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set +e # collect everything, don't bail
|
||||
|
||||
echo '############ STEP 1: Locate project directory ############'
|
||||
cd /opt/wecom-it-desk 2>&1
|
||||
echo "Current dir: $(pwd)"
|
||||
ls -la docker-compose.yml 2>&1
|
||||
echo ''
|
||||
|
||||
echo '############ STEP 2: Diagnose (READ-ONLY) ############'
|
||||
echo '--- All wecom_it_ containers ---'
|
||||
docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES"
|
||||
echo ''
|
||||
echo '--- Disk space ---'
|
||||
df -h /opt 2>&1
|
||||
echo ''
|
||||
echo '--- backend last 60 log lines ---'
|
||||
docker logs wecom_it_backend --tail 60 2>&1
|
||||
echo ''
|
||||
echo '--- backend internal health check ---'
|
||||
docker exec wecom_it_backend curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health 2>&1
|
||||
echo ''
|
||||
|
||||
echo '############ STEP 3: Restart from correct directory ############'
|
||||
cd /opt/wecom-it-desk
|
||||
docker compose up -d 2>&1
|
||||
echo ''
|
||||
echo 'Waiting 15s for services to stabilize...'
|
||||
sleep 15
|
||||
echo ''
|
||||
echo '--- Containers after restart ---'
|
||||
docker ps -a --format "table {{.Names}}\t{{.Status}}" | grep -E "wecom_it_|NAMES"
|
||||
echo ''
|
||||
|
||||
echo '############ STEP 4: End-to-end verification ############'
|
||||
echo '--- backend /health ---'
|
||||
curl -s -o - -w "\nHTTP_CODE: %{http_code}\n" --max-time 5 http://localhost:8000/health
|
||||
echo ''
|
||||
echo '--- nginx routes (expect 200/301/302) ---'
|
||||
for path in / /itagent/ /ith5/ /itadmin/; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "http://localhost${path}")
|
||||
echo " $path -> HTTP $code"
|
||||
done
|
||||
echo ''
|
||||
echo '############ DONE ############'
|
||||
echo 'Paste ALL output above back to Claude for diagnosis'
|
||||
Generated
+4
@@ -23,6 +23,10 @@
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <21.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
||||
@@ -13,11 +13,18 @@
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"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",
|
||||
"axios": "^1.7.0",
|
||||
"codemirror": "^6.0.1",
|
||||
"element-plus": "^2.7.0",
|
||||
"pinia": "^2.1.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-codemirror": "^6.0.1",
|
||||
"vue-json-pretty": "^2.2.4",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"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>
|
||||
@@ -37,6 +37,9 @@ export interface Agent {
|
||||
today_resolved?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// OTP 二次验证(P0-#5 坐席本地密码配套)
|
||||
otp_enabled?: number // 0/1, 是否启用 OTP
|
||||
otp_secret?: string // OTP 密钥(敏感)
|
||||
}
|
||||
|
||||
/** 坐席状态 */
|
||||
@@ -340,6 +343,7 @@ export interface HuorongTerminalDetail {
|
||||
version: string // 火绒客户端版本
|
||||
is_online: boolean // 在线状态
|
||||
last_connect_time?: number // 最后连接时间(Unix时间戳)
|
||||
group_id?: number | string // 分组ID(_info2 可能返回)
|
||||
// 硬件信息(可选,_info2 返回)
|
||||
cpu?: string
|
||||
memory?: string
|
||||
|
||||
@@ -2,230 +2,382 @@
|
||||
=============================================================================
|
||||
企微IT智能服务台 — 排查流程图管理页
|
||||
=============================================================================
|
||||
说明:JSON 导入导出 + 预览 + 版本管理
|
||||
阶段三开始实现,当前为占位功能
|
||||
显示模板列表 + 灰化的导入/导出/新建按钮
|
||||
底部展示实现路径
|
||||
说明:JSON 导入导出 + 在线编辑 + 树形预览
|
||||
阶段三实现 - 用户已选 B 方案(双栏 CodeMirror + vue-json-pretty)
|
||||
功能:
|
||||
- 列表(从 /api/troubleshooting-templates 拉取)
|
||||
- 预览/编辑/删除(单条)
|
||||
- 导入 JSON(文件)
|
||||
- 导出全部(批量下载)
|
||||
- 新建(空模板)
|
||||
=============================================================================
|
||||
-->
|
||||
<template>
|
||||
<div class="flowcharts-page">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-title">排查流程图管理</div>
|
||||
<div class="page-desc">JSON 导入导出 + 预览 + 版本管理。阶段三开始实现,后续升级为可视化拖拽编辑。</div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">排查流程图管理</div>
|
||||
<div class="page-desc">JSON 导入导出 + 在线编辑 + 树形预览。共 {{ flowcharts.length }} 套模板</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮(灰化占位) -->
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flowchart-actions">
|
||||
<el-button type="primary" disabled>
|
||||
<el-button type="primary" @click="handleImport">
|
||||
<el-icon><Upload /></el-icon>
|
||||
导入 JSON
|
||||
</el-button>
|
||||
<el-button disabled>
|
||||
<el-button @click="handleExportAll" :disabled="flowcharts.length === 0">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出全部
|
||||
</el-button>
|
||||
<el-button disabled>
|
||||
<el-button @click="handleNew">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建流程图
|
||||
</el-button>
|
||||
<el-button @click="loadList" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 流程图模板表格 -->
|
||||
<div class="table-wrapper">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="flowcharts"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', fontSize: '12px' }"
|
||||
:cell-style="{ color: 'var(--text-primary)', fontSize: '13px' }"
|
||||
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 }">
|
||||
<div class="flowchart-name">
|
||||
<el-icon :size="16" style="color: var(--accent); margin-right: 6px">
|
||||
<Share />
|
||||
</el-icon>
|
||||
{{ row.name }}
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="80">
|
||||
<el-table-column label="分类" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.category }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="110" prop="updatedAt" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.statusType" size="small">
|
||||
{{ row.statusText }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<el-table-column label="预估时间" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.estimated_time ?? '-' }} 分钟</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center">
|
||||
<template #default>
|
||||
<el-button size="small" text type="primary" disabled>预览</el-button>
|
||||
<el-button size="small" text disabled>编辑</el-button>
|
||||
<el-table-column label="版本" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.version || 'v1.0' }}</template>
|
||||
</el-table-column>
|
||||
<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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 实现路径 -->
|
||||
<div class="roadmap-section">
|
||||
<div class="roadmap-title">
|
||||
<el-icon :size="16" style="color: var(--accent); margin-right: 6px"><Flag /></el-icon>
|
||||
实现路径
|
||||
</div>
|
||||
<div class="roadmap-steps">
|
||||
<div class="roadmap-step active">
|
||||
<div class="step-number">Step 1</div>
|
||||
<div class="step-title">JSON 导入导出 + 预览</div>
|
||||
<div class="step-phase">阶段三 3B</div>
|
||||
</div>
|
||||
<el-icon :size="16" class="roadmap-arrow"><ArrowRight /></el-icon>
|
||||
<div class="roadmap-step">
|
||||
<div class="step-number">Step 2</div>
|
||||
<div class="step-title">导出为 Dify 变量</div>
|
||||
<div class="step-phase">阶段四 4A</div>
|
||||
</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>
|
||||
<!-- 在线编辑器对话框 -->
|
||||
<FlowchartEditorDialog
|
||||
v-model="dialogVisible"
|
||||
:template="currentTemplate"
|
||||
:readonly="dialogMode === 'preview'"
|
||||
:saving="saving"
|
||||
@save="handleSave"
|
||||
@cancel="handleDialogCancel"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的文件选择器(导入 JSON) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
style="display: none"
|
||||
@change="handleFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ==========================================================================
|
||||
// Demo 数据
|
||||
// ==========================================================================
|
||||
const flowcharts = [
|
||||
{
|
||||
name: 'VPN连接故障排查',
|
||||
category: '网络',
|
||||
nodeCount: 12,
|
||||
version: 'v2.1',
|
||||
updatedAt: '2026-06-10',
|
||||
statusType: 'success',
|
||||
statusText: '已发布',
|
||||
},
|
||||
{
|
||||
name: '打印机脱机排查',
|
||||
category: '外设',
|
||||
nodeCount: 8,
|
||||
version: 'v1.3',
|
||||
updatedAt: '2026-06-08',
|
||||
statusType: 'success',
|
||||
statusText: '已发布',
|
||||
},
|
||||
{
|
||||
name: '邮箱登录失败排查',
|
||||
category: '软件',
|
||||
nodeCount: 10,
|
||||
version: 'v1.0',
|
||||
updatedAt: '2026-06-06',
|
||||
statusType: 'warning',
|
||||
statusText: '草稿',
|
||||
},
|
||||
]
|
||||
// =============================================================================
|
||||
// imports
|
||||
// =============================================================================
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Upload, Download, Plus, Refresh,
|
||||
Share, View, Edit, Delete,
|
||||
} from '@element-plus/icons-vue'
|
||||
import FlowchartEditorDialog from '@/components/flowchart/FlowchartEditorDialog.vue'
|
||||
import {
|
||||
listTemplates,
|
||||
getTemplate,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
formatJson,
|
||||
countNodes,
|
||||
type TroubleshootingTemplate,
|
||||
} from '@/api/troubleshooting'
|
||||
|
||||
// =============================================================================
|
||||
// 响应式状态
|
||||
// =============================================================================
|
||||
const flowcharts = ref<TroubleshootingTemplate[]>([])
|
||||
const loading = ref<boolean>(false)
|
||||
const saving = ref<boolean>(false)
|
||||
|
||||
const dialogVisible = ref<boolean>(false)
|
||||
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>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 流程图名称 */
|
||||
.flowchart-name {
|
||||
display: flex;
|
||||
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;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.roadmap-step.active .step-number {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.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;
|
||||
.table-wrapper {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 流程图表格行悬停 */
|
||||
.flowchart-table-row:hover td {
|
||||
background-color: var(--bg-tertiary) !important;
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ const tabs = [
|
||||
// ==========================================================================
|
||||
// 状态
|
||||
// ==========================================================================
|
||||
const activeTab = ref<'terminals' | 'leaks' | 'virus'>('terminals')
|
||||
const activeTab = ref<string>('terminals')
|
||||
const loading = ref(false)
|
||||
const connectionError = ref('')
|
||||
|
||||
@@ -675,7 +675,7 @@ function loadDemoVirusEvents(): void {
|
||||
// ==========================================================================
|
||||
// 标签页切换
|
||||
// ==========================================================================
|
||||
function switchTab(tab: 'terminals' | 'leaks' | 'virus'): void {
|
||||
function switchTab(tab: string): void {
|
||||
activeTab.value = tab
|
||||
currentPage.value = 1
|
||||
searchQuery.value = ''
|
||||
|
||||
Generated
+4
@@ -22,6 +22,10 @@
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <21.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// 包括:
|
||||
// 1. /login → 登录页(简单的用户名密码表单)
|
||||
// 2. /workspace → 坐席工作台(需要认证)
|
||||
// 3. / → 重定向到 /workspace
|
||||
// 3. /agent-preview → v0.5.4 BC/DR 应急页坐席视图(公开)
|
||||
// 4. / → 重定向到 /workspace
|
||||
// =============================================================================
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
@@ -33,6 +34,13 @@ const routes = [
|
||||
component: () => import('@/views/Workspace.vue'),
|
||||
meta: { title: '坐席工作台', requiresAuth: true },
|
||||
},
|
||||
// v0.5.4 BC/DR 应急页坐席视图
|
||||
{
|
||||
path: '/agent-preview',
|
||||
name: 'AgentPreview',
|
||||
component: () => import('@/views/AgentPreviewView.vue'),
|
||||
meta: { title: '坐席助手', requiresAuth: false },
|
||||
},
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -74,6 +82,13 @@ router.beforeEach((to, _from, next) => {
|
||||
const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证
|
||||
const token = localStorage.getItem('agent_token')
|
||||
|
||||
// v0.5.4 BC/DR 应急页(agent-preview)不需 Portal token
|
||||
// 它的鉴权由 /emergency 入口的企微 JS-SDK 完成
|
||||
if (to.name === 'AgentPreview') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresAuth && !token) {
|
||||
// 需要认证但没有 token,跳转到 Portal 统一入口
|
||||
window.location.href = '/itportal/'
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 应急页坐席视图 (v0.5.4)
|
||||
// =============================================================================
|
||||
// 说明:BC/DR 应急场景下,显示坐席端 AI 助手面板
|
||||
// 桌面端:全宽显示 AiAssistantPanel(AI推荐/快速回复/操作步骤/用户信息)
|
||||
// 移动端:顶部"右栏"按钮,点击从右侧滑出 AI 助手面板
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="agent-preview">
|
||||
<!-- ====== 顶部条 ====== -->
|
||||
<div class="agent-preview__topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="logo">🎧</span>
|
||||
<div class="title-block">
|
||||
<h1 class="title">坐席助手</h1>
|
||||
<p class="subtitle">IT 智能服务台 · 应急模式</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<!-- 移动端:右栏按钮(打开抽屉) -->
|
||||
<el-button
|
||||
v-if="isMobile"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="drawerVisible = true"
|
||||
>
|
||||
<el-icon><Menu /></el-icon>
|
||||
<span>AI 助手</span>
|
||||
</el-button>
|
||||
<!-- 桌面端:userid 标签 -->
|
||||
<div v-else class="userid-tag">
|
||||
userid: {{ userid || 'anonymous' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 桌面端:直接显示 AiAssistantPanel ====== -->
|
||||
<div v-if="!isMobile" class="agent-preview__content">
|
||||
<AiAssistantPanel />
|
||||
</div>
|
||||
|
||||
<!-- ====== 移动端:抽屉(el-drawer 从右侧滑出) ====== -->
|
||||
<el-drawer
|
||||
v-if="isMobile"
|
||||
v-model="drawerVisible"
|
||||
direction="rtl"
|
||||
size="90%"
|
||||
:with-header="false"
|
||||
>
|
||||
<div class="agent-preview__drawer-header">
|
||||
<span class="drawer-title">🤖 AI 助手</span>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="drawerVisible = false"
|
||||
>
|
||||
关闭
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="agent-preview__drawer-body">
|
||||
<AiAssistantPanel />
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ====== 移动端:底部提示 ====== -->
|
||||
<div v-if="isMobile" class="agent-preview__mobile-hint">
|
||||
<p>💡 电脑端访问可获得完整体验(AI 助手常驻右侧)</p>
|
||||
<p>移动端请点上方"AI 助手"按钮打开</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Menu } from '@element-plus/icons-vue'
|
||||
import AiAssistantPanel from '@/components/assistant/AiAssistantPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const drawerVisible = ref(false)
|
||||
const userid = computed(() => (route.query.userid as string) || '')
|
||||
|
||||
const isMobile = computed(() => window.innerWidth < 500)
|
||||
|
||||
if (userid.value) {
|
||||
ElMessage({
|
||||
message: '坐席模式',
|
||||
type: 'success',
|
||||
duration: 1500,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.agent-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: #f5f7fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
/* ====== 顶部条 ====== */
|
||||
.agent-preview__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.userid-tag {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
padding: 4px 10px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ====== 桌面端内容区 ====== */
|
||||
.agent-preview__content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* ====== 抽屉 ====== */
|
||||
.agent-preview__drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.agent-preview__drawer-body {
|
||||
height: calc(100% - 45px);
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* ====== 移动端提示 ====== */
|
||||
.agent-preview__mobile-hint {
|
||||
padding: 12px 20px;
|
||||
background: #fdf6ec;
|
||||
color: #e6a23c;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
border-top: 1px solid #faecd8;
|
||||
}
|
||||
|
||||
.agent-preview__mobile-hint p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -186,6 +186,16 @@ function onRightResizeEnd(): void {
|
||||
// ============================================================================
|
||||
|
||||
onMounted(async () => {
|
||||
// 修复 v0.5.1: 企微点坐席直接打开 /itagent/ 时,URL 没 ?token=
|
||||
// 路由守卫虽然会跳到 /itportal/,但在这之前 axios 已经发了请求 → 弹 401
|
||||
// 这里在 onMounted 第一行主动检查 token,没 token 立刻跳 portal,避免 401 弹错
|
||||
const hasAgentToken = localStorage.getItem('agent_token')
|
||||
const hasPortalToken = localStorage.getItem('portal_token')
|
||||
if (!hasAgentToken && !hasPortalToken) {
|
||||
window.location.href = '/itportal/'
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化主题
|
||||
themeStore.initTheme()
|
||||
// 初始化坐席信息
|
||||
|
||||
Vendored
+1
@@ -32,6 +32,7 @@ declare module 'vue' {
|
||||
VanEmpty: typeof import('vant/es')['Empty']
|
||||
VanField: typeof import('vant/es')['Field']
|
||||
VanIcon: typeof import('vant/es')['Icon']
|
||||
VanLoading: typeof import('vant/es')['Loading']
|
||||
VanPopup: typeof import('vant/es')['Popup']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,50 @@
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://*;" />
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台</title>
|
||||
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #f7f8fa; }
|
||||
#app-skeleton {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
|
||||
color: #969799;
|
||||
z-index: 9999;
|
||||
}
|
||||
#app-skeleton .logo {
|
||||
width: 64px; height: 64px; border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
#app-skeleton .title { font-size: 18px; font-weight: 600; color: #323233; margin-bottom: 8px; }
|
||||
#app-skeleton .subtitle { font-size: 14px; color: #969799; margin-bottom: 28px; }
|
||||
#app-skeleton .spinner {
|
||||
width: 28px; height: 28px;
|
||||
border: 3px solid #ebedf0;
|
||||
border-top-color: #1989fa;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.7; transform: scale(0.95); } }
|
||||
/* v0.5.2 强化:不再依赖 :empty 选择器(部分浏览器/Vue mount 太快会失效) */
|
||||
/* 改用 body.app-loaded 类名,由 main.ts 挂载后主动添加 */
|
||||
body.app-loaded #app-skeleton { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app"></div>
|
||||
<!-- 首屏骨架屏(JS 加载期间显示,挂载后自动隐藏) v0.5.2 -->
|
||||
<div id="app-skeleton">
|
||||
<div class="logo"></div>
|
||||
<div class="title">智能IT支持服务台</div>
|
||||
<div class="subtitle">正在加载...</div>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<!-- 入口脚本 -->
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
Generated
+4
@@ -23,6 +23,10 @@
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <21.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antfu/utils": {
|
||||
|
||||
+37
-2
@@ -8,23 +8,58 @@
|
||||
<template>
|
||||
<!-- Vant4 主题配置:根据 themeStore 切换浅色/深色 -->
|
||||
<van-config-provider :theme="themeStore.currentTheme">
|
||||
<router-view />
|
||||
<!-- v0.5.2 优化:用 v-if 控制路由视图,未挂载时显示 loading 占位 -->
|
||||
<router-view v-if="appReady" v-slot="{ Component }">
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<div class="app-loading">
|
||||
<van-loading type="spinner" color="#1989fa" size="36" />
|
||||
<div class="app-loading__text">正在加载...</div>
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</router-view>
|
||||
<!-- 首屏 fallback,Vue 还没 mount 完成时显示 -->
|
||||
<div v-else class="app-loading">
|
||||
<van-loading type="spinner" color="#1989fa" size="36" />
|
||||
<div class="app-loading__text">正在加载...</div>
|
||||
</div>
|
||||
</van-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
/** 主题 Store */
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/** v0.5.2 优化:appReady 控制路由视图是否渲染,避免空白闪烁 */
|
||||
const appReady = ref(false)
|
||||
|
||||
// 应用挂载时初始化主题(从 localStorage 读取偏好并应用)
|
||||
onMounted(() => {
|
||||
themeStore.initTheme()
|
||||
// 标记 app 已就绪,触发 router-view 渲染
|
||||
appReady.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* v0.5.2 新增:全局 loading 样式 */
|
||||
.app-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
.app-loading__text {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: #969799;
|
||||
}
|
||||
/* 根组件样式已在 global.css 中定义 */
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="call-modal__step">
|
||||
<div class="call-modal__header">
|
||||
<span class="call-modal__icon">🔔</span>
|
||||
<h3>摇传菜铃呼叫人工坐席...</h3>
|
||||
<h3>呼叫人工坐席帮助...</h3>
|
||||
</div>
|
||||
<div class="call-modal__body call-modal__body--center">
|
||||
|
||||
|
||||
+17
-1
@@ -34,10 +34,26 @@ app.use(router)
|
||||
// 不需要在这里手动注册,减小打包体积
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 挂载应用到 DOM
|
||||
// v0.5.2:挂载应用 + 显式关闭骨架屏(避免 :empty 选择器失效)
|
||||
// --------------------------------------------------------------------------
|
||||
// 1. 记录挂载开始时间(用于最小显示时间)
|
||||
const mountStart = Date.now()
|
||||
// 2. 最小显示时间 500ms(防止 Vue 太快挂载导致骨架屏"闪一下看不见")
|
||||
const MIN_SKELETON_DISPLAY_MS = 500
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 3. 挂载完成后,主动给 body 加 .app-loaded 类名,触发 CSS 隐藏骨架屏
|
||||
// 比之前用 :empty 选择器更可靠(尤其在 Vue mount < 100ms 的情况下)
|
||||
const elapsed = Date.now() - mountStart
|
||||
if (elapsed >= MIN_SKELETON_DISPLAY_MS) {
|
||||
document.body.classList.add('app-loaded')
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
document.body.classList.add('app-loaded')
|
||||
}, MIN_SKELETON_DISPLAY_MS - elapsed)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 企微 OAuth2 授权检查(已迁移至路由守卫 router/index.ts)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// v0.5.2 优化:ChatView 是 99% 用户唯一访问的页面,改用静态 import
|
||||
// 之前用 () => import() 懒加载,首次访问要二次下载 301KB 的 ChatView chunk
|
||||
// → 表现为白屏→突然全显示
|
||||
import ChatView from '@/views/ChatView.vue'
|
||||
// v0.5.4 BC/DR 应急页(身份检测 + H5 右栏)
|
||||
import EmergencyDispatcher from '@/views/EmergencyDispatcher.vue'
|
||||
import H5PreviewView from '@/views/H5PreviewView.vue'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 企微环境检测工具函数
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -33,8 +41,8 @@ const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'ChatView',
|
||||
// 懒加载:首次访问时才加载组件,减小首屏体积
|
||||
component: () => import('@/views/ChatView.vue'),
|
||||
// v0.5.2:首页静态引入,避免 301KB chunk 二次下载导致白屏
|
||||
component: ChatView,
|
||||
meta: { title: 'IT智能服务台', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
@@ -49,6 +57,19 @@ const routes = [
|
||||
component: () => import('@/views/WeworkOnly.vue'),
|
||||
meta: { title: '请在企业微信中打开', requiresAuth: false },
|
||||
},
|
||||
// v0.5.4 BC/DR 应急页(身份检测 + 员工右栏视图)
|
||||
{
|
||||
path: '/emergency',
|
||||
name: 'EmergencyDispatcher',
|
||||
component: EmergencyDispatcher,
|
||||
meta: { title: '应急身份检测', requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/h5-preview',
|
||||
name: 'H5Preview',
|
||||
component: H5PreviewView,
|
||||
meta: { title: '员工自助', requiresAuth: false },
|
||||
},
|
||||
// 404 兜底:未匹配的路径重定向到首页
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -67,8 +88,14 @@ const router = createRouter({
|
||||
// 路由守卫 — 企微环境检测 + 认证检查
|
||||
// --------------------------------------------------------------------------
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// WeworkOnly 页面和 Login 页面不需要企微检测
|
||||
if (to.name === 'WeworkOnly' || to.name === 'Login') {
|
||||
// v0.5.4 应急页(身份检测 + 预览页)不需要企微 OAuth2 认证
|
||||
// 由 EmergencyDispatcher 自己调企微 JS-SDK 检测角色
|
||||
if (
|
||||
to.name === 'WeworkOnly' ||
|
||||
to.name === 'Login' ||
|
||||
to.name === 'EmergencyDispatcher' ||
|
||||
to.name === 'H5Preview'
|
||||
) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 应急页身份检测 dispatcher (v0.5.4)
|
||||
// =============================================================================
|
||||
// 说明:BC/DR 应急场景的统一入口
|
||||
// 流程:
|
||||
// 1. 加载企微 JS-SDK
|
||||
// 2. 调后端 /api/wecom/jsapi-config 拿签名
|
||||
// 3. wx.config() + wx.agentConfig() 鉴权
|
||||
// 4. 拿当前 userid
|
||||
// 5. 调 /api/wecom/check-role 判断角色
|
||||
// 6. 坐席 → /itagent/agent-preview,员工 → /h5-preview
|
||||
// 错误兜底:默认按"员工"处理,跳转 /h5-preview
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="emergency-dispatcher">
|
||||
<div class="emergency-dispatcher__panel">
|
||||
<div class="logo"></div>
|
||||
<h2 class="title">IT 智能服务台</h2>
|
||||
<p class="subtitle">{{ statusText }}</p>
|
||||
<van-loading v-if="loading" type="spinner" color="#1989fa" size="32" />
|
||||
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showLoadingToast, showFailToast } from 'vant'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMsg = ref('')
|
||||
const statusText = ref('正在检测身份...')
|
||||
|
||||
const isMobile = computed(() => window.innerWidth < 500)
|
||||
|
||||
/**
|
||||
* 加载企微 JS-SDK
|
||||
* 注意:企微 JS-SDK 文件名是 jweixin-1.2.0.js(历史遗留,虽然叫 jweixin)
|
||||
*/
|
||||
function loadWeworkSDK(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 已经加载过
|
||||
if (typeof (window as any).wx !== 'undefined' && (window as any).wx.agentConfig) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://res.wx.qq.com/wwopen/js/wwLogin-1.2.7.js'
|
||||
script.onload = () => {
|
||||
// 加载企微 agent SDK
|
||||
const agentScript = document.createElement('script')
|
||||
agentScript.src = 'https://res.wx.qq.com/open/js/jweixin-1.2.0.js'
|
||||
agentScript.onload = () => resolve()
|
||||
agentScript.onerror = () => reject(new Error('加载企微 agent SDK 失败'))
|
||||
document.head.appendChild(agentScript)
|
||||
}
|
||||
script.onerror = () => reject(new Error('加载企微 JS-SDK 失败'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 拿后端签名
|
||||
*/
|
||||
async function getJsapiConfig(url: string) {
|
||||
const resp = await axios.get('/api/wecom/jsapi-config', { params: { url } })
|
||||
if (resp.data.code !== 0) {
|
||||
throw new Error(`拿签名失败: ${resp.data.message}`)
|
||||
}
|
||||
return resp.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* wx.config 初始化
|
||||
*/
|
||||
function wxConfig(config: any): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wx = (window as any).wx
|
||||
wx.config({
|
||||
beta: true, // 开启内测接口
|
||||
debug: false,
|
||||
appId: config.corp_id,
|
||||
timestamp: config.timestamp,
|
||||
nonceStr: config.nonce_str,
|
||||
signature: config.signature,
|
||||
jsApiList: ['agentConfig'],
|
||||
})
|
||||
wx.ready(() => resolve())
|
||||
wx.error((err: any) => reject(new Error(`wx.config 失败: ${JSON.stringify(err)}`)))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* wx.agentConfig 拿身份
|
||||
*/
|
||||
function wxAgentConfig(config: any): Promise<{ userId: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wx = (window as any).wx
|
||||
wx.agentConfig({
|
||||
corpid: config.corp_id,
|
||||
agentid: config.agent_id,
|
||||
timestamp: config.timestamp,
|
||||
nonceStr: config.nonce_str,
|
||||
signature: config.signature,
|
||||
jsApiList: ['selectExternalContact'],
|
||||
success: (res: any) => {
|
||||
// 拿当前 userid(实际场景可能要从 selectExternalContact 等接口拿)
|
||||
// 这里我们直接通过 URL 参数或后端回查
|
||||
// 简化版:从后端 cookie / 之前登录态拿
|
||||
const userId = (window as any).wecom_userid || ''
|
||||
resolve({ userId })
|
||||
},
|
||||
fail: (err: any) => reject(new Error(`wx.agentConfig 失败: ${JSON.stringify(err)}`)),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 调后端检查角色
|
||||
*/
|
||||
async function checkRole(userid: string) {
|
||||
const resp = await axios.get('/api/wecom/check-role', { params: { userid } })
|
||||
if (resp.data.code !== 0) {
|
||||
throw new Error(`检查角色失败: ${resp.data.message}`)
|
||||
}
|
||||
return resp.data.data
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
statusText.value = '正在加载企微 SDK...'
|
||||
await loadWeworkSDK()
|
||||
|
||||
statusText.value = '正在获取鉴权签名...'
|
||||
const currentUrl = window.location.href.split('#')[0]
|
||||
const config = await getJsapiConfig(currentUrl)
|
||||
|
||||
statusText.value = '正在初始化企微...'
|
||||
await wxConfig(config)
|
||||
|
||||
statusText.value = '正在获取身份...'
|
||||
const { userId } = await wxAgentConfig(config)
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('未能获取当前用户 userid')
|
||||
}
|
||||
|
||||
statusText.value = '正在检查角色...'
|
||||
const roleInfo = await checkRole(userId)
|
||||
console.log('[EmergencyDispatcher] 角色检测:', roleInfo)
|
||||
|
||||
// 跳转
|
||||
statusText.value = `角色: ${roleInfo.role},正在跳转...`
|
||||
if (roleInfo.role === 'agent') {
|
||||
window.location.href = '/itagent/agent-preview?userid=' + encodeURIComponent(userId)
|
||||
} else {
|
||||
router.push({ path: '/h5-preview', query: { userid: userId } })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[EmergencyDispatcher] 错误:', err)
|
||||
errorMsg.value = err.message || '未知错误'
|
||||
loading.value = false
|
||||
showFailToast({ message: errorMsg.value, duration: 5000 })
|
||||
|
||||
// 兜底:3 秒后跳员工页
|
||||
setTimeout(() => {
|
||||
router.push({ path: '/h5-preview' })
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.emergency-dispatcher {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, #f7f8fa 0%, #e7e9ed 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
.emergency-dispatcher__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #646566;
|
||||
margin: 0 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: #fff7e8;
|
||||
color: #ff976a;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 应急页员工视图 (v0.5.4)
|
||||
// =============================================================================
|
||||
// 说明:BC/DR 应急场景下,显示 H5 用户端右栏内容
|
||||
// 桌面端:全宽显示 RightPanel(三段式:AI推荐/常用资源/趣味问答)
|
||||
// 移动端:顶部"菜单"按钮,点击从顶部滑出右栏内容(抽屉式)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="h5-preview">
|
||||
<!-- ====== 顶部条(移动端 + 桌面端都有) ====== -->
|
||||
<div class="h5-preview__topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="logo">🤖</span>
|
||||
<div class="title-block">
|
||||
<h1 class="title">员工自助</h1>
|
||||
<p class="subtitle">IT 智能服务台 · 应急模式</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端:菜单按钮(打开抽屉) -->
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="topbar-menu-btn"
|
||||
@click="drawerVisible = true"
|
||||
>
|
||||
<van-icon name="apps-o" size="22" />
|
||||
<span>右栏</span>
|
||||
</button>
|
||||
<!-- 桌面端:显示 userid(供验证) -->
|
||||
<div v-else class="userid-tag">
|
||||
userid: {{ userid || 'anonymous' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 桌面端:直接显示 RightPanel ====== -->
|
||||
<div v-if="!isMobile" class="h5-preview__content">
|
||||
<RightPanel />
|
||||
</div>
|
||||
|
||||
<!-- ====== 移动端:抽屉(Vant Popup 从顶部弹出) ====== -->
|
||||
<van-popup
|
||||
v-if="isMobile"
|
||||
v-model:show="drawerVisible"
|
||||
position="top"
|
||||
:style="{ height: '85%' }"
|
||||
:close-on-click-overlay="true"
|
||||
closeable
|
||||
safe-area-inset-top
|
||||
>
|
||||
<div class="h5-preview__drawer-header">
|
||||
<span class="drawer-title">📋 右栏内容</span>
|
||||
<van-icon
|
||||
name="cross"
|
||||
size="20"
|
||||
class="drawer-close"
|
||||
@click="drawerVisible = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="h5-preview__drawer-body">
|
||||
<RightPanel />
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- ====== 移动端:底部提示卡片 ====== -->
|
||||
<div v-if="isMobile" class="h5-preview__mobile-hint">
|
||||
<p>💡 电脑端访问可获得完整体验(右栏常驻显示)</p>
|
||||
<p>移动端请点上方"右栏"按钮打开内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import RightPanel from '@/components/assistant/RightPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const drawerVisible = ref(false)
|
||||
const userid = computed(() => (route.query.userid as string) || '')
|
||||
|
||||
const isMobile = computed(() => window.innerWidth < 500)
|
||||
|
||||
// 首次加载提示
|
||||
if (userid.value) {
|
||||
showToast({ message: '员工模式', duration: 1500 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.h5-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: #f7f8fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
/* ====== 顶部条 ====== */
|
||||
.h5-preview__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.userid-tag {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
padding: 4px 10px;
|
||||
background: #f7f8fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 菜单按钮(移动端) */
|
||||
.topbar-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #1c64f2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.topbar-menu-btn:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ====== 桌面端内容区 ====== */
|
||||
.h5-preview__content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ====== 抽屉 ====== */
|
||||
.h5-preview__drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
color: #969799;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.h5-preview__drawer-body {
|
||||
height: calc(100% - 50px);
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* ====== 移动端提示 ====== */
|
||||
.h5-preview__mobile-hint {
|
||||
padding: 12px 20px;
|
||||
background: #fffbe8;
|
||||
color: #ff976a;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
border-top: 1px solid #ffe9b3;
|
||||
}
|
||||
|
||||
.h5-preview__mobile-hint p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Generated
+4
@@ -20,6 +20,10 @@
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <21.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePortalStore } from '@/stores/portal'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Loading, CircleCloseFilled, User, Headset, Setting, InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# NAS full /volume1/ scan with sudo (English-only)
|
||||
# Step 1: User runs `sudo -v` first (password stays local, never enters Claude)
|
||||
# Step 2: This script reuses that 15-min sudo session
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$outputFile = "$PSScriptRoot\nas_volumes.txt"
|
||||
|
||||
chcp 65001 | Out-Null
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host " NAS Full Scan (with sudo)" -ForegroundColor Cyan
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "PREREQUISITE: open another terminal and run:" -ForegroundColor Yellow
|
||||
Write-Host " ssh simon@100.85.152.112" -ForegroundColor White
|
||||
Write-Host " sudo -v <- enter simon's password here, password NOT sent to Claude" -ForegroundColor White
|
||||
Write-Host " (keep that SSH session open for 15 min, sudo session cached)" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter after you have done sudo -v above"
|
||||
|
||||
# Force allocation so sudo can read password from terminal if needed
|
||||
$cmd = @"
|
||||
sudo bash <<'NAS_EOF'
|
||||
echo '===== [1] All top-level entries under /volume1/ ====='
|
||||
ls -la /volume1/ 2>&1
|
||||
echo ''
|
||||
echo '===== [2] Direct children sizes (1-3 minutes) ====='
|
||||
du -sh /volume1/*/ 2>/dev/null | sort -rh
|
||||
echo ''
|
||||
echo '===== [3] Disk space ====='
|
||||
df -h /volume1 2>&1 | head -3
|
||||
echo ''
|
||||
echo '===== [4] /volume1/homes/ ====='
|
||||
ls -la /volume1/homes/ 2>&1 | head -20
|
||||
echo ''
|
||||
echo '===== [5] /volume1/homes/simon/ top dirs by size ====='
|
||||
du -sh /volume1/homes/simon/*/ 2>/dev/null | sort -rh | head -20
|
||||
echo ''
|
||||
echo '===== [6] /volume1/docker/ top dirs by size (likely big) ====='
|
||||
du -sh /volume1/docker/*/ 2>/dev/null | sort -rh | head -20
|
||||
echo ''
|
||||
echo '===== [7] Largest top-level dirs (top 15) ====='
|
||||
du -sh /volume1/* 2>/dev/null | sort -rh | head -15
|
||||
echo ''
|
||||
echo '===== [8] Mounts / storage pools ====='
|
||||
mount | grep -E 'volume|tank' 2>&1 | head -10
|
||||
echo ''
|
||||
echo '===== DONE ====='
|
||||
NAS_EOF
|
||||
"@
|
||||
|
||||
ssh -t simon@100.85.152.112 "$cmd" 2>&1 | Tee-Object -FilePath $outputFile -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host " Done. Output saved to:" -ForegroundColor Green
|
||||
Write-Host " $outputFile" -ForegroundColor White
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Please paste the ENTIRE contents of nas_volumes.txt back" -ForegroundColor Yellow
|
||||
Write-Host "(or just tell me which top-level dir is largest)" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter to close"
|
||||
@@ -0,0 +1,43 @@
|
||||
# NAS /volume1/ directory listing scan script
|
||||
# Double-click or run in PowerShell, lists all top-level dirs with sizes
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$outputFile = "$PSScriptRoot\nas_volumes.txt"
|
||||
|
||||
# Force UTF-8 console encoding for SSH output
|
||||
chcp 65001 | Out-Null
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host " NAS /volume1/ Directory Scan" -ForegroundColor Cyan
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Scanning... du on large dirs may take 1-3 minutes" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$cmd = @"
|
||||
echo '===== Top-level dirs in /volume1/ ====='
|
||||
ls -la /volume1/ 2>&1 | grep -v '^total'
|
||||
echo ''
|
||||
echo '===== Size by dir (largest first, may take minutes) ====='
|
||||
du -sh /volume1/*/ 2>/dev/null | sort -rh
|
||||
echo ''
|
||||
echo '===== /volume1/homes/ ====='
|
||||
ls -la /volume1/homes/ 2>/dev/null | head -20
|
||||
echo ''
|
||||
echo '===== /volume1/homes/simon/ content ====='
|
||||
ls -la /volume1/homes/simon/ 2>/dev/null | head -30
|
||||
du -sh /volume1/homes/simon/*/ 2>/dev/null | sort -rh | head -20
|
||||
echo ''
|
||||
echo '===== DONE ====='
|
||||
"@
|
||||
|
||||
ssh simon@100.85.152.112 $cmd 2>&1 | Tee-Object -FilePath $outputFile -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host " Done. Output saved to:" -ForegroundColor Green
|
||||
Write-Host " $outputFile" -ForegroundColor White
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter to close"
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
hostkeys_find_by_key_hostfile: hostkeys_foreach failed for C:\\Users\\simon/.ssh/known_hosts: Permission denied
|
||||
Failed to add the host to the list of known hosts (C:\\Users\\simon/.ssh/known_hosts).
|
||||
client_input_hostkeys: hostkeys_foreach failed for C:\\Users\\simon/.ssh/known_hosts: Permission denied
|
||||
Password:
|
||||
sudo: timed out reading password
|
||||
|
||||
sudo: a password is required
|
||||
|
||||
Connection to 100.85.152.112 closed.
|
||||
@@ -0,0 +1,7 @@
|
||||
fp='/opt/wecom-it-desk/nginx/nginx.conf'
|
||||
c=open(fp).read()
|
||||
p='\n # 真实 IP 还原(2026-06-15 v0.5.1)\n set_real_ip_from 10.0.0.0/8;\n set_real_ip_from 172.16.0.0/12;\n set_real_ip_from 192.168.0.0/16;\n set_real_ip_from 10.212.0.0/16;\n real_ip_header X-Forwarded-For;\n real_ip_recursive on;\n'
|
||||
o='error_log /var/log/nginx/error.log warn;'
|
||||
n=c.replace(o,o+p,1)
|
||||
open(fp,'w').write(n)
|
||||
print('patched, +%d bytes'%(len(n)-len(c)))
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
修复 docker-compose.yml 中 REDIS_URL 默认值的 URL-encode 问题。
|
||||
|
||||
背景:
|
||||
- 2026-06-15 故障:REDIS_URL 里的密码 R3d!s@2026#Secure 含 @ # 两个 URL 保留字符,
|
||||
Python redis 库解析时密码被截断成 R3d!s,导致鉴权失败 → Redis 连接超时
|
||||
- 修复:把密码 URL-encode(@→%40, #→%23, !→%21)
|
||||
- ⚠️ 关键: 只 URL-encode REDIS_URL 那行的密码,redis-server --requirepass
|
||||
和 healthcheck 的 redis-cli -a 都必须保持**明文**(否则 Redis 容器启动失败/鉴权失败)
|
||||
|
||||
用法:
|
||||
sudo python3 /tmp/patch-redis-url.py
|
||||
"""
|
||||
fp = '/opt/wecom-it-desk/docker-compose.yml'
|
||||
|
||||
# 读取当前内容
|
||||
with open(fp, encoding='utf-8') as f:
|
||||
c = f.read()
|
||||
|
||||
# 旧值(精确匹配 REDIS_URL 那一行,带 redis://://@ 上下文,避免误改 --requirepass)
|
||||
old = 'REDIS_URL=redis://:${REDIS_PASSWORD:-R3d!s@2026#Secure}@redis:6379/0'
|
||||
|
||||
# 新值:URL-encode 后的密码(!→%21, @→%40, #→%23),仅 REDIS_URL 这一行
|
||||
new = 'REDIS_URL=redis://:${REDIS_PASSWORD:-R3d%21s%402026%23Secure}@redis:6379/0'
|
||||
|
||||
# 检查是否已经修复过(幂等性)
|
||||
if old in c:
|
||||
print('[OK] 检测到未编码版本,准备修复...')
|
||||
c2 = c.replace(old, new, 1) # 只替换第一次出现(更安全)
|
||||
with open(fp, 'w', encoding='utf-8') as f:
|
||||
f.write(c2)
|
||||
delta = len(c2) - len(c)
|
||||
print('[OK] 已修复:REDIS_URL 行的密码已 URL-encode')
|
||||
print(f'[OK] 文件长度变化:{delta:+d} 字节')
|
||||
elif new in c:
|
||||
print('[OK] 已经修复过,跳过(幂等性 OK)')
|
||||
else:
|
||||
print('[ERROR] 既没找到旧值也没找到新值,请人工检查 docker-compose.yml')
|
||||
print('---')
|
||||
print('当前 REDIS_URL 相关配置:')
|
||||
import subprocess
|
||||
result = subprocess.run(['grep', '-n', 'REDIS_URL\\|REDIS_PASSWORD\\|--requirepass\\|redis-cli', fp],
|
||||
capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
exit(1)
|
||||
|
||||
# 验证:确保 --requirepass 和 redis-cli 仍然是明文(没被误改)
|
||||
import subprocess
|
||||
result = subprocess.run(['grep', '-nE', 'REDIS_URL|--requirepass|redis-cli.*-a', fp],
|
||||
capture_output=True, text=True)
|
||||
print('---')
|
||||
print('当前所有密码相关行(应只有 REDIS_URL 一行是 URL-encoded,其他保持明文):')
|
||||
print(result.stdout)
|
||||
@@ -0,0 +1,20 @@
|
||||
fp = '/opt/wecom-it-desk/nginx/nginx.conf'
|
||||
with open(fp) as f:
|
||||
c = f.read()
|
||||
patch = '''
|
||||
# ------------------------------------------------------------------
|
||||
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
|
||||
# ------------------------------------------------------------------
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
set_real_ip_from 10.212.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
'''
|
||||
old = 'error_log /var/log/nginx/error.log warn;'
|
||||
new = old + patch
|
||||
new_c = c.replace(old, new, 1)
|
||||
with open(fp, 'w') as f:
|
||||
f.write(new_c)
|
||||
print('patched, +{} bytes'.format(len(new_c) - len(c)))
|
||||
@@ -0,0 +1,70 @@
|
||||
# NAS probe script (English-only, prevents PowerShell 5.1 GBK encoding issue)
|
||||
# Output saved to nas_probe_output.txt
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$outputFile = "$PSScriptRoot\nas_probe_output.txt"
|
||||
|
||||
chcp 65001 | Out-Null
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host " NAS Probe Script" -ForegroundColor Cyan
|
||||
Write-Host "===================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Connecting via Tailscale: simon@100.85.152.112" -ForegroundColor Yellow
|
||||
Write-Host "Read-only probe, output saved to:" -ForegroundColor Yellow
|
||||
Write-Host " $outputFile" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "SSH will prompt for the simon user password..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
$cmd = @"
|
||||
echo '===== [1] DSM Version ====='
|
||||
cat /etc.defaults/VERSION 2>/dev/null | head -10
|
||||
uname -a
|
||||
echo ''
|
||||
echo '===== [2] Docker availability ====='
|
||||
which docker && docker --version
|
||||
ls /var/packages/ContainerManager/target/usr/bin/docker 2>/dev/null
|
||||
/var/packages/ContainerManager/target/usr/bin/docker --version 2>&1
|
||||
echo ''
|
||||
echo '===== [3] All containers (running + stopped) ====='
|
||||
/var/packages/ContainerManager/target/usr/bin/docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>&1 | head -40
|
||||
echo ''
|
||||
echo '===== [4] /volume1/docker structure ====='
|
||||
ls -la /volume1/docker/ 2>&1 | head -40
|
||||
echo '--- sub-dir sizes ---'
|
||||
du -sh /volume1/docker/*/ 2>/dev/null | head -30
|
||||
echo ''
|
||||
echo '===== [5] Listening ports (22/80/443/3000/3022/18080) ====='
|
||||
ss -tln 2>&1 | head -30
|
||||
echo ''
|
||||
echo '===== [6] Tailscale ====='
|
||||
ls /var/packages/Tailscale/target/bin/ 2>/dev/null
|
||||
/var/packages/Tailscale/target/bin/tailscale status 2>/dev/null | head -10
|
||||
echo ''
|
||||
echo '===== [7] Existing Gitea ====='
|
||||
/var/packages/ContainerManager/target/usr/bin/docker ps -a | grep -i gitea
|
||||
ls -la /volume1/docker/gitea 2>&1 | head -10
|
||||
echo ''
|
||||
echo '===== [8] Disk space ====='
|
||||
df -h /volume1 2>&1 | head -3
|
||||
echo ''
|
||||
echo '===== [9] User and permissions ====='
|
||||
id
|
||||
echo ''
|
||||
echo '===== [10] Installed packages ====='
|
||||
ls /var/packages/ 2>/dev/null | grep -iE 'docker|container|tail|portain'
|
||||
echo ''
|
||||
echo '===== DONE ====='
|
||||
"@
|
||||
|
||||
ssh simon@100.85.152.112 $cmd 2>&1 | Tee-Object -FilePath $outputFile -Encoding UTF8
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host " Done. Output saved to:" -ForegroundColor Green
|
||||
Write-Host " $outputFile" -ForegroundColor White
|
||||
Write-Host "===================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Read-Host "Press Enter to close"
|
||||
+6
-6
@@ -2,7 +2,7 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 一键构建 & 部署脚本(共享域名版)
|
||||
# =============================================================================
|
||||
# 说明:与 IT 数据查询平台共享域名 it-dataquery.dc.servyou-it.com
|
||||
# 说明:备案域名 itsupport.servyou.com.cn(2026-06-15 切换),原 it-dataquery.dc.servyou-it.com 已停用
|
||||
# 路由:
|
||||
# / → IT 数据查询平台
|
||||
# /itdesk/ → H5 员工咨询端
|
||||
@@ -203,7 +203,7 @@ pack_deploy() {
|
||||
main() {
|
||||
echo "========================================="
|
||||
echo " 企微IT智能服务台 — 部署工具"
|
||||
echo " 共享域名: it-dataquery.dc.servyou-it.com"
|
||||
echo " 备案域名: https://itsupport.servyou.com.cn"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
@@ -238,10 +238,10 @@ main() {
|
||||
ok "部署完成!"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo " H5 员工端:http://it-dataquery.dc.servyou-it.com/itdesk/"
|
||||
echo " 坐席工作台:http://it-dataquery.dc.servyou-it.com/itagent/"
|
||||
echo " API 文档: http://it-dataquery.dc.servyou-it.com/api/docs"
|
||||
echo " 数据平台: http://it-dataquery.dc.servyou-it.com/"
|
||||
echo " H5 员工端:https://itsupport.servyou.com.cn/itdesk/"
|
||||
echo " 坐席工作台:https://itsupport.servyou.com.cn/itagent/"
|
||||
echo " API 文档: https://itsupport.servyou.com.cn/api/docs"
|
||||
echo " 备案域名: https://itsupport.servyou.com.cn/"
|
||||
echo ""
|
||||
echo " 本地测试: http://localhost:18080/itdesk/"
|
||||
echo " 查看日志:docker compose logs -f"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# /itdesk/ 500 错误诊断脚本
|
||||
# 在生产服务器 10.90.5.110 上跑(PuTTY 登录后):
|
||||
# cd /opt/wecom-it-desk
|
||||
# bash diagnose-500.sh > /tmp/diag.log 2>&1
|
||||
# cat /tmp/diag.log
|
||||
# =============================================================================
|
||||
|
||||
echo "========== 1. 容器状态 =========="
|
||||
docker compose ps
|
||||
|
||||
echo ""
|
||||
echo "========== 2. /opt/wecom-it-desk 目录结构 =========="
|
||||
ls -la /opt/wecom-it-desk/ 2>&1 | head -20
|
||||
echo "--- frontend-h5/dist ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1 | head -10
|
||||
echo "--- frontend-h5/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-agent/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-agent/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-portal/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-portal/dist/assets/ 2>&1 | head -10
|
||||
echo "--- frontend-admin/dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-admin/dist/assets/ 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 3. nginx 容器内文件检查 =========="
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/ 2>&1 | head -20
|
||||
echo "--- /usr/share/nginx/html/itdesk ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1 | head -10
|
||||
echo "--- /usr/share/nginx/html/itdesk/assets ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/assets/ 2>&1 | head -10
|
||||
echo "--- /usr/share/nginx/ssl/ ---"
|
||||
docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 4. nginx 配置实际生效版本(头部 50 行)=========="
|
||||
docker compose exec nginx cat /etc/nginx/nginx.conf 2>&1 | head -50
|
||||
|
||||
echo ""
|
||||
echo "========== 5. nginx 容器端口监听 =========="
|
||||
docker compose exec nginx netstat -tlnp 2>&1 | head -10
|
||||
echo "(没 netstat 用 ss:)"
|
||||
docker compose exec nginx ss -tlnp 2>&1 | head -10
|
||||
|
||||
echo ""
|
||||
echo "========== 6. 直接 curl 测试各路径 =========="
|
||||
echo "--- /itdesk/ (容器内) ---"
|
||||
docker compose exec nginx curl -ksI https://localhost/itdesk/ 2>&1 | head -20
|
||||
echo "--- /itdesk/ (容器外主机 443) ---"
|
||||
curl -ksI https://localhost:443/itdesk/ 2>&1 | head -20
|
||||
echo "--- /itportal/ ---"
|
||||
curl -ksI https://localhost:443/itportal/ 2>&1 | head -20
|
||||
echo "--- /itdesk/assets/ (探 404) ---"
|
||||
curl -ksI https://localhost:443/itdesk/assets/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "========== 7. 主机实际 URL 域名 =========="
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -20
|
||||
echo "---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -20
|
||||
|
||||
echo ""
|
||||
echo "========== 8. nginx access log 最近 30 行(找 500 请求)=========="
|
||||
docker compose exec nginx tail -30 /var/log/nginx/access.log 2>&1
|
||||
echo ""
|
||||
echo "========== 9. nginx error log 最近 30 行 =========="
|
||||
docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1
|
||||
|
||||
echo ""
|
||||
echo "========== 10. backend 容器健康 =========="
|
||||
docker compose ps backend
|
||||
echo "--- backend health endpoint ---"
|
||||
docker compose exec backend curl -ks http://localhost:8000/api/health 2>&1 | head -5
|
||||
|
||||
echo ""
|
||||
echo "========== 11. 看一下后端访问 /api/h5/me (H5 启动时会调)=========="
|
||||
echo "--- /api/h5/me 无 token ---"
|
||||
curl -ks -i -X GET https://itsupport.servyou.com.cn/api/h5/me 2>&1 | head -10
|
||||
@@ -0,0 +1,362 @@
|
||||
# nginx 真实 IP 还原 — 生产部署(小白友好版)
|
||||
|
||||
> 术语速查:**nginx** = 你这台服务器的"门卫",负责把用户请求分发给后端 / 把静态文件返回给浏览器
|
||||
> **配置** = nginx 的工作规则,改配置 = 改门卫的工作方式
|
||||
|
||||
---
|
||||
|
||||
## 我们要做啥(整体目标)
|
||||
|
||||
**一句话目标**:`https://itsupport.servyou.com.cn/itadmin/` 之前返回 403(被门卫拦了),原因是门卫把"代理服务器 IP"当成了"用户 IP",而代理 IP 不在白名单里。这次我们改门卫的规则,让它从请求头里读"真实用户 IP"。
|
||||
|
||||
**一共 7 个动作**:
|
||||
|
||||
| # | 动作 | 大概多久 | 风险 |
|
||||
|---|---|---|---|
|
||||
| 1 | PuTTY 连上服务器 | 1 分钟 | ⚪ 无风险 |
|
||||
| 2 | 备份当前配置 | 几秒 | 🟢 备份原文件,可还原 |
|
||||
| 3 | 写入 13 行新规则 | 几秒 | 🟡 改配置,但有备份 |
|
||||
| 4 | 确认写入正确 | 几秒 | ⚪ 只读不写 |
|
||||
| 5 | 检查配置语法 | 几秒 | ⚪ 只读不写 |
|
||||
| 6 | 让 nginx 重新读规则 | 1 秒 | 🟡 短暂重载,服务不中断 |
|
||||
| 7 | 浏览器看效果 | 几秒 | ⚪ 只读 |
|
||||
|
||||
**总耗时**:第一次大概 5-10 分钟;熟练了 2 分钟
|
||||
|
||||
**整体风险**:🟢 **低** — 每一步都给了"回滚"按钮,改坏了随时能恢复
|
||||
|
||||
---
|
||||
|
||||
## PuTTY 是啥?在哪儿打开?
|
||||
|
||||
**PuTTY** = 一个 SSH 客户端软件,作用是让你从你的 Windows 电脑远程连到公司的 Linux 服务器
|
||||
|
||||
**打开方式**:
|
||||
- 按 `Win 键` → 输入 `putty` → 回车
|
||||
- 或者开始菜单 → 找到 PuTTY 图标
|
||||
|
||||
打开后会看到一个灰底配置界面,我们要填 4 项:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Host Name (or IP address) │ ← 填: 10.212.189.210
|
||||
│ Port │ ← 填: 2222
|
||||
│ Connection type │ ← 选: SSH(默认就是)
|
||||
│ Saved Sessions │ ← 填: wecom-bastion(起个名)
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
点 Save 保存 → 点 Open 开始连接
|
||||
```
|
||||
|
||||
连接后会黑底白字,提示 `login as:` → 输入 `sxn` 回车 → 提示 `password:` → 输入你的堡垒机密码(输入时屏幕不显示,正常,输完回车就行)
|
||||
|
||||
**注意**:输错密码不会锁账号,直接重新输
|
||||
|
||||
---
|
||||
|
||||
## 动作 1:PuTTY 连服务器(⚪ 无风险)
|
||||
|
||||
> **为啥要连服务器?**:改配置必须在服务器上操作,你 Windows 这边只是"遥控器"
|
||||
|
||||
连上堡垒机后,黑底白字会显示一个类似 `sxn@jump-host:~$` 的提示符,说明你已经到堡垒机了。
|
||||
|
||||
**决策树**:
|
||||
```
|
||||
你现在看到了堡垒机提示符(类似 sxn@jump-host:~$)
|
||||
├─ 是 → 在 PuTTY 里继续输入下面命令
|
||||
└─ 否 → 截图发给我,卡哪儿了
|
||||
```
|
||||
|
||||
贴下面的命令(右键 = 粘贴,Enter = 执行):
|
||||
|
||||
```bash
|
||||
# 从堡垒机跳到真正的生产服务器
|
||||
ssh sxn@10.90.5.110
|
||||
```
|
||||
|
||||
回车后可能要输密码(堡垒机和目标机密码可能不同,试一下你之前用过的那个)
|
||||
|
||||
**✅ 成功长这样**:
|
||||
```text
|
||||
sxn@prod-server:~$
|
||||
```
|
||||
|
||||
**❌ 失败常见**:
|
||||
- `Permission denied` → 密码错了,重输
|
||||
- `Connection timed out` → 网络问题,可能 VPN 没连
|
||||
- 卡住不动 → 可能需要输 `yes` 确认服务器指纹,看到 `(yes/no/[fingerprint])?` 就输 `yes` 回车
|
||||
|
||||
---
|
||||
|
||||
## 动作 2:备份当前配置(🟢 低风险,改坏了能还原)
|
||||
|
||||
> **为啥要备份?**:运维铁律 — **改任何东西之前先备份**,这样改坏了能用备份还原,不会把生产搞挂
|
||||
|
||||
```bash
|
||||
# 进入 nginx 配置所在目录
|
||||
cd /opt/wecom-it-desk/nginx
|
||||
|
||||
# 复制一份当前配置,文件名带当前时间(分),方便区分
|
||||
sudo cp nginx.conf nginx.conf.bak-$(date +%H%M)
|
||||
|
||||
# 列出所有备份文件,确认刚才那行成功
|
||||
ls -la nginx.conf.bak-*
|
||||
```
|
||||
|
||||
**为啥用 `$(date +%H%M)`?**:这个写法会自动拼上当前时间(比如 1430 表示 14:30),每次备份文件名都不一样,不会覆盖之前的备份
|
||||
|
||||
**✅ 成功长这样**:
|
||||
```text
|
||||
-rw-r--r-- 1 root root 4821 Jun 15 14:30 nginx.conf.bak-1430
|
||||
```
|
||||
|
||||
**❌ 失败常见**:
|
||||
- `cp: cannot stat 'nginx.conf'` → 当前不在 nginx 目录,先 `cd /opt/wecom-it-desk/nginx` 进去
|
||||
- `Permission denied` → 缺 `sudo`,命令前面加 `sudo` 重试
|
||||
|
||||
---
|
||||
|
||||
## 动作 3:写入 13 行新规则(🟡 中风险,但有备份兜底)
|
||||
|
||||
> **写入啥?**:13 行 nginx 配置,告诉 nginx"从请求头 X-Forwarded-For 里读真实用户 IP"
|
||||
>
|
||||
> **为啥要这样做?**:用户通过公司 WAF/堡垒机访问,WAF 会把真实 IP 放在 `X-Forwarded-For` 请求头里,但 nginx 默认只看直连 IP,所以才误判 403
|
||||
|
||||
**重要**:把下面**从 `cat > /tmp/patch.py` 到 `PYEOF`** 的**整段**一次性粘贴进 PuTTY(右键 = 粘贴)。整段会作为一条命令执行。
|
||||
|
||||
```bash
|
||||
# 创建一个 python 脚本到 /tmp/patch.py
|
||||
cat > /tmp/patch.py << 'PYEOF'
|
||||
fp = '/opt/wecom-it-desk/nginx/nginx.conf'
|
||||
with open(fp) as f:
|
||||
c = f.read()
|
||||
patch = '''
|
||||
# ------------------------------------------------------------------
|
||||
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
|
||||
# ------------------------------------------------------------------
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
set_real_ip_from 10.212.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
'''
|
||||
old = 'error_log /var/log/nginx/error.log warn;'
|
||||
new = old + patch
|
||||
new_c = c.replace(old, new, 1)
|
||||
with open(fp, 'w') as f:
|
||||
f.write(new_c)
|
||||
print('patched, +{} bytes'.format(len(new_c) - len(c)))
|
||||
PYEOF
|
||||
|
||||
# 运行这个 python 脚本,它会自动把上面那 13 行插入到 nginx.conf
|
||||
sudo python3 /tmp/patch.py
|
||||
```
|
||||
|
||||
**术语解释**:
|
||||
- `cat > /tmp/patch.py` → 创建一个文件,内容是后面所有内容
|
||||
- `<< 'PYEOF' ... PYEOF` → 这种写法叫 **heredoc**(直译"这里是文档"),作用是把多行文字原样写入文件
|
||||
- `sudo` → 以管理员身份运行(改系统文件需要权限)
|
||||
|
||||
**✅ 成功长这样**:
|
||||
```text
|
||||
patched, +492 bytes
|
||||
```
|
||||
|
||||
**❌ 失败常见**:
|
||||
- `Permission denied` → 缺 `sudo`,或者 nginx.conf 不存在
|
||||
- `NameError: name 'fp' is not defined` → heredoc 没贴完整,最末尾的 `PYEOF` 没贴上
|
||||
- 没任何输出 → python 没运行,看光标有没有新行,可能没回车
|
||||
|
||||
---
|
||||
|
||||
## 动作 4:确认写入正确(⚪ 无风险,只读)
|
||||
|
||||
> **为啥要确认?**:虽然脚本说写入了,但**人眼看到才真的算**。这步只读不写,放心跑
|
||||
|
||||
```bash
|
||||
# 在 nginx.conf 里搜索"真实 IP 还原"关键字,并显示后面 13 行
|
||||
sudo grep -A 13 "真实 IP 还原" /opt/wecom-it-desk/nginx/nginx.conf
|
||||
```
|
||||
|
||||
**✅ 成功长这样**(应该看到完整 13 行):
|
||||
```nginx
|
||||
# 真实 IP 还原(2026-06-15 v0.5.1 修复)
|
||||
# ------------------------------------------------------------------
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 192.168.0.0/16;
|
||||
set_real_ip_from 10.212.0.0/16;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
```
|
||||
|
||||
**❌ 失败**:
|
||||
- 啥也没输出 → 写入失败,回到动作 3 重做
|
||||
- 只输出一两行 → heredoc 没贴全,需要回滚后重来
|
||||
|
||||
---
|
||||
|
||||
## 动作 5:检查配置语法(⚪ 无风险,只读不执行)
|
||||
|
||||
> **为啥要检查?**:这个命令 nginx 会"假装"按新配置启动,只检查语法,不会真的重启。**通过 = 配置写得对,放心用;不通过 = 写得有问题,继续走会出问题**
|
||||
|
||||
```bash
|
||||
# 在 nginx 容器(就是跑 nginx 服务的那个小 Linux)内,做配置语法检查
|
||||
docker compose exec nginx nginx -t
|
||||
```
|
||||
|
||||
**术语解释**:
|
||||
- `docker compose` → 管理这台服务器上所有"容器"的命令
|
||||
- `exec` → "钻进"某个容器里执行命令
|
||||
- `nginx -t` → nginx 自带的"语法检查"工具(全称 `--test`)
|
||||
|
||||
**✅ 成功长这样**:
|
||||
```text
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
```
|
||||
|
||||
**❌ 失败**(`test is successful` 没出现):
|
||||
- `unexpected "}"` / `unknown directive` → 写错字了,回去动作 4 看看哪里对不上
|
||||
- **直接停下,不要继续** → 复制错误信息贴回给我
|
||||
|
||||
---
|
||||
|
||||
## 动作 6:让 nginx 重新读规则(🟡 中风险,但服务不中断)
|
||||
|
||||
> **"重新读"是啥意思?**:nginx 现在用的还是旧配置,我们让 nginx 不用重启(不会断服务)就把新配置加载进来。这个动作叫"热加载"或 "reload"
|
||||
>
|
||||
> **会断网吗?**:不会,reload 是无缝的,用户那边无感知
|
||||
|
||||
```bash
|
||||
# 通知 nginx 容器内的 master 进程重新读配置
|
||||
docker compose exec nginx nginx -s reload
|
||||
```
|
||||
|
||||
**术语解释**:`-s reload` = 发信号(英文 signal)给 nginx,告诉它"重读配置"
|
||||
|
||||
**✅ 成功长这样**(没报错即成功):
|
||||
```text
|
||||
2026/06/15 14:35:12 [notice] 1#1: signal process started
|
||||
```
|
||||
|
||||
**❌ 失败**:
|
||||
- `nginx: [error]` 开头 → 配置没通过,回去动作 5 看哪里没对
|
||||
- 啥也没输出 → 命令没执行,看光标位置
|
||||
|
||||
---
|
||||
|
||||
## 动作 7:浏览器看效果(⚪ 无风险)
|
||||
|
||||
**为啥这步是浏览器而不是 curl?**:curl 看响应头,浏览器看真实页面。**人眼看到才作数**
|
||||
|
||||
**操作步骤**:
|
||||
1. 打开浏览器
|
||||
2. **开隐身模式**(`Ctrl + Shift + N`,Chrome / Edge 都是这个快捷键)
|
||||
- **为啥要隐身?**:隐身模式不读本地缓存,看到的就是 nginx **当下**返回的
|
||||
3. 地址栏输入 `https://itsupport.servyou.com.cn/itadmin/`
|
||||
4. 按回车
|
||||
|
||||
**✅ 成功长这样**:
|
||||
- 页面正常显示
|
||||
- 按 `F12` 打开开发者工具 → `Network` 选项卡 → 顶部那一行状态码是 **200**(不是 403)
|
||||
|
||||
**❌ 失败**:
|
||||
- 仍然是 403 → 见下面"如果还是 403"段
|
||||
- 502 / 504 → nginx 后面那个服务挂了,贴错误给我
|
||||
- 页面打不开(连接被拒) → DNS 没配,联系 IT 运维
|
||||
|
||||
---
|
||||
|
||||
## 如果还是 403 — 看 WAF 出口 IP(诊断)
|
||||
|
||||
> **啥是 WAF?**:公司部署在 nginx 前面的"统一入口",所有用户请求先经过 WAF 再到 nginx。WAF 自己的 IP 不一定在你写的 4 段内网里,所以还得加
|
||||
|
||||
```bash
|
||||
# 看 nginx 最后 20 条访问日志,找 $remote_addr 是不是 WAF 的 IP
|
||||
docker compose exec nginx tail -20 /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
**日志长这样**:
|
||||
```text
|
||||
10.80.5.123 - - [15/Jun/2026:14:35:45 +0800] "GET /itadmin/ HTTP/1.1" 403 ...
|
||||
^^^^^^^
|
||||
这就是 $remote_addr
|
||||
```
|
||||
|
||||
把那个 IP 数字(比如 `10.80.5.123`)贴回给我,我会:
|
||||
1. 给你追加一行 `set_real_ip_from 10.80.5.123;`
|
||||
2. 让你重跑动作 5 + 动作 6
|
||||
|
||||
---
|
||||
|
||||
## 如果改坏了 — 回滚(啥时候都能用)
|
||||
|
||||
> **啥时候用?**:任何一个动作出问题,你都可以直接回滚到动作 2 备份的版本
|
||||
|
||||
```bash
|
||||
# 列出所有备份,挑最近的一个
|
||||
ls -la /opt/wecom-it-desk/nginx/nginx.conf.bak-*
|
||||
```
|
||||
|
||||
```bash
|
||||
# 用最近那个备份覆盖当前配置(把 1430 换成上面列出的真实时间)
|
||||
sudo cp /opt/wecom-it-desk/nginx/nginx.conf.bak-1430 /opt/wecom-it-desk/nginx/nginx.conf
|
||||
```
|
||||
|
||||
```bash
|
||||
# 重新加载回滚后的配置
|
||||
docker compose exec nginx nginx -s reload
|
||||
```
|
||||
|
||||
回滚后页面应该回到改之前的状态(403 回来),说明回滚成功
|
||||
|
||||
---
|
||||
|
||||
## 一张图看懂流程
|
||||
|
||||
```
|
||||
PuTTY 连服务器
|
||||
│
|
||||
▼
|
||||
备份原配置
|
||||
│
|
||||
▼
|
||||
写入 13 行新规则
|
||||
│
|
||||
▼
|
||||
确认写入正确 ──→ ❌ 不对 ──→ 重做写入 / 回滚
|
||||
│ ✅
|
||||
▼
|
||||
检查配置语法 ──→ ❌ 语法错 ──→ 复制错误贴回给我,不要继续
|
||||
│ ✅
|
||||
▼
|
||||
重载 nginx ─────→ ❌ 报错 ──→ 检查容器状态 / 找 Claude
|
||||
│ ✅
|
||||
▼
|
||||
浏览器看效果 ──→ ❌ 还是 403 ──→ 看 WAF 出口 IP,贴给 Claude
|
||||
│ ✅
|
||||
▼
|
||||
🎉 完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 我建议你第一次做
|
||||
|
||||
**第一次建议**:动作 1 → 动作 2 → **停一下,截图发给我** → 我确认备份成功 → 你再继续动作 3 之后
|
||||
|
||||
**熟练了以后**:一口气跑完动作 1-7,中间不打断
|
||||
|
||||
---
|
||||
|
||||
## 关联
|
||||
|
||||
- 评审报告:`review-p0-security-2026-06-14.md` P0-3
|
||||
- 待办:`ip-whitelist-trust-proxies-todo.md` — v1.0 前必须收窄 4 段 → 4 个 IP
|
||||
- 本地配置:`deploy-server/nginx/nginx.conf`(已包含 patch,下次重打包自动带)
|
||||
- 服务器 IP 变更:`project-production-server-ip-2026-06-15.md` — 10.80.0.136 已下线,用 10.90.5.110
|
||||
- 客户端约束:`feedback-putty-not-openssh.md` — 用 PuTTY,不用 `ssh -J`
|
||||
- 命令行规范:`feedback-cmd-step-by-step.md` — 每行一条 + 中文注释
|
||||
- 小白引导规范:`feedback-beginner-friendly-guide.md` — 讲清目标+风险、术语解释、出错兜底
|
||||
@@ -0,0 +1,8 @@
|
||||
$b = [System.IO.File]::ReadAllText('fix-prod.b64').Trim()
|
||||
$n = $b.Length
|
||||
$half = [int]($n / 2)
|
||||
$s1 = $b.Substring(0, $half)
|
||||
$s2 = $b.Substring($half)
|
||||
[System.IO.File]::WriteAllText('fix-prod.s1', $s1)
|
||||
[System.IO.File]::WriteAllText('fix-prod.s2', $s2)
|
||||
Write-Host "Total=$n seg1=$($s1.Length) seg2=$($s2.Length)"
|
||||
@@ -0,0 +1,78 @@
|
||||
cat > /tmp/gitea-stage1.sh <<'NAS_EOF'
|
||||
#!/bin/bash
|
||||
set +e # don't bail on error, collect everything
|
||||
|
||||
DOCKER=/var/packages/ContainerManager/target/usr/bin/docker
|
||||
|
||||
echo '===== [1] Disk space ====='
|
||||
df -h /volume1
|
||||
|
||||
echo ''
|
||||
echo '===== [2] Docker version ====='
|
||||
$DOCKER --version 2>&1
|
||||
$DOCKER info 2>&1 | head -20
|
||||
|
||||
echo ''
|
||||
echo '===== [3] Existing containers (running + stopped) ====='
|
||||
$DOCKER ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [4] Existing images (gitea-related highlighted) ====='
|
||||
$DOCKER images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}' 2>&1
|
||||
echo '--- gitea images only ---'
|
||||
$DOCKER images 2>&1 | grep -i gitea
|
||||
|
||||
echo ''
|
||||
echo '===== [5] /volume1/docker structure (top-level) ====='
|
||||
ls -la /volume1/docker/ 2>&1 | head -30
|
||||
echo '--- sub-dir sizes (top 20) ---'
|
||||
sudo du -sh /volume1/docker/*/ 2>/dev/null | sort -rh | head -20
|
||||
|
||||
echo ''
|
||||
echo '===== [6] /volume1/docker/gitea exists? ====='
|
||||
ls -la /volume1/docker/gitea 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [7] Listening ports (3000/2222 must be free) ====='
|
||||
ss -tln 2>&1 | grep -E ':3000|:2222|:80|:443' || echo '(none of 3000/2222/80/443 in use)'
|
||||
|
||||
echo ''
|
||||
echo '===== [8] Tailscale ====='
|
||||
/var/packages/Tailscale/target/bin/tailscale status 2>&1 | head -10
|
||||
ip -4 addr show tailscale0 2>&1 | grep inet
|
||||
|
||||
echo ''
|
||||
echo '===== [9] Docker daemon registry config ====='
|
||||
cat /var/packages/ContainerManager/etc/docker/daemon.json 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [10] Test Docker Hub reachability ====='
|
||||
curl -s -o /dev/null -w 'docker.io: HTTP %{http_code}, time %{time_total}s\n' \
|
||||
--max-time 8 https://registry-1.docker.io/v2/ 2>&1
|
||||
curl -s -o /dev/null -w 'gcr.io: HTTP %{http_code}, time %{time_total}s\n' \
|
||||
--max-time 8 https://gcr.io/v2/ 2>&1
|
||||
curl -s -o /dev/null -w 'tencentyun mirror: HTTP %{http_code}, time %{time_total}s\n' \
|
||||
--max-time 8 https://mirror.ccs.tencentyun.com/v2/ 2>&1
|
||||
|
||||
echo ''
|
||||
echo '===== [11] User & groups (is simon in docker group?) ====='
|
||||
id
|
||||
groups
|
||||
|
||||
echo ''
|
||||
echo '===== [12] CPU / memory ====='
|
||||
free -h
|
||||
nproc
|
||||
|
||||
echo ''
|
||||
echo '===== STAGE 1 DONE ====='
|
||||
NAS_EOF
|
||||
|
||||
chmod +x /tmp/gitea-stage1.sh
|
||||
echo '=== SCRIPT WRITTEN: /tmp/gitea-stage1.sh ==='
|
||||
echo '=== Press ENTER to execute (sudo will prompt for password) ==='
|
||||
read
|
||||
sudo bash /tmp/gitea-stage1.sh 2>&1 | tee /tmp/gitea-stage1.log
|
||||
echo ''
|
||||
echo '=== LOG SAVED: /tmp/gitea-stage1.log ==='
|
||||
echo '=== Paste the entire output above back to Claude ==='
|
||||
@@ -0,0 +1,81 @@
|
||||
# 快速诊断 /itdesk/ 500 错误
|
||||
|
||||
**Claude 无法直接 SSH(Windows known_hosts 权限 + 堡垒机交互登录限制),需你跑下面命令并把输出贴回。**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 一键跑法(推荐)
|
||||
|
||||
**完整脚本已写到** `D:\资料\03-项目开发\wecom_it_smart_desk-claude\diagnose-500.sh`(3484 字节)
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. **上传脚本到服务器**(`/tmp/`):
|
||||
```powershell
|
||||
# 你在 PowerShell(堡垒机后的 Windows)跑:
|
||||
scp "D:\资料\03-项目开发\wecom_it_smart_desk-claude\diagnose-500.sh" user@10.90.5.110:/tmp/
|
||||
# (用你自己的文件传输方式,因为堡垒机禁 scp ProxyJump)
|
||||
```
|
||||
|
||||
2. **PuTTY 登录**:
|
||||
- Host:`10.212.189.210`,Port:`2222`,SSH → Open
|
||||
- 用户 `sxn` + 密码
|
||||
- 堡垒机内 `ssh sxn@10.90.5.110` 跳目标机
|
||||
|
||||
3. **在服务器上跑**:
|
||||
```bash
|
||||
sudo cp /tmp/diagnose-500.sh /opt/wecom-it-desk/
|
||||
cd /opt/wecom-it-desk
|
||||
bash diagnose-500.sh > /tmp/diag.log 2>&1
|
||||
cat /tmp/diag.log
|
||||
```
|
||||
|
||||
4. **把 /tmp/diag.log 的内容贴回 Claude**
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 或者手敲(精简版)
|
||||
|
||||
```bash
|
||||
# 1. 容器状态
|
||||
docker compose ps
|
||||
|
||||
# 2. dist 目录在不在
|
||||
ls /opt/wecom-it-desk/frontend-h5/dist/
|
||||
ls /opt/wecom-it-desk/frontend-h5/dist/assets/
|
||||
|
||||
# 3. nginx 容器内能看到 dist 吗
|
||||
docker compose exec nginx ls /usr/share/nginx/html/itdesk/
|
||||
docker compose exec nginx ls /usr/share/nginx/html/itdesk/assets/
|
||||
|
||||
# 4. SSL 证书
|
||||
docker compose exec nginx ls /etc/nginx/ssl/
|
||||
|
||||
# 5. 直接 curl 测试
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ | head -10
|
||||
curl -ksI https://itsupport.servyou.com.cn/itportal/ | head -10
|
||||
curl -ksI https://itsupport.servyou.com.cn/itagent/ | head -10
|
||||
curl -ksI https://itsupport.servyou.com.cn/itadmin/ | head -10
|
||||
|
||||
# 6. nginx 日志
|
||||
docker compose logs --tail=20 nginx
|
||||
docker compose logs --tail=20 backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 我会关注
|
||||
|
||||
| 现象 | 诊断 |
|
||||
|---|---|
|
||||
| `ls /opt/wecom-it-desk/frontend-h5/dist/` 显示 **No such file** | 部署包没含 H5 dist(nginx 会 404 → 但一般不会 500) |
|
||||
| `docker compose exec nginx ls /usr/share/nginx/html/itdesk/` 失败 | nginx 容器挂载路径错了,或 dist 没拷贝进去 |
|
||||
| `curl -ksI https://itsupport.servyou.com.cn/itdesk/` 返回 **HTTP/1.1 500** | 后端代理或 SPA 内部错误 |
|
||||
| `curl -ksI https://itsupport.servyou.com.cn/itportal/` 也 500 | **全站问题**,看 nginx 日志 |
|
||||
| `curl -ksI https://itsupport.servyou.com.cn/itportal/` 200 但 /itdesk/ 500 | **H5 端特定问题**,看 nginx 容器内的文件 |
|
||||
| nginx 错误日志有 **proxy_pass 错误** | 后端没启动或端口不通 |
|
||||
| nginx 错误日志有 **"rewrite ... cycle"** | try_files 死循环,需修 nginx 配置 |
|
||||
|
||||
---
|
||||
|
||||
> 把输出贴回 Claude 后,我会精确定位 500 根因并给出最小修复。
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# 手敲 6 段命令(脚本上传失败时用)
|
||||
|
||||
**PuTTY 登录**:
|
||||
- Host:`10.212.189.210`,Port:`2222`,SSH → Open
|
||||
- 用户 `sxn` + 密码
|
||||
- 堡垒机内再 `ssh sxn@10.90.5.110` 跳目标机
|
||||
|
||||
**逐段跑(每段贴回输出)**:
|
||||
|
||||
```bash
|
||||
# === 段 1: 容器 + dist 目录 ===
|
||||
docker compose ps
|
||||
echo "--- H5 dist ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1
|
||||
echo "--- H5 dist/assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1
|
||||
|
||||
# === 段 2: nginx 容器内挂载 ===
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/ 2>&1
|
||||
echo "--- nginx 容器内 itdesk ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1
|
||||
echo "--- nginx 容器内 SSL ---"
|
||||
docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1
|
||||
|
||||
# === 段 3: 各路径 curl 头(用主机端口绕开 nginx 容器内)===
|
||||
echo "--- /itdesk/ ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -8
|
||||
echo "--- /itportal/ ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -8
|
||||
echo "--- /itagent/ ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -8
|
||||
echo "--- /itadmin/ ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -8
|
||||
echo "--- /itdesk/index.html(直接抓 index)---"
|
||||
curl -ks https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20
|
||||
|
||||
# === 段 4: 容器内 curl 443 测 ===
|
||||
docker compose exec nginx curl -ksI https://localhost/itdesk/ 2>&1 | head -8
|
||||
echo "---"
|
||||
docker compose exec nginx curl -ksI https://localhost/itportal/ 2>&1 | head -8
|
||||
|
||||
# === 段 5: nginx + backend 日志 ===
|
||||
echo "--- nginx 日志 ---"
|
||||
docker compose logs --tail=30 nginx 2>&1
|
||||
echo "--- backend 日志 ---"
|
||||
docker compose logs --tail=30 backend 2>&1
|
||||
|
||||
# === 段 6: 容器内 nginx 错误日志 ===
|
||||
docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1
|
||||
echo "--- access.log ---"
|
||||
docker compose exec nginx tail -30 /var/log/nginx/access.log 2>&1
|
||||
```
|
||||
|
||||
**把全部输出贴回 Claude。**
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# 3 种方法在服务器上跑诊断脚本
|
||||
|
||||
**目标**:在 10.90.5.110 服务器上跑 diagnose-500.sh,把输出粘回给我
|
||||
|
||||
---
|
||||
|
||||
## 方法 1(推荐):PuTTY 连进去,一行命令恢复 + 跑
|
||||
|
||||
**步骤 1**:PuTTY 客户端
|
||||
- Host:`10.212.189.210`,Port:`2222`,SSH → Open
|
||||
- 用户 `sxn` + 密码
|
||||
- 堡垒机内再 `ssh sxn@10.90.5.110` 跳目标机
|
||||
|
||||
**步骤 2**:服务器内贴这一行(整段一次性):
|
||||
```bash
|
||||
cat > /tmp/diag.sh << 'ENDOFSCRIPT'
|
||||
#!/bin/bash
|
||||
docker compose ps
|
||||
echo "---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/ 2>&1 | head -10
|
||||
echo "--- assets ---"
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1 | head -10
|
||||
echo "--- nginx 容器内 ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1 | head -10
|
||||
echo "--- nginx 容器内 assets ---"
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/assets/ 2>&1 | head -10
|
||||
echo "--- SSL ---"
|
||||
docker compose exec nginx ls -la /etc/nginx/ssl/ 2>&1 | head -10
|
||||
echo "--- /itdesk/ 头 ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -8
|
||||
echo "--- /itportal/ 头 ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itportal/ 2>&1 | head -8
|
||||
echo "--- /itagent/ 头 ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itagent/ 2>&1 | head -8
|
||||
echo "--- /itadmin/ 头 ---"
|
||||
curl -ksI https://itsupport.servyou.com.cn/itadmin/ 2>&1 | head -8
|
||||
echo "--- /itdesk/ 完整 body 前 20 行 ---"
|
||||
curl -ks https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -20
|
||||
echo "--- nginx 错误日志 ---"
|
||||
docker compose exec nginx tail -30 /var/log/nginx/error.log 2>&1
|
||||
echo "--- nginx 访问日志 ---"
|
||||
docker compose exec nginx tail -20 /var/log/nginx/access.log 2>&1
|
||||
echo "--- backend 日志 ---"
|
||||
docker compose logs --tail=20 backend 2>&1
|
||||
ENDOFSCRIPT
|
||||
bash /tmp/diag.sh 2>&1
|
||||
```
|
||||
|
||||
**步骤 3**:把输出整段粘回给我
|
||||
|
||||
---
|
||||
|
||||
## 方法 2:用 scp 上传本地脚本
|
||||
|
||||
**前提**:你能 scp 到 10.90.5.110(堡垒机后的方式)
|
||||
|
||||
```bash
|
||||
scp "C:\Users\simon\Downloads\diagnose-500 (1).sh" sxn@10.90.5.110:/tmp/
|
||||
# (如果直连 scp 不通,可能要用堡垒机的文件传输功能)
|
||||
```
|
||||
|
||||
然后 PuTTY 连进去跑:
|
||||
- Host:`10.212.189.210`,Port:`2222`,SSH → Open
|
||||
- 堡垒机内 `ssh sxn@10.90.5.110` 跳目标机
|
||||
```bash
|
||||
sudo cp /tmp/diagnose-500.sh /opt/wecom-it-desk/
|
||||
cd /opt/wecom-it-desk
|
||||
bash diagnose-500.sh > /tmp/diag.log 2>&1
|
||||
cat /tmp/diag.log
|
||||
```
|
||||
|
||||
把 `cat /tmp/diag.log` 的输出粘回
|
||||
|
||||
---
|
||||
|
||||
## 方法 3:服务器直接下载(若服务器能上外网)
|
||||
|
||||
```bash
|
||||
# PuTTY 连:Host 10.212.189.210 Port 2222 → 堡垒机内 ssh sxn@10.90.5.110
|
||||
cd /tmp
|
||||
# 如果服务器能访问 GitHub raw / Gitea
|
||||
curl -O https://你的存放点/diagnose-500.sh
|
||||
bash diagnose-500.sh > /tmp/diag.log 2>&1
|
||||
cat /tmp/diag.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最简版(只要 5 行输出)
|
||||
|
||||
如果方法 1 太长,**只要这 5 行**就够我定位:
|
||||
|
||||
```bash
|
||||
docker compose ps 2>&1
|
||||
ls -la /opt/wecom-it-desk/frontend-h5/dist/assets/ 2>&1
|
||||
docker compose exec nginx ls -la /usr/share/nginx/html/itdesk/ 2>&1
|
||||
docker compose exec nginx tail -10 /var/log/nginx/error.log 2>&1
|
||||
curl -ksI https://itsupport.servyou.com.cn/itdesk/ 2>&1 | head -8
|
||||
```
|
||||
|
||||
**把这 5 段输出粘回,我能立刻定位 500 原因。**
|
||||
@@ -0,0 +1,145 @@
|
||||
# 部署包 v0.5.2(2026-06-16)
|
||||
|
||||
## 📦 包含文件
|
||||
|
||||
| 文件 | 大小 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `deploy-h5-v2.tar` | 645 KB | 本地 `D:\资料\03-项目开发\wecom_it_smart_desk-claude\deploy-h5-v2.tar` | H5 前端强化版骨架屏 |
|
||||
| `deploy-backend-v052.tar` | 2.5 MB | 本地 `D:\资料\03-项目开发\wecom_it_smart_desk-claude\deploy-backend-v052.tar` | 后端 3 个 hotfix |
|
||||
|
||||
## 🔄 改动清单(本次)
|
||||
|
||||
### 后端 3 个 hotfix
|
||||
1. **`backend/app/main.py`** — 审批链接 seed 重写(6 条一站式运维平台真实工单)
|
||||
2. **`backend/app/api/h5.py:836-846`** — `messages.id` UUID 比较 → 字符串比较(防 PG 报 "operator does not exist: character varying = uuid")
|
||||
3. **`backend/app/api/ws.py:38,196`** — 移除 `request: Request` 参数,改用 `websocket.headers` / `websocket.query_params`(修 WS 连接失败)
|
||||
|
||||
### H5 前端 1 个强化
|
||||
4. **`frontend-h5/index.html`** + **`main.ts`** + **`App.vue`** + **`router/index.ts`**
|
||||
- 骨架屏 CSS 强化(logo 64px + 阴影 + 脉冲动画)
|
||||
- 显式 `body.app-loaded` 类名 + 最小 500ms 显示(代替易失效的 `:empty` 选择器)
|
||||
- ChatView 改静态 import(去掉 301KB 二次请求)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 部署步骤(PuTTY 一次跑完)
|
||||
|
||||
### 步骤 1:WinSCP 上传 2 个包到 `/tmp/`
|
||||
- `deploy-h5-v2.tar` (645 KB)
|
||||
- `deploy-backend-v052.tar` (2.5 MB)
|
||||
|
||||
### 步骤 2:覆盖部署 H5 前端
|
||||
|
||||
```bash
|
||||
rm -rf /opt/wecom-it-desk/frontend-h5/dist
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/wecom-it-desk/frontend-h5/dist
|
||||
```
|
||||
|
||||
```bash
|
||||
tar -xf /tmp/deploy-h5-v2.tar -C /opt/wecom-it-desk/frontend-h5/dist
|
||||
```
|
||||
|
||||
```bash
|
||||
grep -c "app-loaded" /opt/wecom-it-desk/frontend-h5/dist/index.html
|
||||
```
|
||||
> 期望输出:`≥ 1`(新特征字符串)
|
||||
|
||||
### 步骤 3:覆盖部署后端 3 个 hotfix
|
||||
|
||||
```bash
|
||||
# 先备份
|
||||
cp -r /opt/wecom-it-desk/backend/app /opt/wecom-it-desk/backend/app.bak-$(date +%Y%m%d-%H%M)
|
||||
```
|
||||
|
||||
```bash
|
||||
# 解压新代码到 /tmp/backend-new
|
||||
mkdir -p /tmp/backend-new
|
||||
tar -xf /tmp/deploy-backend-v052.tar -C /tmp/backend-new
|
||||
```
|
||||
|
||||
```bash
|
||||
# 复制到 backend 目录(只覆盖改过的 3 个文件)
|
||||
cp /tmp/backend-new/backend/app/main.py /opt/wecom-it-desk/backend/app/main.py
|
||||
cp /tmp/backend-new/backend/app/api/h5.py /opt/wecom-it-desk/backend/app/api/h5.py
|
||||
cp /tmp/backend-new/backend/app/api/ws.py /opt/wecom-it-desk/backend/app/api/ws.py
|
||||
```
|
||||
|
||||
```bash
|
||||
# 重启 backend 容器生效
|
||||
docker restart wecom_it_backend
|
||||
```
|
||||
|
||||
```bash
|
||||
# 验证启动成功(看启动日志,无报错即可)
|
||||
docker logs --tail 20 wecom_it_backend
|
||||
```
|
||||
|
||||
### 步骤 4:让审批链接重新 seed(数据库操作)
|
||||
|
||||
```bash
|
||||
# 进入 backend 容器执行 SQL
|
||||
docker exec -it wecom_it_postgres psql -U wecom -d wecom_it_desk -c "DELETE FROM approval_links;"
|
||||
```
|
||||
|
||||
```bash
|
||||
# 重启 backend 让 seed 重新跑
|
||||
docker restart wecom_it_backend
|
||||
```
|
||||
|
||||
```bash
|
||||
# 验证:看 approval_links 应该有 10 条(6 IT + 2 HR + 1 行政 + 1 财务)
|
||||
docker exec -it wecom_it_postgres psql -U wecom -d wecom_it_desk -c "SELECT category, COUNT(*) FROM approval_links GROUP BY category;"
|
||||
```
|
||||
> 期望输出:6 条 IT + 2 条 HR + 1 条 行政 + 1 条 财务
|
||||
|
||||
### 步骤 5:验证 /itadmin/ 403 + 一站式运维平台链接
|
||||
|
||||
1. **走企微入口**:企微 → IT 数据查询平台(或类似应用) → 进 /itadmin/ → 转外部浏览器
|
||||
2. **预期**:不再 403,正常加载管理后台
|
||||
3. **H5 端**:
|
||||
- 企微 → IT 智能服务台 → /itdesk/ → 转外部浏览器
|
||||
- 右侧"常用资源"标签里应该看到 6 条新 IT 工单链接
|
||||
4. **后端 WebSocket**:
|
||||
- 坐席工作台 /itagent/ 打开 → 浏览器 F12 → Network → 看 `ws://...` 状态应该是 `101 Switching Protocols` 而不是失败
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 出错兜底(3 秒回滚)
|
||||
|
||||
```bash
|
||||
# H5 回滚
|
||||
rm -rf /opt/wecom-it-desk/frontend-h5/dist
|
||||
mv /opt/wecom-it-desk/frontend-h5/dist.bak-最新时间戳 /opt/wecom-it-desk/frontend-h5/dist
|
||||
```
|
||||
|
||||
```bash
|
||||
# 后端回滚
|
||||
rm -rf /opt/wecom-it-desk/backend/app
|
||||
mv /opt/wecom-it-desk/backend/app.bak-最新时间戳 /opt/wecom-it-desk/backend/app
|
||||
docker restart wecom_it_backend
|
||||
```
|
||||
|
||||
```bash
|
||||
# 审批链接恢复
|
||||
docker exec -it wecom_it_postgres psql -U wecom -d wecom_it_desk -c "DELETE FROM approval_links; INSERT INTO approval_links(...) VALUES(...);"
|
||||
# (或从 alembic 之前的 migration 找原始 seed 数据)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 验证清单(用户跑完部署后逐项打勾)
|
||||
|
||||
- [ ] H5 骨架屏:浏览器访问 /itdesk/ 看到蓝色 logo + 文字
|
||||
- [ ] /itadmin/ 不再 403
|
||||
- [ ] H5 常用资源 6 条 IT 工单链接(点开能跳到 devops.dc.servyou-it.com)
|
||||
- [ ] /itagent/ WebSocket 连接成功(F12 Network 看 WS 状态 101)
|
||||
- [ ] /itportal/ 业务功能正常
|
||||
|
||||
---
|
||||
|
||||
**部署包生成时间**:2026-06-16 08:17
|
||||
**版本**:v0.5.2
|
||||
**配套文档**:本文件 `部署包-2026-06-16-v0.5.2.md`
|
||||
@@ -0,0 +1,155 @@
|
||||
# 部署包 v0.5.3(2026-06-16)
|
||||
|
||||
## 🔄 v0.5.3 vs v0.5.2 区别
|
||||
|
||||
| 项 | v0.5.2 | v0.5.3 |
|
||||
|------|------|------|
|
||||
| 一站式运维平台 IT 链接 | 6 条 | **5 条**(去掉"IT设备升级与硬件维修",与一站式运维平台冲突) |
|
||||
| 审批链接总数 | 10 条 | **9 条**(5 IT + 2 HR + 1 行政 + 1 财务) |
|
||||
| 后端 hotfix | main.py / h5.py / ws.py | 同 3 个文件,只 main.py 内容变化 |
|
||||
| 部署包文件 | deploy-backend-v052.tar | **deploy-backend-v053.tar** |
|
||||
|
||||
## 📦 包含文件
|
||||
|
||||
| 文件 | 大小 | 路径 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `deploy-h5-v2.tar` | 645 KB | 本地 `D:\资料\03-项目开发\wecom_it_smart_desk-claude\deploy-h5-v2.tar` | H5 前端强化版骨架屏(沿用 v0.5.2) |
|
||||
| `deploy-backend-v053.tar` | 2.5 MB | 本地 `D:\资料\03-项目开发\wecom_it_smart_desk-claude\deploy-backend-v053.tar` | **本版本后端**(替代 v052) |
|
||||
|
||||
## 🔄 后端 hotfix 清单(本版本)
|
||||
|
||||
1. **`backend/app/main.py`** — 审批链接 seed 改为 **5 IT + 2 HR + 1 行政 + 1 财务 = 9 条**,去除"IT设备升级与硬件维修"(申请单冲突)
|
||||
2. **`backend/app/api/h5.py:836-846`** — `messages.id` UUID 比较 → 字符串比较(防 PG 报 "operator does not exist: character varying = uuid")
|
||||
3. **`backend/app/api/ws.py:38,196`** — 移除 `request: Request` 参数,改用 `websocket.headers` / `websocket.query_params`(修 WS 连接失败)
|
||||
|
||||
## 🚀 部署步骤(PuTTY 一次跑完)
|
||||
|
||||
### 步骤 0:⚠️ 弃用 v0.5.2 部署包
|
||||
|
||||
> **重要**:上一版 `deploy-backend-v052.tar` 包含了已删除的"IT设备升级与硬件维修"链接,**不要上传** v052。只上传 v053。
|
||||
|
||||
### 步骤 1:WinSCP 上传 2 个包到 `/tmp/`
|
||||
|
||||
- `deploy-h5-v2.tar` (645 KB)
|
||||
- `deploy-backend-v053.tar` (2.5 MB,新版本)
|
||||
|
||||
### 步骤 2:覆盖部署 H5 前端(如果上次已部署可跳过)
|
||||
|
||||
```bash
|
||||
grep -c "app-loaded" /opt/wecom-it-desk/frontend-h5/dist/index.html
|
||||
```
|
||||
> 期望:`≥ 1`(已部署过)
|
||||
|
||||
### 步骤 3:覆盖部署后端 3 个 hotfix
|
||||
|
||||
```bash
|
||||
cp -r /opt/wecom-it-desk/backend/app /opt/wecom-it-desk/backend/app.bak-$(date +%Y%m%d-%H%M)
|
||||
```
|
||||
> 备份当前后端
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/backend-new-v053
|
||||
```
|
||||
> 创建新版解压目录
|
||||
|
||||
```bash
|
||||
tar -xf /tmp/deploy-backend-v053.tar -C /tmp/backend-new-v053
|
||||
```
|
||||
> 解压 v053 包
|
||||
|
||||
```bash
|
||||
yes | cp -f /tmp/backend-new-v053/backend/app/main.py /opt/wecom-it-desk/backend/app/main.py
|
||||
```
|
||||
> **强制覆盖** main.py(yes 自动回答 y)
|
||||
|
||||
```bash
|
||||
yes | cp -f /tmp/backend-new-v053/backend/app/api/h5.py /opt/wecom-it-desk/backend/app/api/h5.py
|
||||
```
|
||||
> **强制覆盖** h5.py
|
||||
|
||||
```bash
|
||||
yes | cp -f /tmp/backend-new-v053/backend/app/api/ws.py /opt/wecom-it-desk/backend/app/api/ws.py
|
||||
```
|
||||
> **强制覆盖** ws.py
|
||||
|
||||
### 步骤 4:验证覆盖成功(3 个 grep)
|
||||
|
||||
```bash
|
||||
grep -c "devops.dc.servyou-it.com" /opt/wecom-it-desk/backend/app/main.py
|
||||
```
|
||||
> 期望输出:`5`(v0.5.3 改为 5 条)
|
||||
|
||||
```bash
|
||||
grep -c "IT设备升级与硬件维修" /opt/wecom-it-desk/backend/app/main.py
|
||||
```
|
||||
> 期望输出:`0`(应已删除,只剩注释里可能有提及)
|
||||
|
||||
```bash
|
||||
grep -c "str(after_message_id)" /opt/wecom-it-desk/backend/app/api/h5.py
|
||||
```
|
||||
> 期望输出:`1`
|
||||
|
||||
```bash
|
||||
grep -c "request: Request" /opt/wecom-it-desk/backend/app/api/ws.py
|
||||
```
|
||||
> 期望输出:`0`
|
||||
|
||||
### 步骤 5:删旧数据 + 重启让新 seed 跑
|
||||
|
||||
```bash
|
||||
docker exec -it wecom_it_postgres psql -U wecom -d wecom_it_desk -c "DELETE FROM approval_links;"
|
||||
```
|
||||
> 期望:`DELETE 8`(清掉旧占位符)
|
||||
|
||||
```bash
|
||||
docker restart wecom_it_backend
|
||||
```
|
||||
> 重启后端
|
||||
|
||||
```bash
|
||||
sleep 5
|
||||
```
|
||||
> 等后端启动完成
|
||||
|
||||
### 步骤 6:验证审批链接已 seed 进 9 条
|
||||
|
||||
```bash
|
||||
docker exec -it wecom_it_postgres psql -U wecom -d wecom_it_desk -c "SELECT category, COUNT(*) FROM approval_links GROUP BY category ORDER BY category;"
|
||||
```
|
||||
> 期望输出:
|
||||
> ```
|
||||
> category | count
|
||||
> -----------+-------
|
||||
> IT | 5
|
||||
> HR | 2
|
||||
> 行政 | 1
|
||||
> 财务 | 1
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 出错兜底(3 秒回滚)
|
||||
|
||||
```bash
|
||||
# 后端回滚到上次备份
|
||||
rm -rf /opt/wecom-it-desk/backend/app
|
||||
mv /opt/wecom-it-desk/backend/app.bak-最新时间戳 /opt/wecom-it-desk/backend/app
|
||||
docker restart wecom_it_backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 验证清单(用户跑完部署后逐项打勾)
|
||||
|
||||
- [ ] H5 骨架屏:浏览器访问 /itdesk/ 看到蓝色 logo + 文字
|
||||
- [ ] /itadmin/ 不再 403
|
||||
- [ ] H5 常用资源 **5 条** IT 工单链接(没有"IT设备升级与硬件维修",点开能跳到 devops.dc.servyou-it.com)
|
||||
- [ ] /itagent/ WebSocket 连接成功(F12 Network 看 WS 状态 101)
|
||||
- [ ] /itportal/ 业务功能正常
|
||||
|
||||
---
|
||||
|
||||
**部署包生成时间**:2026-06-16 08:40
|
||||
**版本**:v0.5.3
|
||||
**配套文档**:本文件 `部署包-2026-06-16-v0.5.3.md`
|
||||
**前置版本**:v0.5.2(已弃用,不要再上传)
|
||||
Reference in New Issue
Block a user