feat: 租户级企微配置改造
All checks were successful
continuous-integration/drone/push Build is passing

- 新增 platform_tenant_wechat_apps 表(租户企微应用配置)
- platform_apps 增加 require_jssdk 字段
- platform_tenant_apps 增加 wechat_app_id 关联字段
- 新增企微应用管理 API 和页面
- 应用管理页面增加 JS-SDK 开关
- 应用配置页面增加企微应用选择
This commit is contained in:
111
2026-01-23 19:05:00 +08:00
parent f815b29c51
commit c4bd7c8251
13 changed files with 1198 additions and 580 deletions

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# AI 对话启动指南
> 本文档用于快速让新 AI 了解项目背景和当前状态
---
## 项目概述
这是一个多租户 AI 应用平台,包含:
| 项目 | 说明 | 技术栈 |
|------|------|--------|
| 000-platform | 统一管理平台Admin UI | Python FastAPI + Vue 3 |
| 001-tools | PHP+n8n 工具迁移 | Python FastAPI + Vue 3 |
| 011-ai-interview | AI 面试应用 | Python FastAPI + Vue 3 |
---
## 当前状态
### 已完成
- DevOps: Gitea + Drone CI/CD + Docker 自动部署
- 多租户鉴权: URL 参数签名验证 (tid, aid, ts, sign)
- 应用管理: Admin UI 可配置应用和生成签名链接
- 5 个工具迁移: 头脑风暴、高情商回复、面诊方案、客户画像、医疗合规
- AI 调用统计: 记录到 platform_ai_call_events
### 待完成
- 企业微信 JS-SDK 集成
- n8n 返回 token 统计
- 生产环境部署
---
## 关键文件位置
```
AgentWD/
├── _shared/ # 共享文档
│ ├── AI对话启动指南.md # 本文件
│ ├── 项目进度/ # 进度文档
│ │ ├── 整体进度汇总.md
│ │ ├── 000-platform进度.md
│ │ └── 001-tools迁移进度.md
│ └── 数据库/
│ └── 测试环境配置.md # 服务器/数据库信息
├── 框架/
│ └── CICD配置.md # CI/CD 说明
├── projects/
│ ├── 000-platform/ # 管理平台
│ ├── 001-tools/ # 工具集
│ └── 011-ai-interview/ # AI 面试
└── scripts/ # 数据库脚本
```
---
## 服务器信息
| 服务 | 地址 | 说明 |
|------|------|------|
| 测试服务器 | 47.107.172.23 | root / Nj861021 |
| MySQL | 47.107.71.55 | scrm_reader / ScrmReader2024Pass |
| n8n | https://n8n.ireborn.com.cn | 工作流引擎 |
| Gitea | https://git.ai.ireborn.com.cn | 代码托管 |
| Drone CI | https://ci.ai.ireborn.com.cn | 自动部署 |
---
## 测试地址
| 服务 | 测试环境 |
|------|----------|
| 管理平台 | https://platform.test.ai.ireborn.com.cn/admin |
| 工具集 | https://tools.test.ai.ireborn.com.cn |
| AI 面试 | https://interview.test.ai.ireborn.com.cn |
---
## 鉴权机制
### 001-tools 租户鉴权
```
URL: https://tools.test.ai.ireborn.com.cn/brainstorm?tid=test&aid=tools
```
- `tid`: 租户ID必须
- `aid`: 应用代码(必须,默认 tools
- `ts`: 时间戳(需签名的租户必须)
- `sign`: HMAC-SHA256(tid+aid+ts, token_secret)
### 测试租户
- `test`: 免签名,直接 `?tid=test&aid=tools`
- `qiqi`: 需签名,通过 Admin UI 生成链接
---
## 开发规范
1. **分支策略**: develop → 测试环境, main → 生产环境
2. **代码提交**: 推送后自动触发 Drone CI/CD
3. **样式保留**: 001-tools 各工具保持原 PHP 样式,不统一
4. **数据库表**: 统一使用 `platform_` 前缀
---
## 常用命令
```bash
# 检查构建状态
curl "https://ci.ai.ireborn.com.cn/api/repos/admin/000-platform/builds?per_page=1"
# SSH 到服务器
ssh root@47.107.172.23 # 密码: Nj861021
# 执行 MySQL
mysql -h 47.107.71.55 -u scrm_reader -pScrmReader2024Pass new_qiqi
```
---
## 详细进度
请阅读以下文件获取更多信息:
1. `_shared/项目进度/整体进度汇总.md` - 整体架构和状态
2. `_shared/项目进度/001-tools迁移进度.md` - 工具迁移详情
3. `_shared/项目进度/000-platform进度.md` - 平台服务详情
4. `_shared/数据库/测试环境配置.md` - 环境配置详情

View File

@@ -1,46 +1,48 @@
"""平台服务入口""" """平台服务入口"""
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import get_settings from .config import get_settings
from .routers import stats_router, logs_router, config_router, health_router from .routers import stats_router, logs_router, config_router, health_router
from .routers.auth import router as auth_router from .routers.auth import router as auth_router
from .routers.tenants import router as tenants_router from .routers.tenants import router as tenants_router
from .routers.tenant_apps import router as tenant_apps_router from .routers.tenant_apps import router as tenant_apps_router
from .routers.apps import router as apps_router from .routers.tenant_wechat_apps import router as tenant_wechat_apps_router
from .routers.apps import router as apps_router
settings = get_settings()
settings = get_settings()
app = FastAPI(
title=settings.APP_NAME, app = FastAPI(
version=settings.APP_VERSION, title=settings.APP_NAME,
description="平台基础设施服务 - 统计/日志/配置管理" version=settings.APP_VERSION,
) description="平台基础设施服务 - 统计/日志/配置管理"
)
# CORS
app.add_middleware( # CORS
CORSMiddleware, app.add_middleware(
allow_origins=["*"], CORSMiddleware,
allow_credentials=True, allow_origins=["*"],
allow_methods=["*"], allow_credentials=True,
allow_headers=["*"], allow_methods=["*"],
) allow_headers=["*"],
)
# 注册路由
app.include_router(health_router) # 注册路由
app.include_router(auth_router, prefix="/api") app.include_router(health_router)
app.include_router(tenants_router, prefix="/api") app.include_router(auth_router, prefix="/api")
app.include_router(tenant_apps_router, prefix="/api") app.include_router(tenants_router, prefix="/api")
app.include_router(apps_router, prefix="/api") app.include_router(tenant_apps_router, prefix="/api")
app.include_router(stats_router, prefix="/api") app.include_router(tenant_wechat_apps_router, prefix="/api")
app.include_router(logs_router, prefix="/api") app.include_router(apps_router, prefix="/api")
app.include_router(config_router, prefix="/api") app.include_router(stats_router, prefix="/api")
app.include_router(logs_router, prefix="/api")
app.include_router(config_router, prefix="/api")
@app.get("/")
async def root():
return { @app.get("/")
"service": settings.APP_NAME, async def root():
"version": settings.APP_VERSION, return {
"docs": "/docs" "service": settings.APP_NAME,
} "version": settings.APP_VERSION,
"docs": "/docs"
}

View File

@@ -1,17 +1,19 @@
"""数据模型""" """数据模型"""
from .tenant import Tenant, Subscription, Config from .tenant import Tenant, Subscription, Config
from .tenant_app import TenantApp from .tenant_app import TenantApp
from .app import App from .tenant_wechat_app import TenantWechatApp
from .stats import AICallEvent, TenantUsageDaily from .app import App
from .logs import PlatformLog from .stats import AICallEvent, TenantUsageDaily
from .logs import PlatformLog
__all__ = [
"Tenant", __all__ = [
"Subscription", "Tenant",
"Config", "Subscription",
"TenantApp", "Config",
"App", "TenantApp",
"AICallEvent", "TenantWechatApp",
"TenantUsageDaily", "App",
"PlatformLog" "AICallEvent",
] "TenantUsageDaily",
"PlatformLog"
]

View File

@@ -1,23 +1,26 @@
"""应用定义模型""" """应用定义模型"""
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base from ..database import Base
class App(Base): class App(Base):
"""应用定义表 - 定义可供租户使用的应用""" """应用定义表 - 定义可供租户使用的应用"""
__tablename__ = "platform_apps" __tablename__ = "platform_apps"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
app_code = Column(String(50), nullable=False, unique=True) # 唯一标识,如 tools app_code = Column(String(50), nullable=False, unique=True) # 唯一标识,如 tools
app_name = Column(String(100), nullable=False) # 显示名称 app_name = Column(String(100), nullable=False) # 显示名称
base_url = Column(String(500)) # 基础URL如 https://tools.test.ai.ireborn.com.cn base_url = Column(String(500)) # 基础URL如 https://tools.test.ai.ireborn.com.cn
description = Column(Text) # 应用描述 description = Column(Text) # 应用描述
# 应用下的工具/功能列表JSON 数组) # 应用下的工具/功能列表JSON 数组)
# [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...] # [{"code": "brainstorm", "name": "头脑风暴", "path": "/brainstorm"}, ...]
tools = Column(Text) tools = Column(Text)
status = Column(SmallInteger, default=1) # 0-禁用 1-启用 # 是否需要企微JS-SDK
created_at = Column(TIMESTAMP, default=datetime.now) require_jssdk = Column(SmallInteger, default=0) # 0-不需要 1-需要
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -13,10 +13,8 @@ class TenantApp(Base):
app_code = Column(String(50), nullable=False, default='tools') app_code = Column(String(50), nullable=False, default='tools')
app_name = Column(String(100)) app_name = Column(String(100))
# 企业微信配置 # 企业微信配置(关联 platform_tenant_wechat_apps
wechat_corp_id = Column(String(100)) wechat_app_id = Column(Integer) # 关联的企微应用ID
wechat_agent_id = Column(String(50))
wechat_secret_encrypted = Column(Text)
# 鉴权配置 # 鉴权配置
access_token = Column(String(64)) # 访问令牌(长期有效) access_token = Column(String(64)) # 访问令牌(长期有效)

View File

@@ -0,0 +1,23 @@
"""租户企业微信应用配置模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class TenantWechatApp(Base):
"""租户企业微信应用配置表
一个租户可以配置多个企微应用,供不同的平台应用关联使用
"""
__tablename__ = "platform_tenant_wechat_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False, index=True)
name = Column(String(100), nullable=False) # 应用名称,如"工具集应用"
corp_id = Column(String(100), nullable=False) # 企业ID
agent_id = Column(String(50), nullable=False) # 应用AgentId
secret_encrypted = Column(Text) # 加密的Secret
status = Column(SmallInteger, default=1) # 0-禁用 1-启用
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

View File

@@ -30,6 +30,7 @@ class AppCreate(BaseModel):
base_url: Optional[str] = None base_url: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
tools: Optional[List[ToolItem]] = None tools: Optional[List[ToolItem]] = None
require_jssdk: bool = False
class AppUpdate(BaseModel): class AppUpdate(BaseModel):
@@ -38,6 +39,7 @@ class AppUpdate(BaseModel):
base_url: Optional[str] = None base_url: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
tools: Optional[List[ToolItem]] = None tools: Optional[List[ToolItem]] = None
require_jssdk: Optional[bool] = None
status: Optional[int] = None status: Optional[int] = None
@@ -117,6 +119,7 @@ async def create_app(
base_url=data.base_url, base_url=data.base_url,
description=data.description, description=data.description,
tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None, tools=json.dumps([t.model_dump() for t in data.tools], ensure_ascii=False) if data.tools else None,
require_jssdk=1 if data.require_jssdk else 0,
status=1 status=1
) )
db.add(app) db.add(app)
@@ -147,6 +150,10 @@ async def update_app(
else: else:
update_data['tools'] = None update_data['tools'] = None
# 处理 require_jssdk
if 'require_jssdk' in update_data:
update_data['require_jssdk'] = 1 if update_data['require_jssdk'] else 0
for key, value in update_data.items(): for key, value in update_data.items():
setattr(app, key, value) setattr(app, key, value)
@@ -261,6 +268,7 @@ def format_app(app: App) -> dict:
"base_url": app.base_url, "base_url": app.base_url,
"description": app.description, "description": app.description,
"tools": json.loads(app.tools) if app.tools else [], "tools": json.loads(app.tools) if app.tools else [],
"require_jssdk": bool(app.require_jssdk),
"status": app.status, "status": app.status,
"created_at": app.created_at, "created_at": app.created_at,
"updated_at": app.updated_at "updated_at": app.updated_at

View File

@@ -10,7 +10,6 @@ from ..database import get_db
from ..models.tenant_app import TenantApp from ..models.tenant_app import TenantApp
from .auth import get_current_user, require_operator from .auth import get_current_user, require_operator
from ..models.user import User from ..models.user import User
from ..services.crypto import encrypt_config, decrypt_config
router = APIRouter(prefix="/tenant-apps", tags=["应用配置"]) router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
@@ -21,9 +20,7 @@ class TenantAppCreate(BaseModel):
tenant_id: str tenant_id: str
app_code: str = "tools" app_code: str = "tools"
app_name: Optional[str] = None app_name: Optional[str] = None
wechat_corp_id: Optional[str] = None wechat_app_id: Optional[int] = None # 关联的企微应用ID
wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None # 明文,存储时加密
access_token: Optional[str] = None # 如果不传则自动生成 access_token: Optional[str] = None # 如果不传则自动生成
allowed_origins: Optional[List[str]] = None allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None
@@ -31,9 +28,7 @@ class TenantAppCreate(BaseModel):
class TenantAppUpdate(BaseModel): class TenantAppUpdate(BaseModel):
app_name: Optional[str] = None app_name: Optional[str] = None
wechat_corp_id: Optional[str] = None wechat_app_id: Optional[int] = None # 关联的企微应用ID
wechat_agent_id: Optional[str] = None
wechat_secret: Optional[str] = None
access_token: Optional[str] = None access_token: Optional[str] = None
allowed_origins: Optional[List[str]] = None allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None allowed_tools: Optional[List[str]] = None
@@ -66,7 +61,7 @@ async def list_tenant_apps(
"total": total, "total": total,
"page": page, "page": page,
"size": size, "size": size,
"items": [format_tenant_app(app, mask_secret=True) for app in apps] "items": [format_tenant_app(app, mask_secret=True, db=db) for app in apps]
} }
@@ -81,7 +76,7 @@ async def get_tenant_app(
if not app: if not app:
raise HTTPException(status_code=404, detail="应用配置不存在") raise HTTPException(status_code=404, detail="应用配置不存在")
return format_tenant_app(app, mask_secret=True) return format_tenant_app(app, mask_secret=True, db=db)
@router.post("") @router.post("")
@@ -102,18 +97,11 @@ async def create_tenant_app(
# 自动生成 access_token # 自动生成 access_token
access_token = data.access_token or secrets.token_hex(32) access_token = data.access_token or secrets.token_hex(32)
# 加密 wechat_secret
wechat_secret_encrypted = None
if data.wechat_secret:
wechat_secret_encrypted = encrypt_config(data.wechat_secret)
app = TenantApp( app = TenantApp(
tenant_id=data.tenant_id, tenant_id=data.tenant_id,
app_code=data.app_code, app_code=data.app_code,
app_name=data.app_name, app_name=data.app_name,
wechat_corp_id=data.wechat_corp_id, wechat_app_id=data.wechat_app_id,
wechat_agent_id=data.wechat_agent_id,
wechat_secret_encrypted=wechat_secret_encrypted,
access_token=access_token, access_token=access_token,
allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None, allowed_origins=json.dumps(data.allowed_origins) if data.allowed_origins else None,
allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None, allowed_tools=json.dumps(data.allowed_tools) if data.allowed_tools else None,
@@ -140,12 +128,6 @@ async def update_tenant_app(
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# 处理 wechat_secret
if 'wechat_secret' in update_data:
if update_data['wechat_secret']:
app.wechat_secret_encrypted = encrypt_config(update_data['wechat_secret'])
del update_data['wechat_secret']
# 处理 JSON 字段 # 处理 JSON 字段
if 'allowed_origins' in update_data: if 'allowed_origins' in update_data:
update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None
@@ -194,34 +176,28 @@ async def regenerate_token(
return {"success": True, "access_token": new_token} return {"success": True, "access_token": new_token}
@router.get("/{app_id}/wechat-secret") def format_tenant_app(app: TenantApp, mask_secret: bool = True, db: Session = None) -> dict:
async def get_wechat_secret(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取解密的 wechat_secret仅操作员以上"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
secret = None
if app.wechat_secret_encrypted:
secret = decrypt_config(app.wechat_secret_encrypted)
return {"wechat_secret": secret}
def format_tenant_app(app: TenantApp, mask_secret: bool = True) -> dict:
"""格式化应用配置""" """格式化应用配置"""
# 获取关联的企微应用信息
wechat_app_info = None
if app.wechat_app_id and db:
from ..models.tenant_wechat_app import TenantWechatApp
wechat_app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app.wechat_app_id).first()
if wechat_app:
wechat_app_info = {
"id": wechat_app.id,
"name": wechat_app.name,
"corp_id": wechat_app.corp_id,
"agent_id": wechat_app.agent_id
}
result = { result = {
"id": app.id, "id": app.id,
"tenant_id": app.tenant_id, "tenant_id": app.tenant_id,
"app_code": app.app_code, "app_code": app.app_code,
"app_name": app.app_name, "app_name": app.app_name,
"wechat_corp_id": app.wechat_corp_id, "wechat_app_id": app.wechat_app_id,
"wechat_agent_id": app.wechat_agent_id, "wechat_app": wechat_app_info,
"has_wechat_secret": bool(app.wechat_secret_encrypted),
"access_token": "******" if mask_secret and app.access_token else app.access_token, "access_token": "******" if mask_secret and app.access_token else app.access_token,
"allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [], "allowed_origins": json.loads(app.allowed_origins) if app.allowed_origins else [],
"allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [], "allowed_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],

View File

@@ -0,0 +1,198 @@
"""租户企业微信应用配置路由"""
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.tenant_wechat_app import TenantWechatApp
from .auth import get_current_user, require_operator
from ..models.user import User
from ..services.crypto import encrypt_config, decrypt_config
router = APIRouter(prefix="/tenant-wechat-apps", tags=["租户企微应用"])
# Schemas
class TenantWechatAppCreate(BaseModel):
tenant_id: str
name: str
corp_id: str
agent_id: str
secret: Optional[str] = None # 明文,存储时加密
class TenantWechatAppUpdate(BaseModel):
name: Optional[str] = None
corp_id: Optional[str] = None
agent_id: Optional[str] = None
secret: Optional[str] = None
status: Optional[int] = None
# API Endpoints
@router.get("")
async def list_tenant_wechat_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户企微应用列表"""
query = db.query(TenantWechatApp)
if tenant_id:
query = query.filter(TenantWechatApp.tenant_id == tenant_id)
total = query.count()
apps = query.order_by(TenantWechatApp.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_wechat_app(app) for app in apps]
}
@router.get("/by-tenant/{tenant_id}")
async def list_by_tenant(
tenant_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取指定租户的所有企微应用(用于下拉选择)"""
apps = db.query(TenantWechatApp).filter(
TenantWechatApp.tenant_id == tenant_id,
TenantWechatApp.status == 1
).order_by(TenantWechatApp.id.asc()).all()
return [{"id": app.id, "name": app.name, "corp_id": app.corp_id, "agent_id": app.agent_id} for app in apps]
@router.get("/{app_id}")
async def get_tenant_wechat_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取企微应用详情"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
return format_wechat_app(app)
@router.post("")
async def create_tenant_wechat_app(
data: TenantWechatAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建企微应用"""
# 加密 secret
secret_encrypted = None
if data.secret:
secret_encrypted = encrypt_config(data.secret)
app = TenantWechatApp(
tenant_id=data.tenant_id,
name=data.name,
corp_id=data.corp_id,
agent_id=data.agent_id,
secret_encrypted=secret_encrypted,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id}
@router.put("/{app_id}")
async def update_tenant_wechat_app(
app_id: int,
data: TenantWechatAppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新企微应用"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 secret 加密
if 'secret' in update_data:
if update_data['secret']:
app.secret_encrypted = encrypt_config(update_data['secret'])
del update_data['secret']
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_tenant_wechat_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除企微应用"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
# 检查是否有租户应用在使用
from ..models.tenant_app import TenantApp
usage_count = db.query(TenantApp).filter(TenantApp.wechat_app_id == app_id).count()
if usage_count > 0:
raise HTTPException(status_code=400, detail=f"{usage_count} 个应用配置正在使用此企微应用,无法删除")
db.delete(app)
db.commit()
return {"success": True}
@router.get("/{app_id}/secret")
async def get_wechat_secret(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""获取解密的 secret仅操作员以上"""
app = db.query(TenantWechatApp).filter(TenantWechatApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="企微应用不存在")
secret = None
if app.secret_encrypted:
secret = decrypt_config(app.secret_encrypted)
return {"secret": secret}
def format_wechat_app(app: TenantWechatApp) -> dict:
"""格式化企微应用数据"""
return {
"id": app.id,
"tenant_id": app.tenant_id,
"name": app.name,
"corp_id": app.corp_id,
"agent_id": app.agent_id,
"has_secret": bool(app.secret_encrypted),
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

View File

@@ -1,92 +1,98 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const routes = [ const routes = [
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: () => import('@/views/login/index.vue'), component: () => import('@/views/login/index.vue'),
meta: { title: '登录', public: true } meta: { title: '登录', public: true }
}, },
{ {
path: '/', path: '/',
component: () => import('@/components/Layout.vue'), component: () => import('@/components/Layout.vue'),
redirect: '/dashboard', redirect: '/dashboard',
children: [ children: [
{ {
path: 'dashboard', path: 'dashboard',
name: 'Dashboard', name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'Odometer' } meta: { title: '仪表盘', icon: 'Odometer' }
}, },
{ {
path: 'tenants', path: 'tenants',
name: 'Tenants', name: 'Tenants',
component: () => import('@/views/tenants/index.vue'), component: () => import('@/views/tenants/index.vue'),
meta: { title: '租户管理', icon: 'OfficeBuilding' } meta: { title: '租户管理', icon: 'OfficeBuilding' }
}, },
{ {
path: 'tenants/:id', path: 'tenants/:id',
name: 'TenantDetail', name: 'TenantDetail',
component: () => import('@/views/tenants/detail.vue'), component: () => import('@/views/tenants/detail.vue'),
meta: { title: '租户详情', hidden: true } meta: { title: '租户详情', hidden: true }
}, },
{ {
path: 'apps', path: 'apps',
name: 'Apps', name: 'Apps',
component: () => import('@/views/apps/index.vue'), component: () => import('@/views/apps/index.vue'),
meta: { title: '应用管理', icon: 'Grid' } meta: { title: '应用管理', icon: 'Grid' }
}, },
{ {
path: 'app-config', path: 'tenant-wechat-apps',
name: 'AppConfig', name: 'TenantWechatApps',
component: () => import('@/views/app-config/index.vue'), component: () => import('@/views/tenant-wechat-apps/index.vue'),
meta: { title: '租户应用配置', icon: 'Setting' } meta: { title: '企微应用', icon: 'ChatDotRound' }
}, },
{ {
path: 'stats', path: 'app-config',
name: 'Stats', name: 'AppConfig',
component: () => import('@/views/stats/index.vue'), component: () => import('@/views/app-config/index.vue'),
meta: { title: '统计分析', icon: 'TrendCharts' } meta: { title: '租户应用配置', icon: 'Setting' }
}, },
{ {
path: 'logs', path: 'stats',
name: 'Logs', name: 'Stats',
component: () => import('@/views/logs/index.vue'), component: () => import('@/views/stats/index.vue'),
meta: { title: '日志查看', icon: 'Document' } meta: { title: '统计分析', icon: 'TrendCharts' }
}, },
{ {
path: 'users', path: 'logs',
name: 'Users', name: 'Logs',
component: () => import('@/views/users/index.vue'), component: () => import('@/views/logs/index.vue'),
meta: { title: '用户管理', icon: 'User', role: 'admin' } meta: { title: '日志查看', icon: 'Document' }
} },
] {
} path: 'users',
] name: 'Users',
component: () => import('@/views/users/index.vue'),
const router = createRouter({ meta: { title: '用户管理', icon: 'User', role: 'admin' }
history: createWebHistory(), }
routes ]
}) }
]
// 路由守卫
router.beforeEach((to, from, next) => { const router = createRouter({
// 设置页面标题 history: createWebHistory(),
document.title = to.meta.title ? `${to.meta.title} - 平台管理` : '平台管理' routes
})
// 检查登录状态
const authStore = useAuthStore() // 路由守卫
router.beforeEach((to, from, next) => {
if (to.meta.public) { // 设置页面标题
next() document.title = to.meta.title ? `${to.meta.title} - 平台管理` : '平台管理'
} else if (!authStore.isLoggedIn) {
next('/login') // 检查登录状态
} else if (to.meta.role && authStore.user?.role !== to.meta.role) { const authStore = useAuthStore()
next('/dashboard')
} else { if (to.meta.public) {
next() next()
} } else if (!authStore.isLoggedIn) {
}) next('/login')
} else if (to.meta.role && authStore.user?.role !== to.meta.role) {
export default router next('/dashboard')
} else {
next()
}
})
export default router

View File

@@ -19,6 +19,10 @@ const query = reactive({
// 应用列表(从应用管理获取) // 应用列表(从应用管理获取)
const appList = ref([]) const appList = ref([])
const appToolsMap = ref({}) // app_code -> tools[] const appToolsMap = ref({}) // app_code -> tools[]
const appRequireJssdk = ref({}) // app_code -> require_jssdk
// 企微应用列表(按租户)
const wechatAppList = ref([]) // 当前表单租户的企微应用列表
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
@@ -29,12 +33,15 @@ const form = reactive({
tenant_id: '', tenant_id: '',
app_code: 'tools', app_code: 'tools',
app_name: '', app_name: '',
wechat_corp_id: '', wechat_app_id: null, // 关联的企微应用ID
wechat_agent_id: '',
wechat_secret: '',
allowed_tools: [] allowed_tools: []
}) })
// 当前选择的应用是否需要 JS-SDK
const currentAppRequireJssdk = computed(() => {
return appRequireJssdk.value[form.app_code] || false
})
// 根据选择的应用获取工具选项 // 根据选择的应用获取工具选项
const toolOptions = computed(() => { const toolOptions = computed(() => {
const tools = appToolsMap.value[form.app_code] || [] const tools = appToolsMap.value[form.app_code] || []
@@ -66,11 +73,13 @@ const urlInfo = ref({})
async function fetchApps() { async function fetchApps() {
try { try {
const res = await api.get('/api/apps/all') const res = await api.get('/api/apps', { params: { size: 100 } })
appList.value = res.data || [] const apps = res.data.items || []
appList.value = apps.map(a => ({ app_code: a.app_code, app_name: a.app_name }))
// 获取每个应用的工具列表 // 获取每个应用的工具列表和 JS-SDK 要求
for (const app of appList.value) { for (const app of apps) {
appRequireJssdk.value[app.app_code] = app.require_jssdk || false
try { try {
const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`) const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`)
appToolsMap.value[app.app_code] = toolsRes.data || [] appToolsMap.value[app.app_code] = toolsRes.data || []
@@ -83,6 +92,19 @@ async function fetchApps() {
} }
} }
async function fetchWechatApps(tenantId) {
if (!tenantId) {
wechatAppList.value = []
return
}
try {
const res = await api.get(`/api/tenant-wechat-apps/by-tenant/${tenantId}`)
wechatAppList.value = res.data || []
} catch (e) {
wechatAppList.value = []
}
}
async function fetchList() { async function fetchList() {
loading.value = true loading.value = true
try { try {
@@ -113,37 +135,38 @@ function handleCreate() {
tenant_id: '', tenant_id: '',
app_code: 'tools', app_code: 'tools',
app_name: '', app_name: '',
wechat_corp_id: '', wechat_app_id: null,
wechat_agent_id: '',
wechat_secret: '',
allowed_tools: [] allowed_tools: []
}) })
wechatAppList.value = []
dialogVisible.value = true dialogVisible.value = true
} }
function handleEdit(row) { async function handleEdit(row) {
editingId.value = row.id editingId.value = row.id
dialogTitle.value = '编辑应用配置' dialogTitle.value = '编辑应用配置'
Object.assign(form, { Object.assign(form, {
tenant_id: row.tenant_id, tenant_id: row.tenant_id,
app_code: row.app_code, app_code: row.app_code,
app_name: row.app_name || '', app_name: row.app_name || '',
wechat_corp_id: row.wechat_corp_id || '', wechat_app_id: row.wechat_app_id || null,
wechat_agent_id: row.wechat_agent_id || '',
wechat_secret: '', // 不回显密钥
allowed_tools: row.allowed_tools || [] allowed_tools: row.allowed_tools || []
}) })
// 获取该租户的企微应用列表
await fetchWechatApps(row.tenant_id)
dialogVisible.value = true dialogVisible.value = true
} }
// 租户ID变化时重新获取企微应用列表
async function handleTenantChange() {
form.wechat_app_id = null
await fetchWechatApps(form.tenant_id)
}
async function handleSubmit() { async function handleSubmit() {
await formRef.value.validate() await formRef.value.validate()
const data = { ...form } const data = { ...form }
// 如果没有输入新密钥,不传这个字段
if (!data.wechat_secret) {
delete data.wechat_secret
}
try { try {
if (editingId.value) { if (editingId.value) {
@@ -188,21 +211,6 @@ async function handleRegenerateToken(row) {
} }
} }
async function handleViewSecret(row) {
try {
const res = await api.get(`/api/tenant-apps/${row.id}/wechat-secret`)
if (res.data.wechat_secret) {
ElMessageBox.alert(res.data.wechat_secret, '微信 Secret', {
confirmButtonText: '关闭'
})
} else {
ElMessage.info('未配置微信 Secret')
}
} catch (e) {
// 错误已在拦截器处理
}
}
// 生成链接功能 // 生成链接功能
function handleShowUrl(row) { function handleShowUrl(row) {
currentRow.value = row currentRow.value = row
@@ -319,12 +327,12 @@ onMounted(() => {
<el-table-column prop="tenant_id" label="租户ID" width="120" /> <el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="app_code" label="应用" width="100" /> <el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="app_name" label="应用名称" width="150" /> <el-table-column prop="app_name" label="应用名称" width="150" />
<el-table-column prop="wechat_corp_id" label="企业ID" width="150" show-overflow-tooltip /> <el-table-column label="企微应用" width="180">
<el-table-column prop="wechat_agent_id" label="应用ID" width="100" />
<el-table-column label="微信密钥" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.has_wechat_secret" type="success" size="small">已配置</el-tag> <template v-if="row.wechat_app">
<el-tag v-else type="info" size="small">未配置</el-tag> <el-tag type="success" size="small">{{ row.wechat_app.name }}</el-tag>
</template>
<el-tag v-else type="info" size="small">未关联</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Access Token" width="120"> <el-table-column label="Access Token" width="120">
@@ -341,12 +349,11 @@ onMounted(() => {
<span v-if="(row.allowed_tools || []).length > 3">...</span> <span v-if="(row.allowed_tools || []).length > 3">...</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="300" fixed="right"> <el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="success" link size="small" @click="handleShowUrl(row)">生成链接</el-button> <el-button type="success" link size="small" @click="handleShowUrl(row)">生成链接</el-button>
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button> <el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleViewSecret(row)">密钥</el-button> <el-button v-if="authStore.isOperator" type="info" link size="small" @click="handleRegenerateToken(row)">重置Token</el-button>
<el-button v-if="authStore.isOperator" type="info" link size="small" @click="handleRegenerateToken(row)">重置</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button> <el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -367,7 +374,12 @@ onMounted(() => {
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px"> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户ID" prop="tenant_id"> <el-form-item label="租户ID" prop="tenant_id">
<el-input v-model="form.tenant_id" :disabled="!!editingId" placeholder="如: tenant_001" /> <el-input
v-model="form.tenant_id"
:disabled="!!editingId"
placeholder="如: qiqi"
@blur="handleTenantChange"
/>
</el-form-item> </el-form-item>
<el-form-item label="应用" prop="app_code"> <el-form-item label="应用" prop="app_code">
<el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择应用" style="width: 100%"> <el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择应用" style="width: 100%">
@@ -379,16 +391,25 @@ onMounted(() => {
<el-input v-model="form.app_name" placeholder="显示名称(可选)" /> <el-input v-model="form.app_name" placeholder="显示名称(可选)" />
</el-form-item> </el-form-item>
<el-divider content-position="left">企业微信配置</el-divider> <el-divider v-if="currentAppRequireJssdk" content-position="left">企业微信关联</el-divider>
<el-form-item label="企业 ID"> <el-form-item v-if="currentAppRequireJssdk" label="关联企微应用">
<el-input v-model="form.wechat_corp_id" placeholder="ww开头的企业ID" /> <el-select
</el-form-item> v-model="form.wechat_app_id"
<el-form-item label="应用 ID"> placeholder="选择企微应用"
<el-input v-model="form.wechat_agent_id" placeholder="自建应用的 AgentId" /> clearable
</el-form-item> style="width: 100%"
<el-form-item label="应用 Secret"> >
<el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" /> <el-option
v-for="wa in wechatAppList"
:key="wa.id"
:label="`${wa.name} (${wa.corp_id})`"
:value="wa.id"
/>
</el-select>
<div v-if="wechatAppList.length === 0 && form.tenant_id" style="color: #909399; font-size: 12px; margin-top: 4px">
该租户暂无企微应用请先在企微应用中配置
</div>
</el-form-item> </el-form-item>
<el-divider content-position="left">权限配置</el-divider> <el-divider content-position="left">权限配置</el-divider>

View File

@@ -1,302 +1,316 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api' import api from '@/api'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
const loading = ref(false) const loading = ref(false)
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const query = reactive({ const query = reactive({
page: 1, page: 1,
size: 20 size: 20
}) })
// 对话框 // 对话框
const dialogVisible = ref(false) const dialogVisible = ref(false)
const dialogTitle = ref('') const dialogTitle = ref('')
const editingId = ref(null) const editingId = ref(null)
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ const form = reactive({
app_code: '', app_code: '',
app_name: '', app_name: '',
base_url: '', base_url: '',
description: '', description: '',
tools: [] require_jssdk: false,
}) tools: []
})
const rules = {
app_code: [{ required: true, message: '请输入应用代码', trigger: 'blur' }], const rules = {
app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }] app_code: [{ required: true, message: '请输入应用代码', trigger: 'blur' }],
} app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }]
}
// 工具编辑
const toolDialogVisible = ref(false) // 工具编辑
const editingToolIndex = ref(-1) const toolDialogVisible = ref(false)
const toolForm = reactive({ const editingToolIndex = ref(-1)
code: '', const toolForm = reactive({
name: '', code: '',
path: '' name: '',
}) path: ''
})
async function fetchList() {
loading.value = true async function fetchList() {
try { loading.value = true
const res = await api.get('/api/apps', { params: query }) try {
tableData.value = res.data.items || [] const res = await api.get('/api/apps', { params: query })
total.value = res.data.total || 0 tableData.value = res.data.items || []
} catch (e) { total.value = res.data.total || 0
console.error(e) } catch (e) {
} finally { console.error(e)
loading.value = false } finally {
} loading.value = false
} }
}
function handleSearch() {
query.page = 1 function handleSearch() {
fetchList() query.page = 1
} fetchList()
}
function handlePageChange(page) {
query.page = page function handlePageChange(page) {
fetchList() query.page = page
} fetchList()
}
function handleCreate() {
editingId.value = null function handleCreate() {
dialogTitle.value = '新建应用' editingId.value = null
Object.assign(form, { dialogTitle.value = '新建应用'
app_code: '', Object.assign(form, {
app_name: '', app_code: '',
base_url: '', app_name: '',
description: '', base_url: '',
tools: [] description: '',
}) require_jssdk: false,
dialogVisible.value = true tools: []
} })
dialogVisible.value = true
function handleEdit(row) { }
editingId.value = row.id
dialogTitle.value = '编辑应用' function handleEdit(row) {
Object.assign(form, { editingId.value = row.id
app_code: row.app_code, dialogTitle.value = '编辑应用'
app_name: row.app_name, Object.assign(form, {
base_url: row.base_url || '', app_code: row.app_code,
description: row.description || '', app_name: row.app_name,
tools: row.tools || [] base_url: row.base_url || '',
}) description: row.description || '',
dialogVisible.value = true require_jssdk: row.require_jssdk || false,
} tools: row.tools || []
})
async function handleSubmit() { dialogVisible.value = true
await formRef.value.validate() }
const data = { ...form } async function handleSubmit() {
await formRef.value.validate()
try {
if (editingId.value) { const data = { ...form }
await api.put(`/api/apps/${editingId.value}`, data)
ElMessage.success('更新成功') try {
} else { if (editingId.value) {
await api.post('/api/apps', data) await api.put(`/api/apps/${editingId.value}`, data)
ElMessage.success('创建成功') ElMessage.success('更新成功')
} } else {
dialogVisible.value = false await api.post('/api/apps', data)
fetchList() ElMessage.success('创建成功')
} catch (e) { }
// 错误已在拦截器处理 dialogVisible.value = false
} fetchList()
} } catch (e) {
// 错误已在拦截器处理
async function handleDelete(row) { }
await ElMessageBox.confirm(`确定删除应用 "${row.app_name}" 吗?`, '提示', { }
type: 'warning'
}) async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除应用 "${row.app_name}" 吗?`, '提示', {
try { type: 'warning'
await api.delete(`/api/apps/${row.id}`) })
ElMessage.success('删除成功')
fetchList() try {
} catch (e) { await api.delete(`/api/apps/${row.id}`)
// 错误已在拦截器处理 ElMessage.success('删除成功')
} fetchList()
} } catch (e) {
// 错误已在拦截器处理
async function handleToggleStatus(row) { }
const newStatus = row.status === 1 ? 0 : 1 }
try {
await api.put(`/api/apps/${row.id}`, { status: newStatus }) async function handleToggleStatus(row) {
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用') const newStatus = row.status === 1 ? 0 : 1
fetchList() try {
} catch (e) { await api.put(`/api/apps/${row.id}`, { status: newStatus })
// 错误已在拦截器处理 ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
} fetchList()
} } catch (e) {
// 错误已在拦截器处理
// 工具管理 }
function handleAddTool() { }
editingToolIndex.value = -1
Object.assign(toolForm, { code: '', name: '', path: '' }) // 工具管理
toolDialogVisible.value = true function handleAddTool() {
} editingToolIndex.value = -1
Object.assign(toolForm, { code: '', name: '', path: '' })
function handleEditTool(index) { toolDialogVisible.value = true
editingToolIndex.value = index }
const tool = form.tools[index]
Object.assign(toolForm, { ...tool }) function handleEditTool(index) {
toolDialogVisible.value = true editingToolIndex.value = index
} const tool = form.tools[index]
Object.assign(toolForm, { ...tool })
function handleDeleteTool(index) { toolDialogVisible.value = true
form.tools.splice(index, 1) }
}
function handleDeleteTool(index) {
function handleSaveTool() { form.tools.splice(index, 1)
if (!toolForm.code || !toolForm.name) { }
ElMessage.warning('请填写工具代码和名称')
return function handleSaveTool() {
} if (!toolForm.code || !toolForm.name) {
ElMessage.warning('请填写工具代码和名称')
const tool = { ...toolForm } return
if (editingToolIndex.value >= 0) { }
form.tools[editingToolIndex.value] = tool
} else { const tool = { ...toolForm }
form.tools.push(tool) if (editingToolIndex.value >= 0) {
} form.tools[editingToolIndex.value] = tool
toolDialogVisible.value = false } else {
} form.tools.push(tool)
}
onMounted(() => { toolDialogVisible.value = false
fetchList() }
})
</script> onMounted(() => {
fetchList()
<template> })
<div class="page-container"> </script>
<div class="page-header">
<div class="title">应用管理</div> <template>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate"> <div class="page-container">
<el-icon><Plus /></el-icon> <div class="page-header">
新建应用 <div class="title">应用管理</div>
</el-button> <el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
</div> <el-icon><Plus /></el-icon>
新建应用
<div class="page-tip"> </el-button>
<el-alert type="info" :closable="false"> </div>
应用管理定义可供租户使用的应用配置应用的基础URL和工具列表
租户配置中选择应用后即可生成带签名的访问链接 <div class="page-tip">
</el-alert> <el-alert type="info" :closable="false">
</div> 应用管理定义可供租户使用的应用配置应用的基础URL和工具列表
租户配置中选择应用后即可生成带签名的访问链接
<!-- 表格 --> </el-alert>
<el-table v-loading="loading" :data="tableData" style="width: 100%"> </div>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="app_code" label="应用代码" width="120" /> <!-- 表格 -->
<el-table-column prop="app_name" label="应用名称" width="150" /> <el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="base_url" label="基础URL" min-width="250" show-overflow-tooltip /> <el-table-column prop="id" label="ID" width="60" />
<el-table-column label="工具数量" width="100"> <el-table-column prop="app_code" label="应用代码" width="120" />
<template #default="{ row }"> <el-table-column prop="app_name" label="应用名称" width="150" />
<el-tag size="small">{{ (row.tools || []).length }} </el-tag> <el-table-column prop="base_url" label="基础URL" min-width="250" show-overflow-tooltip />
</template> <el-table-column label="工具数量" width="100">
</el-table-column> <template #default="{ row }">
<el-table-column label="状态" width="80"> <el-tag size="small">{{ (row.tools || []).length }} </el-tag>
<template #default="{ row }"> </template>
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small"> </el-table-column>
{{ row.status === 1 ? '启用' : '禁用' }} <el-table-column label="JS-SDK" width="90">
</el-tag> <template #default="{ row }">
</template> <el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
</el-table-column> {{ row.require_jssdk ? '需要' : '不需要' }}
<el-table-column label="操作" width="200" fixed="right"> </el-tag>
<template #default="{ row }"> </template>
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button> </el-table-column>
<el-button v-if="authStore.isOperator" :type="row.status === 1 ? 'warning' : 'success'" link size="small" @click="handleToggleStatus(row)"> <el-table-column label="状态" width="80">
{{ row.status === 1 ? '禁用' : '启用' }} <template #default="{ row }">
</el-button> <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button> {{ row.status === 1 ? '启用' : '禁用' }}
</template> </el-tag>
</el-table-column> </template>
</el-table> </el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<!-- 分页 --> <template #default="{ row }">
<div style="margin-top: 20px; display: flex; justify-content: flex-end"> <el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-pagination <el-button v-if="authStore.isOperator" :type="row.status === 1 ? 'warning' : 'success'" link size="small" @click="handleToggleStatus(row)">
v-model:current-page="query.page" {{ row.status === 1 ? '禁用' : '启用' }}
:page-size="query.size" </el-button>
:total="total" <el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
layout="total, prev, pager, next" </template>
@current-change="handlePageChange" </el-table-column>
/> </el-table>
</div>
<!-- 分页 -->
<!-- 编辑对话框 --> <div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px"> <el-pagination
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px"> v-model:current-page="query.page"
<el-form-item label="应用代码" prop="app_code"> :page-size="query.size"
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: tools" /> :total="total"
</el-form-item> layout="total, prev, pager, next"
<el-form-item label="应用名称" prop="app_name"> @current-change="handlePageChange"
<el-input v-model="form.app_name" placeholder="显示名称" /> />
</el-form-item> </div>
<el-form-item label="基础URL">
<el-input v-model="form.base_url" placeholder="如: https://tools.test.ai.ireborn.com.cn" /> <!-- 编辑对话框 -->
</el-form-item> <el-dialog v-model="dialogVisible" :title="dialogTitle" width="700px">
<el-form-item label="描述"> <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" /> <el-form-item label="应用代码" prop="app_code">
</el-form-item> <el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: tools" />
</el-form-item>
<el-divider content-position="left">工具列表</el-divider> <el-form-item label="应用名称" prop="app_name">
<el-input v-model="form.app_name" placeholder="显示名称" />
<el-form-item label="工具"> </el-form-item>
<div style="width: 100%"> <el-form-item label="基础URL">
<el-table :data="form.tools" size="small" border style="margin-bottom: 10px"> <el-input v-model="form.base_url" placeholder="如: https://tools.test.ai.ireborn.com.cn" />
<el-table-column prop="code" label="代码" width="120" /> </el-form-item>
<el-table-column prop="name" label="名称" width="120" /> <el-form-item label="描述">
<el-table-column prop="path" label="路径" /> <el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" />
<el-table-column label="操作" width="120"> </el-form-item>
<template #default="{ $index }"> <el-form-item label="企微JS-SDK">
<el-button type="primary" link size="small" @click="handleEditTool($index)">编辑</el-button> <el-switch v-model="form.require_jssdk" />
<el-button type="danger" link size="small" @click="handleDeleteTool($index)">删除</el-button> <span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用才能使用</span>
</template> </el-form-item>
</el-table-column>
</el-table> <el-divider content-position="left">工具列表</el-divider>
<el-button type="primary" size="small" @click="handleAddTool">
<el-icon><Plus /></el-icon> <el-form-item label="工具">
添加工具 <div style="width: 100%">
</el-button> <el-table :data="form.tools" size="small" border style="margin-bottom: 10px">
</div> <el-table-column prop="code" label="代码" width="120" />
</el-form-item> <el-table-column prop="name" label="名称" width="120" />
</el-form> <el-table-column prop="path" label="路径" />
<template #footer> <el-table-column label="操作" width="120">
<el-button @click="dialogVisible = false">取消</el-button> <template #default="{ $index }">
<el-button type="primary" @click="handleSubmit">确定</el-button> <el-button type="primary" link size="small" @click="handleEditTool($index)">编辑</el-button>
</template> <el-button type="danger" link size="small" @click="handleDeleteTool($index)">删除</el-button>
</el-dialog> </template>
</el-table-column>
<!-- 工具编辑对话框 --> </el-table>
<el-dialog v-model="toolDialogVisible" :title="editingToolIndex >= 0 ? '编辑工具' : '添加工具'" width="400px"> <el-button type="primary" size="small" @click="handleAddTool">
<el-form :model="toolForm" label-width="80px"> <el-icon><Plus /></el-icon>
<el-form-item label="代码"> 添加工具
<el-input v-model="toolForm.code" placeholder="如: brainstorm" /> </el-button>
</el-form-item> </div>
<el-form-item label="名称"> </el-form-item>
<el-input v-model="toolForm.name" placeholder="如: 头脑风暴" /> </el-form>
</el-form-item> <template #footer>
<el-form-item label="路径"> <el-button @click="dialogVisible = false">取消</el-button>
<el-input v-model="toolForm.path" placeholder="如: /brainstorm" /> <el-button type="primary" @click="handleSubmit">确定</el-button>
</el-form-item> </template>
</el-form> </el-dialog>
<template #footer>
<el-button @click="toolDialogVisible = false">取消</el-button> <!-- 工具编辑对话框 -->
<el-button type="primary" @click="handleSaveTool">确定</el-button> <el-dialog v-model="toolDialogVisible" :title="editingToolIndex >= 0 ? '编辑工具' : '添加工具'" width="400px">
</template> <el-form :model="toolForm" label-width="80px">
</el-dialog> <el-form-item label="代码">
</div> <el-input v-model="toolForm.code" placeholder="如: brainstorm" />
</template> </el-form-item>
<el-form-item label="名称">
<style scoped> <el-input v-model="toolForm.name" placeholder="如: 头脑风暴" />
.page-tip { </el-form-item>
margin-bottom: 16px; <el-form-item label="路径">
} <el-input v-model="toolForm.path" placeholder="如: /brainstorm" />
</style> </el-form-item>
</el-form>
<template #footer>
<el-button @click="toolDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveTool">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-tip {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,238 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
tenant_id: ''
})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
name: '',
corp_id: '',
agent_id: '',
secret: ''
})
const rules = {
tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }],
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
corp_id: [{ required: true, message: '请输入企业ID', trigger: 'blur' }],
agent_id: [{ required: true, message: '请输入应用ID', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenant-wechat-apps', { params: query })
tableData.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function handleCreate() {
editingId.value = null
dialogTitle.value = '新建企微应用'
Object.assign(form, {
tenant_id: query.tenant_id || '',
name: '',
corp_id: '',
agent_id: '',
secret: ''
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑企微应用'
Object.assign(form, {
tenant_id: row.tenant_id,
name: row.name,
corp_id: row.corp_id,
agent_id: row.agent_id,
secret: '' // 不回显密钥
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
// 如果没有输入新密钥,不传这个字段
if (!data.secret) {
delete data.secret
}
try {
if (editingId.value) {
await api.put(`/api/tenant-wechat-apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/tenant-wechat-apps', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除企微应用「${row.name}」吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenant-wechat-apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleViewSecret(row) {
try {
const res = await api.get(`/api/tenant-wechat-apps/${row.id}/secret`)
if (res.data.secret) {
ElMessageBox.alert(res.data.secret, '应用 Secret', {
confirmButtonText: '关闭'
})
} else {
ElMessage.info('未配置 Secret')
}
} catch (e) {
// 错误已在拦截器处理
}
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">企微应用管理</div>
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建企微应用
</el-button>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="query.tenant_id"
placeholder="租户ID"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="tenant_id" label="租户ID" width="120" />
<el-table-column prop="name" label="应用名称" width="180" />
<el-table-column prop="corp_id" label="企业ID" width="200" show-overflow-tooltip />
<el-table-column prop="agent_id" label="应用ID" width="120" />
<el-table-column label="Secret" width="100">
<template #default="{ row }">
<el-tag v-if="row.has_secret" type="success" size="small">已配置</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="authStore.isOperator" type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="authStore.isOperator" type="warning" link size="small" @click="handleViewSecret(row)">查看密钥</el-button>
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="query.page"
:page-size="query.size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
/>
</div>
<!-- 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="租户ID" prop="tenant_id">
<el-input v-model="form.tenant_id" :disabled="!!editingId" placeholder="如: qiqi" />
</el-form-item>
<el-form-item label="应用名称" prop="name">
<el-input v-model="form.name" placeholder="如: 工具集企微应用" />
</el-form-item>
<el-form-item label="企业ID" prop="corp_id">
<el-input v-model="form.corp_id" placeholder="ww开头的企业ID" />
</el-form-item>
<el-form-item label="应用ID" prop="agent_id">
<el-input v-model="form.agent_id" placeholder="自建应用的 AgentId" />
</el-form-item>
<el-form-item label="应用 Secret">
<el-input
v-model="form.secret"
type="password"
show-password
:placeholder="editingId ? '留空则不修改' : '应用的 Secret'"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>