Compare commits

10 Commits

Author SHA1 Message Date
111
c4bd7c8251 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 开关
- 应用配置页面增加企微应用选择
2026-01-23 19:05:00 +08:00
111
f815b29c51 feat: 静态 Token 鉴权改造
All checks were successful
continuous-integration/drone/push Build is passing
- 将 token_secret 改为 access_token(长期有效)
- 移除 token_required 字段,统一使用 token 验证
- 生成链接简化为 ?tid=xxx&token=xxx 格式
- 前端移除签名验证开关,链接永久有效
2026-01-23 18:43:04 +08:00
111
39f33d7ac5 feat: 添加应用管理和生成签名链接功能
All checks were successful
continuous-integration/drone/push Build is passing
- 新增 platform_apps 表和 App 模型
- 新增应用管理页面 /apps
- 应用配置页面添加"生成链接"功能
- 支持一键生成带签名的访问 URL
2026-01-23 18:22:17 +08:00
111
2a9f62bef8 fix: use correct token fields (input_tokens + output_tokens)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:14:55 +08:00
111
b018844078 fix: add GET endpoints for stats summary and logs query
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:12:18 +08:00
111
64f07a9af5 fix: use ports 3003/4003 (3002 used by Drone)
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-23 16:04:40 +08:00
111
d108b168dd fix: change frontend ports to 3002/4002 (3001 used by Gitea)
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 16:01:59 +08:00
111
4e954af55c fix: use npm install instead of npm ci (no lock file)
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 15:56:36 +08:00
111
531d9522c5 fix: use China mirrors for pip and npm in Dockerfiles
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-23 15:54:22 +08:00
111
b89d5ddee9 feat: add admin UI frontend and complete backend APIs
Some checks failed
continuous-integration/drone/push Build is failing
- Add Vue 3 frontend with Element Plus
- Implement login, dashboard, tenant management
- Add app configuration, logs viewer, stats pages
- Add user management for admins
- Update Drone CI to build and deploy frontend
- Frontend ports: 3001 (test), 4001 (prod)
2026-01-23 15:51:37 +08:00
42 changed files with 4727 additions and 62 deletions

View File

@@ -10,6 +10,7 @@ trigger:
- push
steps:
# 构建后端镜像
- name: build-backend
image: docker:dind
volumes:
@@ -19,6 +20,17 @@ steps:
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
- docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest
# 构建前端镜像
- name: build-frontend
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-frontend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend .
- docker tag platform-frontend:${DRONE_COMMIT_SHA:0:8} platform-frontend:latest
# 部署测试环境
- name: deploy-test
image: docker:dind
volumes:
@@ -34,13 +46,15 @@ steps:
CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key
commands:
- docker stop platform-backend-test || true
- docker rm platform-backend-test || true
- docker stop platform-backend-test platform-frontend-test || true
- docker rm platform-backend-test platform-frontend-test || true
- docker run -d --name platform-backend-test -p 8001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
- docker run -d --name platform-frontend-test -p 3003:80 --restart unless-stopped platform-frontend:latest
when:
branch:
- develop
# 部署生产环境
- name: deploy-prod
image: docker:dind
volumes:
@@ -56,9 +70,10 @@ steps:
CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key
commands:
- docker stop platform-backend-prod || true
- docker rm platform-backend-prod || true
- docker stop platform-backend-prod platform-frontend-prod || true
- docker rm platform-backend-prod platform-frontend-prod || true
- docker run -d --name platform-backend-prod -p 9001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
- docker run -d --name platform-frontend-prod -p 4003:80 --restart unless-stopped platform-frontend:latest
when:
branch:
- main

2
.gitignore vendored
View File

@@ -13,7 +13,7 @@ venv/
*.swp
*.swo
*.log
logs/
/logs/
.DS_Store
Thumbs.db
dist/

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

View File

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

26
backend/app/models/app.py Normal file
View File

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

@@ -0,0 +1,28 @@
"""租户应用配置模型"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, Integer, String, Text, SmallInteger, TIMESTAMP
from ..database import Base
class TenantApp(Base):
"""租户应用配置表"""
__tablename__ = "platform_tenant_apps"
id = Column(Integer, primary_key=True, autoincrement=True)
tenant_id = Column(String(50), nullable=False)
app_code = Column(String(50), nullable=False, default='tools')
app_name = Column(String(100))
# 企业微信配置(关联 platform_tenant_wechat_apps
wechat_app_id = Column(Integer) # 关联的企微应用ID
# 鉴权配置
access_token = Column(String(64)) # 访问令牌(长期有效)
allowed_origins = Column(Text) # JSON 数组
# 功能权限
allowed_tools = Column(Text) # JSON 数组
status = Column(SmallInteger, default=1)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

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

@@ -0,0 +1,19 @@
"""用户模型"""
from datetime import datetime
from sqlalchemy import Column, BigInteger, String, Enum, TIMESTAMP, SmallInteger
from ..database import Base
class User(Base):
"""用户表"""
__tablename__ = "platform_users"
id = Column(BigInteger, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
nickname = Column(String(100))
role = Column(Enum('admin', 'operator', 'viewer'), default='viewer')
status = Column(SmallInteger, default=1) # 1=启用, 0=禁用
last_login_at = Column(TIMESTAMP, nullable=True)
created_at = Column(TIMESTAMP, default=datetime.now)
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)

275
backend/app/routers/apps.py Normal file
View File

@@ -0,0 +1,275 @@
"""应用管理路由"""
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.app import App
from ..models.tenant_app import TenantApp
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/apps", tags=["应用管理"])
# ============ Schemas ============
class ToolItem(BaseModel):
"""工具项"""
code: str
name: str
path: str
class AppCreate(BaseModel):
"""创建应用"""
app_code: str
app_name: str
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
require_jssdk: bool = False
class AppUpdate(BaseModel):
"""更新应用"""
app_name: Optional[str] = None
base_url: Optional[str] = None
description: Optional[str] = None
tools: Optional[List[ToolItem]] = None
require_jssdk: Optional[bool] = None
status: Optional[int] = None
class GenerateUrlRequest(BaseModel):
"""生成链接请求"""
tenant_id: str
app_code: str
tool_code: Optional[str] = None # 不传则生成应用首页链接
# ============ API Endpoints ============
@router.get("")
async def list_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[int] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用列表"""
query = db.query(App)
if status is not None:
query = query.filter(App.status == status)
total = query.count()
apps = query.order_by(App.id.asc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_app(app) for app in apps]
}
@router.get("/all")
async def list_all_apps(
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取所有启用的应用(用于下拉选择)"""
apps = db.query(App).filter(App.status == 1).order_by(App.id.asc()).all()
return [{"app_code": app.app_code, "app_name": app.app_name} for app in apps]
@router.get("/{app_id}")
async def get_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用详情"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
return format_app(app)
@router.post("")
async def create_app(
data: AppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用"""
# 检查 app_code 是否重复
exists = db.query(App).filter(App.app_code == data.app_code).first()
if exists:
raise HTTPException(status_code=400, detail="应用代码已存在")
app = App(
app_code=data.app_code,
app_name=data.app_name,
base_url=data.base_url,
description=data.description,
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
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id}
@router.put("/{app_id}")
async def update_app(
app_id: int,
data: AppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新应用"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 tools JSON
if 'tools' in update_data:
if update_data['tools']:
update_data['tools'] = json.dumps([t.model_dump() if hasattr(t, 'model_dump') else t for t in update_data['tools']], ensure_ascii=False)
else:
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():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除应用"""
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查是否有租户在使用
tenant_count = db.query(TenantApp).filter(TenantApp.app_code == app.app_code).count()
if tenant_count > 0:
raise HTTPException(status_code=400, detail=f"{tenant_count} 个租户正在使用此应用,无法删除")
db.delete(app)
db.commit()
return {"success": True}
@router.post("/generate-url")
async def generate_url(
data: GenerateUrlRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
生成访问链接
返回完整的可直接使用的 URL使用静态 token长期有效
"""
# 获取应用信息
app = db.query(App).filter(App.app_code == data.app_code, App.status == 1).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在或已禁用")
if not app.base_url:
raise HTTPException(status_code=400, detail="应用未配置基础URL")
# 获取租户配置
tenant_app = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
TenantApp.app_code == data.app_code,
TenantApp.status == 1
).first()
if not tenant_app:
raise HTTPException(status_code=404, detail="租户未配置此应用")
if not tenant_app.access_token:
raise HTTPException(status_code=400, detail="租户应用未配置访问令牌")
# 构建基础 URL
base_url = app.base_url.rstrip('/')
if data.tool_code:
# 查找工具路径
tools = json.loads(app.tools) if app.tools else []
tool = next((t for t in tools if t.get('code') == data.tool_code), None)
if tool:
base_url = f"{base_url}{tool.get('path', '')}"
else:
base_url = f"{base_url}/{data.tool_code}"
# 构建参数(静态 token长期有效
params = {
"tid": data.tenant_id,
"token": tenant_app.access_token
}
# 组装 URL
query_string = "&".join([f"{k}={v}" for k, v in params.items()])
full_url = f"{base_url}?{query_string}"
return {
"success": True,
"url": full_url,
"params": params,
"note": "静态链接,长期有效"
}
@router.get("/{app_code}/tools")
async def get_app_tools(
app_code: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用的工具列表(用于配置权限时选择)"""
app = db.query(App).filter(App.app_code == app_code).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
tools = json.loads(app.tools) if app.tools else []
return tools
def format_app(app: App) -> dict:
"""格式化应用数据"""
return {
"id": app.id,
"app_code": app.app_code,
"app_name": app.app_name,
"base_url": app.base_url,
"description": app.description,
"tools": json.loads(app.tools) if app.tools else [],
"require_jssdk": bool(app.require_jssdk),
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}

223
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,223 @@
"""认证路由"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth import (
authenticate_user,
create_access_token,
decode_token,
update_last_login,
hash_password,
TokenData,
UserInfo
)
from ..models.user import User
router = APIRouter(prefix="/auth", tags=["认证"])
security = HTTPBearer()
class LoginRequest(BaseModel):
"""登录请求"""
username: str
password: str
class LoginResponse(BaseModel):
"""登录响应"""
success: bool
token: Optional[str] = None
user: Optional[UserInfo] = None
error: Optional[str] = None
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str
new_password: str
# 权限依赖
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""获取当前用户"""
token = credentials.credentials
token_data = decode_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效或已过期"
)
user = db.query(User).filter(User.id == token_data.user_id).first()
if not user or user.status != 1:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已禁用"
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""要求管理员权限"""
if user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return user
async def require_operator(user: User = Depends(get_current_user)) -> User:
"""要求操作员以上权限"""
if user.role not in ('admin', 'operator'):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要操作员以上权限"
)
return user
# API 端点
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用户登录"""
user = authenticate_user(db, request.username, request.password)
if not user:
return LoginResponse(success=False, error="用户名或密码错误")
# 更新登录时间
update_last_login(db, user.id)
# 生成 Token
token = create_access_token({
"user_id": user.id,
"username": user.username,
"role": user.role
})
return LoginResponse(
success=True,
token=token,
user=UserInfo(
id=user.id,
username=user.username,
nickname=user.nickname,
role=user.role
)
)
@router.get("/me", response_model=UserInfo)
async def get_me(user: User = Depends(get_current_user)):
"""获取当前用户信息"""
return UserInfo(
id=user.id,
username=user.username,
nickname=user.nickname,
role=user.role
)
@router.post("/change-password")
async def change_password(
request: ChangePasswordRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""修改密码"""
from ..services.auth import verify_password
if not verify_password(request.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
new_hash = hash_password(request.new_password)
db.query(User).filter(User.id == user.id).update({"password_hash": new_hash})
db.commit()
return {"success": True, "message": "密码修改成功"}
@router.get("/users")
async def list_users(
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取用户列表(仅管理员)"""
users = db.query(User).all()
return [
{
"id": u.id,
"username": u.username,
"nickname": u.nickname,
"role": u.role,
"status": u.status,
"last_login_at": u.last_login_at,
"created_at": u.created_at
}
for u in users
]
class CreateUserRequest(BaseModel):
username: str
password: str
nickname: Optional[str] = None
role: str = "viewer"
@router.post("/users")
async def create_user(
request: CreateUserRequest,
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""创建用户(仅管理员)"""
# 检查用户名是否存在
exists = db.query(User).filter(User.username == request.username).first()
if exists:
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
username=request.username,
password_hash=hash_password(request.password),
nickname=request.nickname,
role=request.role,
status=1
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {"success": True, "id": new_user.id}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
user: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除用户(仅管理员)"""
if user_id == user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
db.delete(target)
db.commit()
return {"success": True}

View File

@@ -1,16 +1,27 @@
"""日志路由"""
from fastapi import APIRouter, Depends, Header, HTTPException
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from ..database import get_db
from ..config import get_settings
from ..models.logs import PlatformLog
from ..schemas.logs import LogCreate, LogResponse, BatchLogRequest
from ..services.auth import decode_token
router = APIRouter(prefix="/api/logs", tags=["logs"])
router = APIRouter(prefix="/logs", tags=["logs"])
settings = get_settings()
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
return decode_token(token)
return None
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
"""验证API Key"""
if x_api_key != settings.API_KEY:
@@ -43,3 +54,62 @@ async def batch_write_logs(
db.add_all(logs)
db.commit()
return {"success": True, "count": len(logs)}
@router.get("")
async def query_logs(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
log_type: Optional[str] = None,
level: Optional[str] = None,
app_code: Optional[str] = None,
tenant_id: Optional[str] = None,
trace_id: Optional[str] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""查询日志列表"""
query = db.query(PlatformLog)
if log_type:
query = query.filter(PlatformLog.log_type == log_type)
if level:
query = query.filter(PlatformLog.level == level)
if app_code:
query = query.filter(PlatformLog.app_code == app_code)
if tenant_id:
query = query.filter(PlatformLog.tenant_id == tenant_id)
if trace_id:
query = query.filter(PlatformLog.trace_id == trace_id)
if keyword:
query = query.filter(PlatformLog.message.like(f"%{keyword}%"))
total = query.count()
items = query.order_by(desc(PlatformLog.log_time)).offset((page-1)*size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [
{
"id": item.id,
"log_type": item.log_type,
"level": item.level,
"app_code": item.app_code,
"tenant_id": item.tenant_id,
"trace_id": item.trace_id,
"message": item.message,
"path": item.path,
"method": item.method,
"status_code": item.status_code,
"duration_ms": item.duration_ms,
"ip_address": item.ip_address,
"extra_data": item.extra_data,
"stack_trace": item.stack_trace,
"log_time": str(item.log_time) if item.log_time else None
}
for item in items
]
}

View File

@@ -1,16 +1,28 @@
"""统计上报路由"""
from fastapi import APIRouter, Depends, Header, HTTPException
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..config import get_settings
from ..models.stats import AICallEvent
from ..schemas.stats import AICallEventCreate, AICallEventResponse, BatchReportRequest
from ..services.auth import decode_token
router = APIRouter(prefix="/api/stats", tags=["stats"])
router = APIRouter(prefix="/stats", tags=["stats"])
settings = get_settings()
def get_current_user_optional(authorization: Optional[str] = Header(None)):
"""可选的用户认证"""
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
return decode_token(token)
return None
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
"""验证API Key"""
if x_api_key != settings.API_KEY:
@@ -43,3 +55,79 @@ async def batch_report_ai_calls(
db.add_all(events)
db.commit()
return {"success": True, "count": len(events)}
@router.get("/summary")
async def get_stats_summary(
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取统计摘要(用于仪表盘)"""
today = datetime.now().date()
# 今日调用次数和 token 消耗
today_stats = db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) == today
).first()
# 本周数据
week_start = today - timedelta(days=today.weekday())
week_stats = db.query(
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) >= week_start
).first()
return {
"today_calls": today_stats.calls if today_stats else 0,
"today_tokens": int(today_stats.tokens) if today_stats else 0,
"week_calls": week_stats.calls if week_stats else 0,
"week_tokens": int(week_stats.tokens) if week_stats else 0
}
@router.get("/trend")
async def get_stats_trend(
days: int = Query(7, ge=1, le=30),
tenant_id: Optional[str] = None,
db: Session = Depends(get_db),
user = Depends(get_current_user_optional)
):
"""获取调用趋势数据"""
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days-1)
query = db.query(
func.date(AICallEvent.created_at).label('date'),
func.count(AICallEvent.id).label('calls'),
func.coalesce(func.sum(AICallEvent.input_tokens + AICallEvent.output_tokens), 0).label('tokens')
).filter(
func.date(AICallEvent.created_at) >= start_date,
func.date(AICallEvent.created_at) <= end_date
)
if tenant_id:
query = query.filter(AICallEvent.tenant_id == tenant_id)
results = query.group_by(func.date(AICallEvent.created_at)).all()
# 转换为字典便于查找
data_map = {str(r.date): {"calls": r.calls, "tokens": int(r.tokens)} for r in results}
# 填充所有日期
trend = []
current = start_date
while current <= end_date:
date_str = str(current)
trend.append({
"date": date_str,
"calls": data_map.get(date_str, {}).get("calls", 0),
"tokens": data_map.get(date_str, {}).get("tokens", 0)
})
current += timedelta(days=1)
return {"trend": trend}

View File

@@ -0,0 +1,208 @@
"""租户应用配置路由"""
import json
import secrets
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_app import TenantApp
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
# Schemas
class TenantAppCreate(BaseModel):
tenant_id: str
app_code: str = "tools"
app_name: Optional[str] = None
wechat_app_id: Optional[int] = None # 关联的企微应用ID
access_token: Optional[str] = None # 如果不传则自动生成
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
class TenantAppUpdate(BaseModel):
app_name: Optional[str] = None
wechat_app_id: Optional[int] = None # 关联的企微应用ID
access_token: Optional[str] = None
allowed_origins: Optional[List[str]] = None
allowed_tools: Optional[List[str]] = None
status: Optional[int] = None
# API Endpoints
@router.get("")
async def list_tenant_apps(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
tenant_id: Optional[str] = None,
app_code: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用配置列表"""
query = db.query(TenantApp)
if tenant_id:
query = query.filter(TenantApp.tenant_id == tenant_id)
if app_code:
query = query.filter(TenantApp.app_code == app_code)
total = query.count()
apps = query.order_by(TenantApp.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [format_tenant_app(app, mask_secret=True, db=db) for app in apps]
}
@router.get("/{app_id}")
async def get_tenant_app(
app_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取应用配置详情"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
return format_tenant_app(app, mask_secret=True, db=db)
@router.post("")
async def create_tenant_app(
data: TenantAppCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建应用配置"""
# 检查是否重复
exists = db.query(TenantApp).filter(
TenantApp.tenant_id == data.tenant_id,
TenantApp.app_code == data.app_code
).first()
if exists:
raise HTTPException(status_code=400, detail="该租户应用配置已存在")
# 自动生成 access_token
access_token = data.access_token or secrets.token_hex(32)
app = TenantApp(
tenant_id=data.tenant_id,
app_code=data.app_code,
app_name=data.app_name,
wechat_app_id=data.wechat_app_id,
access_token=access_token,
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,
status=1
)
db.add(app)
db.commit()
db.refresh(app)
return {"success": True, "id": app.id, "access_token": access_token}
@router.put("/{app_id}")
async def update_tenant_app(
app_id: int,
data: TenantAppUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新应用配置"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
update_data = data.model_dump(exclude_unset=True)
# 处理 JSON 字段
if 'allowed_origins' in update_data:
update_data['allowed_origins'] = json.dumps(update_data['allowed_origins']) if update_data['allowed_origins'] else None
if 'allowed_tools' in update_data:
update_data['allowed_tools'] = json.dumps(update_data['allowed_tools']) if update_data['allowed_tools'] else None
for key, value in update_data.items():
setattr(app, key, value)
db.commit()
return {"success": True}
@router.delete("/{app_id}")
async def delete_tenant_app(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除应用配置"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
db.delete(app)
db.commit()
return {"success": True}
@router.post("/{app_id}/regenerate-token")
async def regenerate_token(
app_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""重新生成 access_token"""
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用配置不存在")
new_token = secrets.token_hex(32)
app.access_token = new_token
db.commit()
return {"success": True, "access_token": new_token}
def format_tenant_app(app: TenantApp, mask_secret: bool = True, db: Session = None) -> 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 = {
"id": app.id,
"tenant_id": app.tenant_id,
"app_code": app.app_code,
"app_name": app.app_name,
"wechat_app_id": app.wechat_app_id,
"wechat_app": wechat_app_info,
"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_tools": json.loads(app.allowed_tools) if app.allowed_tools else [],
"status": app.status,
"created_at": app.created_at,
"updated_at": app.updated_at
}
return result

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

@@ -0,0 +1,301 @@
"""租户管理路由"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from datetime import date
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..database import get_db
from ..models.tenant import Tenant, Subscription
from ..models.stats import TenantUsageDaily
from .auth import get_current_user, require_operator
from ..models.user import User
router = APIRouter(prefix="/tenants", tags=["租户管理"])
# Schemas
class TenantCreate(BaseModel):
code: str
name: str
contact_info: Optional[dict] = None
status: str = "active"
expired_at: Optional[date] = None
class TenantUpdate(BaseModel):
name: Optional[str] = None
contact_info: Optional[dict] = None
status: Optional[str] = None
expired_at: Optional[date] = None
class SubscriptionCreate(BaseModel):
tenant_id: int
app_code: str
start_date: Optional[date] = None
end_date: Optional[date] = None
quota: Optional[dict] = None
status: str = "active"
class SubscriptionUpdate(BaseModel):
start_date: Optional[date] = None
end_date: Optional[date] = None
quota: Optional[dict] = None
status: Optional[str] = None
# API Endpoints
@router.get("")
async def list_tenants(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
keyword: Optional[str] = None,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户列表"""
query = db.query(Tenant)
if status:
query = query.filter(Tenant.status == status)
if keyword:
query = query.filter(
(Tenant.code.contains(keyword)) | (Tenant.name.contains(keyword))
)
total = query.count()
tenants = query.order_by(Tenant.id.desc()).offset((page - 1) * size).limit(size).all()
return {
"total": total,
"page": page,
"size": size,
"items": [
{
"id": t.id,
"code": t.code,
"name": t.name,
"contact_info": t.contact_info,
"status": t.status,
"expired_at": t.expired_at,
"created_at": t.created_at
}
for t in tenants
]
}
@router.get("/{tenant_id}")
async def get_tenant(
tenant_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户详情"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
# 获取订阅
subscriptions = db.query(Subscription).filter(
Subscription.tenant_id == tenant_id
).all()
# 获取用量统计最近30天
usage = db.query(
func.sum(TenantUsageDaily.ai_calls).label('total_calls'),
func.sum(TenantUsageDaily.ai_tokens).label('total_tokens'),
func.sum(TenantUsageDaily.ai_cost).label('total_cost')
).filter(
TenantUsageDaily.tenant_id == tenant_id
).first()
return {
"id": tenant.id,
"code": tenant.code,
"name": tenant.name,
"contact_info": tenant.contact_info,
"status": tenant.status,
"expired_at": tenant.expired_at,
"created_at": tenant.created_at,
"updated_at": tenant.updated_at,
"subscriptions": [
{
"id": s.id,
"app_code": s.app_code,
"start_date": s.start_date,
"end_date": s.end_date,
"quota": s.quota,
"status": s.status
}
for s in subscriptions
],
"usage_summary": {
"total_calls": int(usage.total_calls or 0),
"total_tokens": int(usage.total_tokens or 0),
"total_cost": float(usage.total_cost or 0)
}
}
@router.post("")
async def create_tenant(
data: TenantCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建租户"""
# 检查 code 是否重复
exists = db.query(Tenant).filter(Tenant.code == data.code).first()
if exists:
raise HTTPException(status_code=400, detail="租户代码已存在")
tenant = Tenant(
code=data.code,
name=data.name,
contact_info=data.contact_info,
status=data.status,
expired_at=data.expired_at
)
db.add(tenant)
db.commit()
db.refresh(tenant)
return {"success": True, "id": tenant.id}
@router.put("/{tenant_id}")
async def update_tenant(
tenant_id: int,
data: TenantUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新租户"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(tenant, key, value)
db.commit()
return {"success": True}
@router.delete("/{tenant_id}")
async def delete_tenant(
tenant_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除租户"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
# 删除关联的订阅
db.query(Subscription).filter(Subscription.tenant_id == tenant_id).delete()
db.delete(tenant)
db.commit()
return {"success": True}
# 订阅管理
@router.get("/{tenant_id}/subscriptions")
async def list_subscriptions(
tenant_id: int,
user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""获取租户订阅列表"""
subscriptions = db.query(Subscription).filter(
Subscription.tenant_id == tenant_id
).all()
return [
{
"id": s.id,
"tenant_id": s.tenant_id,
"app_code": s.app_code,
"start_date": s.start_date,
"end_date": s.end_date,
"quota": s.quota,
"status": s.status,
"created_at": s.created_at
}
for s in subscriptions
]
@router.post("/{tenant_id}/subscriptions")
async def create_subscription(
tenant_id: int,
data: SubscriptionCreate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""创建订阅"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="租户不存在")
subscription = Subscription(
tenant_id=tenant_id,
app_code=data.app_code,
start_date=data.start_date,
end_date=data.end_date,
quota=data.quota,
status=data.status
)
db.add(subscription)
db.commit()
db.refresh(subscription)
return {"success": True, "id": subscription.id}
@router.put("/subscriptions/{subscription_id}")
async def update_subscription(
subscription_id: int,
data: SubscriptionUpdate,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""更新订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(subscription, key, value)
db.commit()
return {"success": True}
@router.delete("/subscriptions/{subscription_id}")
async def delete_subscription(
subscription_id: int,
user: User = Depends(require_operator),
db: Session = Depends(get_db)
):
"""删除订阅"""
subscription = db.query(Subscription).filter(Subscription.id == subscription_id).first()
if not subscription:
raise HTTPException(status_code=404, detail="订阅不存在")
db.delete(subscription)
db.commit()
return {"success": True}

View File

@@ -0,0 +1,89 @@
"""认证服务"""
import bcrypt
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..config import get_settings
from ..models.user import User
class TokenData(BaseModel):
"""Token 数据"""
user_id: int
username: str
role: str
class UserInfo(BaseModel):
"""用户信息"""
id: int
username: str
nickname: Optional[str]
role: str
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码"""
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def hash_password(password: str) -> str:
"""哈希密码"""
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
def create_access_token(data: dict, expires_delta: timedelta = None) -> str:
"""创建 JWT Token"""
settings = get_settings()
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[TokenData]:
"""解析 JWT Token"""
settings = get_settings()
try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
return TokenData(
user_id=payload.get("user_id"),
username=payload.get("username"),
role=payload.get("role")
)
except JWTError:
return None
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
"""认证用户"""
user = db.query(User).filter(User.username == username).first()
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if user.status != 1:
return None
return user
def update_last_login(db: Session, user_id: int):
"""更新最后登录时间"""
db.query(User).filter(User.id == user_id).update(
{"last_login_at": datetime.now()}
)
db.commit()

View File

@@ -35,3 +35,8 @@ def decrypt_value(encrypted_value: str) -> str:
encrypted = base64.urlsafe_b64decode(encrypted_value.encode())
decrypted = f.decrypt(encrypted)
return decrypted.decode()
# 别名
encrypt_config = encrypt_value
decrypt_config = decrypt_value

View File

@@ -7,5 +7,6 @@ pydantic-settings>=2.0.0
cryptography>=42.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
python-multipart>=0.0.6
httpx>=0.26.0

View File

@@ -2,9 +2,9 @@ FROM python:3.11-slim
WORKDIR /app
# 安装依赖
# 安装依赖(使用阿里云镜像)
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt
# 复制代码
COPY backend/app ./app

View File

@@ -0,0 +1,21 @@
FROM node:20-alpine as builder
WORKDIR /app
# 安装依赖(使用淘宝镜像)
COPY frontend/package.json ./
RUN npm config set registry https://registry.npmmirror.com && npm install
# 构建
COPY frontend/ .
RUN npm run build
# 生产镜像
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY deploy/nginx/frontend.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Vue Router history mode
location / {
try_files $uri $uri/ /index.html;
}
# API 代理到后端
location /api/ {
proxy_pass http://172.17.0.1:8001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>平台管理后台</title>
<link rel="icon" href="data:,">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "000-platform-admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"axios": "^1.6.0",
"element-plus": "^2.5.0",
"@element-plus/icons-vue": "^2.3.0",
"echarts": "^5.4.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"sass": "^1.69.0"
}
}

23
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,23 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
import { onMounted } from 'vue'
const authStore = useAuthStore()
onMounted(() => {
// 恢复登录状态
authStore.initFromStorage()
})
</script>
<template>
<router-view />
</template>
<style>
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
}
</style>

40
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,40 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '',
timeout: 30000
})
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (error.response?.status === 403) {
ElMessage.error('没有权限执行此操作')
} else {
ElMessage.error(error.response?.data?.detail || error.message || '请求失败')
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,185 @@
// 全局样式
* {
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
background-color: #f5f7fa;
}
// 布局
.layout {
height: 100vh;
display: flex;
}
.sidebar {
width: 220px;
background: linear-gradient(180deg, #1e3a5f 0%, #0d2137 100%);
color: #fff;
flex-shrink: 0;
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.el-menu {
border: none;
background: transparent;
.el-menu-item {
color: rgba(255,255,255,0.7);
&:hover {
background: rgba(255,255,255,0.1);
}
&.is-active {
background: #409eff;
color: #fff;
}
}
}
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 60px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.breadcrumb {
font-size: 14px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
.username {
font-size: 14px;
color: #606266;
}
}
}
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
// 页面容器
.page-container {
background: #fff;
border-radius: 8px;
padding: 20px;
min-height: 100%;
}
// 页面头部
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
// 搜索栏
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
// 统计卡片
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
.stat-title {
font-size: 14px;
color: #909399;
margin-bottom: 12px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #303133;
}
.stat-trend {
font-size: 12px;
margin-top: 8px;
&.up {
color: #67c23a;
}
&.down {
color: #f56c6c;
}
}
}
// 表格
.el-table {
.cell {
padding: 8px 12px;
}
}
// 对话框
.el-dialog {
.el-dialog__body {
padding: 20px 24px;
}
}
// 状态标签
.status-active {
color: #67c23a;
}
.status-expired {
color: #f56c6c;
}
.status-trial {
color: #e6a23c;
}

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 菜单项
const menuItems = computed(() => {
const items = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
{ path: '/tenants', title: '租户管理', icon: 'OfficeBuilding' },
{ path: '/app-config', title: '应用配置', icon: 'Setting' },
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
{ path: '/logs', title: '日志查看', icon: 'Document' }
]
// 管理员才能看到用户管理
if (authStore.isAdmin) {
items.push({ path: '/users', title: '用户管理', icon: 'User' })
}
return items
})
const activeMenu = computed(() => route.path)
function handleMenuSelect(path) {
router.push(path)
}
function handleLogout() {
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
authStore.logout()
router.push('/login')
})
}
</script>
<template>
<div class="layout">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<el-icon><Platform /></el-icon>
<span style="margin-left: 8px">平台管理</span>
</div>
<el-menu
:default-active="activeMenu"
background-color="transparent"
text-color="rgba(255,255,255,0.7)"
active-text-color="#fff"
@select="handleMenuSelect"
>
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</aside>
<!-- 主内容区 -->
<div class="main-container">
<!-- 顶部栏 -->
<header class="header">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="user-info">
<span class="username">{{ authStore.user?.nickname || authStore.user?.username }}</span>
<el-dropdown trigger="click">
<el-avatar :size="32">
{{ (authStore.user?.nickname || authStore.user?.username || 'U')[0].toUpperCase() }}
</el-avatar>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 内容区 -->
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>

23
frontend/src/main.js Normal file
View File

@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './assets/styles/main.scss'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

View File

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

View File

@@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref('')
const user = ref(null)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const isOperator = computed(() => ['admin', 'operator'].includes(user.value?.role))
function initFromStorage() {
const savedToken = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (savedToken) {
token.value = savedToken
api.defaults.headers.common['Authorization'] = `Bearer ${savedToken}`
}
if (savedUser) {
try {
user.value = JSON.parse(savedUser)
} catch (e) {
// ignore
}
}
}
async function login(username, password) {
const response = await api.post('/api/auth/login', { username, password })
if (response.data.success) {
token.value = response.data.token
user.value = response.data.user
localStorage.setItem('token', token.value)
localStorage.setItem('user', JSON.stringify(user.value))
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
return true
}
throw new Error(response.data.error || '登录失败')
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
delete api.defaults.headers.common['Authorization']
}
return {
token,
user,
isLoggedIn,
isAdmin,
isOperator,
initFromStorage,
login,
logout
}
})

View File

@@ -0,0 +1,502 @@
<script setup>
import { ref, reactive, onMounted, computed } 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: '',
app_code: ''
})
// 应用列表(从应用管理获取)
const appList = ref([])
const appToolsMap = ref({}) // app_code -> tools[]
const appRequireJssdk = ref({}) // app_code -> require_jssdk
// 企微应用列表(按租户)
const wechatAppList = ref([]) // 当前表单租户的企微应用列表
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
tenant_id: '',
app_code: 'tools',
app_name: '',
wechat_app_id: null, // 关联的企微应用ID
allowed_tools: []
})
// 当前选择的应用是否需要 JS-SDK
const currentAppRequireJssdk = computed(() => {
return appRequireJssdk.value[form.app_code] || false
})
// 根据选择的应用获取工具选项
const toolOptions = computed(() => {
const tools = appToolsMap.value[form.app_code] || []
if (tools.length > 0) {
return tools.map(t => ({ label: t.name, value: t.code }))
}
// 默认工具列表(兼容旧数据)
return [
{ label: '高情商回复', value: 'high-eq' },
{ label: '头脑风暴', value: 'brainstorm' },
{ label: '面诊方案', value: 'consultation' },
{ label: '客户画像', value: 'customer-profile' },
{ label: '医疗合规', value: 'medical-compliance' }
]
})
const rules = {
tenant_id: [{ required: true, message: '请输入租户ID', trigger: 'blur' }],
app_code: [{ required: true, message: '请选择应用', trigger: 'change' }]
}
// 生成链接对话框
const urlDialogVisible = ref(false)
const urlLoading = ref(false)
const currentRow = ref(null)
const selectedTool = ref('')
const generatedUrl = ref('')
const urlInfo = ref({})
async function fetchApps() {
try {
const res = await api.get('/api/apps', { params: { size: 100 } })
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 apps) {
appRequireJssdk.value[app.app_code] = app.require_jssdk || false
try {
const toolsRes = await api.get(`/api/apps/${app.app_code}/tools`)
appToolsMap.value[app.app_code] = toolsRes.data || []
} catch (e) {
appToolsMap.value[app.app_code] = []
}
}
} catch (e) {
console.error('获取应用列表失败:', e)
}
}
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() {
loading.value = true
try {
const res = await api.get('/api/tenant-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: '',
app_code: 'tools',
app_name: '',
wechat_app_id: null,
allowed_tools: []
})
wechatAppList.value = []
dialogVisible.value = true
}
async function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑应用配置'
Object.assign(form, {
tenant_id: row.tenant_id,
app_code: row.app_code,
app_name: row.app_name || '',
wechat_app_id: row.wechat_app_id || null,
allowed_tools: row.allowed_tools || []
})
// 获取该租户的企微应用列表
await fetchWechatApps(row.tenant_id)
dialogVisible.value = true
}
// 租户ID变化时重新获取企微应用列表
async function handleTenantChange() {
form.wechat_app_id = null
await fetchWechatApps(form.tenant_id)
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
try {
if (editingId.value) {
await api.put(`/api/tenant-apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
const res = await api.post('/api/tenant-apps', data)
ElMessage.success(`创建成功Access Token: ${res.data.access_token}`)
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除此配置吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenant-apps/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleRegenerateToken(row) {
await ElMessageBox.confirm('重新生成 Access Token 将使旧的链接失效,确定继续?', '提示', {
type: 'warning'
})
try {
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
ElMessage.success(`新 Access Token: ${res.data.access_token}`)
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
// 生成链接功能
function handleShowUrl(row) {
currentRow.value = row
selectedTool.value = ''
generatedUrl.value = ''
urlInfo.value = {}
urlDialogVisible.value = true
}
async function handleGenerateUrl() {
if (!currentRow.value) return
urlLoading.value = true
try {
const res = await api.post('/api/apps/generate-url', {
tenant_id: currentRow.value.tenant_id,
app_code: currentRow.value.app_code,
tool_code: selectedTool.value || null
})
if (res.data.success) {
generatedUrl.value = res.data.url
urlInfo.value = res.data
} else {
ElMessage.error(res.data.error || '生成失败')
}
} catch (e) {
console.error(e)
} finally {
urlLoading.value = false
}
}
function handleCopyUrl() {
if (!generatedUrl.value) return
navigator.clipboard.writeText(generatedUrl.value).then(() => {
ElMessage.success('链接已复制到剪贴板')
}).catch(() => {
// 降级方案
const input = document.createElement('input')
input.value = generatedUrl.value
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
ElMessage.success('链接已复制到剪贴板')
})
}
// 获取当前行可选的工具
const currentToolOptions = computed(() => {
if (!currentRow.value) return []
const appTools = appToolsMap.value[currentRow.value.app_code] || []
const allowedTools = currentRow.value.allowed_tools || []
if (appTools.length > 0) {
// 过滤出允许的工具
if (allowedTools.length > 0) {
return appTools.filter(t => allowedTools.includes(t.code)).map(t => ({ label: t.name, value: t.code }))
}
return appTools.map(t => ({ label: t.name, value: t.code }))
}
// 默认工具
const defaultTools = [
{ label: '高情商回复', value: 'high-eq' },
{ label: '头脑风暴', value: 'brainstorm' },
{ label: '面诊方案', value: 'consultation' },
{ label: '客户画像', value: 'customer-profile' },
{ label: '医疗合规', value: 'medical-compliance' }
]
if (allowedTools.length > 0) {
return defaultTools.filter(t => allowedTools.includes(t.value))
}
return defaultTools
})
onMounted(() => {
fetchApps()
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: 160px"
@keyup.enter="handleSearch"
/>
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px">
<el-option label="tools" value="tools" />
<el-option label="interview" value="interview" />
</el-select>
<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="app_code" label="应用" width="100" />
<el-table-column prop="app_name" label="应用名称" width="150" />
<el-table-column label="企微应用" width="180">
<template #default="{ row }">
<template v-if="row.wechat_app">
<el-tag type="success" size="small">{{ row.wechat_app.name }}</el-tag>
</template>
<el-tag v-else type="info" size="small">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="Access Token" width="120">
<template #default="{ row }">
<el-tag v-if="row.access_token" type="success" size="small">已配置</el-tag>
<el-tag v-else type="danger" size="small">未配置</el-tag>
</template>
</el-table-column>
<el-table-column prop="allowed_tools" label="允许工具" min-width="150">
<template #default="{ row }">
<el-tag v-for="tool in (row.allowed_tools || []).slice(0, 3)" :key="tool" size="small" style="margin-right: 4px">
{{ tool }}
</el-tag>
<span v-if="(row.allowed_tools || []).length > 3">...</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<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="info" link size="small" @click="handleRegenerateToken(row)">重置Token</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="600px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="租户ID" prop="tenant_id">
<el-input
v-model="form.tenant_id"
:disabled="!!editingId"
placeholder="如: qiqi"
@blur="handleTenantChange"
/>
</el-form-item>
<el-form-item label="应用" prop="app_code">
<el-select v-model="form.app_code" :disabled="!!editingId" placeholder="选择应用" style="width: 100%">
<el-option v-for="app in appList" :key="app.app_code" :label="app.app_name" :value="app.app_code" />
<el-option label="tools (默认)" value="tools" />
</el-select>
</el-form-item>
<el-form-item label="配置名称">
<el-input v-model="form.app_name" placeholder="显示名称(可选)" />
</el-form-item>
<el-divider v-if="currentAppRequireJssdk" content-position="left">企业微信关联</el-divider>
<el-form-item v-if="currentAppRequireJssdk" label="关联企微应用">
<el-select
v-model="form.wechat_app_id"
placeholder="选择企微应用"
clearable
style="width: 100%"
>
<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-divider content-position="left">权限配置</el-divider>
<el-form-item label="允许的工具">
<el-checkbox-group v-model="form.allowed_tools">
<el-checkbox v-for="opt in toolOptions" :key="opt.value" :label="opt.value">{{ opt.label }}</el-checkbox>
</el-checkbox-group>
</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>
<!-- 生成链接对话框 -->
<el-dialog v-model="urlDialogVisible" title="生成访问链接" width="650px">
<div v-if="currentRow" class="url-dialog-content">
<el-descriptions :column="2" border size="small" style="margin-bottom: 20px">
<el-descriptions-item label="租户ID">{{ currentRow.tenant_id }}</el-descriptions-item>
<el-descriptions-item label="应用">{{ currentRow.app_code }}</el-descriptions-item>
<el-descriptions-item label="Access Token">
<el-tag v-if="currentRow.access_token" type="success" size="small">已配置</el-tag>
<el-tag v-else type="danger" size="small">未配置</el-tag>
</el-descriptions-item>
<el-descriptions-item label="允许工具">
{{ (currentRow.allowed_tools || []).length > 0 ? currentRow.allowed_tools.join(', ') : '全部' }}
</el-descriptions-item>
</el-descriptions>
<el-form label-width="80px">
<el-form-item label="选择工具">
<el-select v-model="selectedTool" placeholder="选择工具(留空则生成首页链接)" clearable style="width: 100%">
<el-option v-for="opt in currentToolOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="urlLoading" @click="handleGenerateUrl">
生成链接
</el-button>
</el-form-item>
</el-form>
<div v-if="generatedUrl" class="url-result">
<el-divider content-position="left">生成结果</el-divider>
<el-alert
type="success"
:title="urlInfo.note || '静态链接,长期有效'"
:closable="false"
style="margin-bottom: 12px"
/>
<div class="url-box">
<el-input
v-model="generatedUrl"
type="textarea"
:rows="3"
readonly
/>
<el-button type="primary" style="margin-top: 10px" @click="handleCopyUrl">
<el-icon><CopyDocument /></el-icon>
复制链接
</el-button>
</div>
</div>
</div>
<template #footer>
<el-button @click="urlDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.url-dialog-content {
padding: 0 10px;
}
.url-result {
margin-top: 10px;
}
.url-box {
background: #f5f7fa;
padding: 15px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,316 @@
<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
})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
app_code: '',
app_name: '',
base_url: '',
description: '',
require_jssdk: false,
tools: []
})
const rules = {
app_code: [{ required: true, message: '请输入应用代码', trigger: 'blur' }],
app_name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }]
}
// 工具编辑
const toolDialogVisible = ref(false)
const editingToolIndex = ref(-1)
const toolForm = reactive({
code: '',
name: '',
path: ''
})
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/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, {
app_code: '',
app_name: '',
base_url: '',
description: '',
require_jssdk: false,
tools: []
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑应用'
Object.assign(form, {
app_code: row.app_code,
app_name: row.app_name,
base_url: row.base_url || '',
description: row.description || '',
require_jssdk: row.require_jssdk || false,
tools: row.tools || []
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
const data = { ...form }
try {
if (editingId.value) {
await api.put(`/api/apps/${editingId.value}`, data)
ElMessage.success('更新成功')
} else {
await api.post('/api/apps', data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除应用 "${row.app_name}" 吗?`, '提示', {
type: 'warning'
})
try {
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 })
ElMessage.success(newStatus === 1 ? '已启用' : '已禁用')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
// 工具管理
function handleAddTool() {
editingToolIndex.value = -1
Object.assign(toolForm, { code: '', name: '', path: '' })
toolDialogVisible.value = true
}
function handleEditTool(index) {
editingToolIndex.value = index
const tool = form.tools[index]
Object.assign(toolForm, { ...tool })
toolDialogVisible.value = true
}
function handleDeleteTool(index) {
form.tools.splice(index, 1)
}
function handleSaveTool() {
if (!toolForm.code || !toolForm.name) {
ElMessage.warning('请填写工具代码和名称')
return
}
const tool = { ...toolForm }
if (editingToolIndex.value >= 0) {
form.tools[editingToolIndex.value] = tool
} else {
form.tools.push(tool)
}
toolDialogVisible.value = false
}
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="page-tip">
<el-alert type="info" :closable="false">
应用管理定义可供租户使用的应用配置应用的基础URL和工具列表
租户配置中选择应用后即可生成带签名的访问链接
</el-alert>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<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-column prop="base_url" label="基础URL" min-width="250" show-overflow-tooltip />
<el-table-column label="工具数量" width="100">
<template #default="{ row }">
<el-tag size="small">{{ (row.tools || []).length }} </el-tag>
</template>
</el-table-column>
<el-table-column label="JS-SDK" width="90">
<template #default="{ row }">
<el-tag :type="row.require_jssdk ? 'warning' : 'info'" size="small">
{{ row.require_jssdk ? '需要' : '不需要' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<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="row.status === 1 ? 'warning' : 'success'" link size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '禁用' : '启用' }}
</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="700px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="应用代码" prop="app_code">
<el-input v-model="form.app_code" :disabled="!!editingId" placeholder="唯一标识,如: tools" />
</el-form-item>
<el-form-item label="应用名称" prop="app_name">
<el-input v-model="form.app_name" placeholder="显示名称" />
</el-form-item>
<el-form-item label="基础URL">
<el-input v-model="form.base_url" placeholder="如: https://tools.test.ai.ireborn.com.cn" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="应用描述" />
</el-form-item>
<el-form-item label="企微JS-SDK">
<el-switch v-model="form.require_jssdk" />
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后租户需关联企微应用才能使用</span>
</el-form-item>
<el-divider content-position="left">工具列表</el-divider>
<el-form-item label="工具">
<div style="width: 100%">
<el-table :data="form.tools" size="small" border style="margin-bottom: 10px">
<el-table-column prop="code" label="代码" width="120" />
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="path" label="路径" />
<el-table-column label="操作" width="120">
<template #default="{ $index }">
<el-button type="primary" link size="small" @click="handleEditTool($index)">编辑</el-button>
<el-button type="danger" link size="small" @click="handleDeleteTool($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-button type="primary" size="small" @click="handleAddTool">
<el-icon><Plus /></el-icon>
添加工具
</el-button>
</div>
</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>
<!-- 工具编辑对话框 -->
<el-dialog v-model="toolDialogVisible" :title="editingToolIndex >= 0 ? '编辑工具' : '添加工具'" width="400px">
<el-form :model="toolForm" label-width="80px">
<el-form-item label="代码">
<el-input v-model="toolForm.code" placeholder="如: brainstorm" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="toolForm.name" placeholder="如: 头脑风暴" />
</el-form-item>
<el-form-item label="路径">
<el-input v-model="toolForm.path" placeholder="如: /brainstorm" />
</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,175 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
const stats = ref({
totalTenants: 0,
activeTenants: 0,
todayCalls: 0,
todayTokens: 0
})
const recentLogs = ref([])
const chartRef = ref(null)
let chartInstance = null
async function fetchStats() {
try {
// 获取租户统计
const tenantsRes = await api.get('/api/tenants', { params: { size: 1 } })
stats.value.totalTenants = tenantsRes.data.total || 0
// 获取统计数据
const statsRes = await api.get('/api/stats/summary')
if (statsRes.data) {
stats.value.todayCalls = statsRes.data.today_calls || 0
stats.value.todayTokens = statsRes.data.today_tokens || 0
}
} catch (e) {
console.error('获取统计失败:', e)
}
}
async function fetchRecentLogs() {
try {
const res = await api.get('/api/logs', { params: { size: 10, log_type: 'request' } })
recentLogs.value = res.data.items || []
} catch (e) {
console.error('获取日志失败:', e)
}
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
const option = {
title: {
text: '近7天 AI 调用趋势',
textStyle: { fontSize: 14, fontWeight: 500 }
},
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
},
yAxis: {
type: 'value'
},
series: [
{
name: '调用次数',
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
])
},
lineStyle: { color: '#409eff' },
itemStyle: { color: '#409eff' },
data: [120, 132, 101, 134, 90, 230, 210]
}
]
}
chartInstance.setOption(option)
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
fetchStats()
fetchRecentLogs()
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">租户总数</div>
<div class="stat-value">{{ stats.totalTenants }}</div>
</div>
<div class="stat-card">
<div class="stat-title">活跃租户</div>
<div class="stat-value">{{ stats.activeTenants || '-' }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 AI 调用</div>
<div class="stat-value">{{ stats.todayCalls }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 Token 消耗</div>
<div class="stat-value">{{ stats.todayTokens.toLocaleString() }}</div>
</div>
</div>
<!-- 图表区域 -->
<div class="chart-section">
<div class="chart-container" ref="chartRef"></div>
</div>
<!-- 最近日志 -->
<div class="page-container" style="margin-top: 20px">
<div class="page-header">
<div class="title">最近请求日志</div>
</div>
<el-table :data="recentLogs" style="width: 100%" size="small">
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" />
<el-table-column prop="status_code" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="100">
<template #default="{ row }">
{{ row.duration_ms }}ms
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
</el-table>
</div>
</div>
</template>
<style lang="scss" scoped>
.dashboard {
.chart-section {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.chart-container {
height: 300px;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const formRef = ref(null)
async function handleLogin() {
await formRef.value.validate()
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>平台管理后台</h1>
<p>统一管理租户应用与数据</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
size="large"
@submit.prevent="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>默认账号: admin / admin123</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
h1 {
font-size: 24px;
color: #303133;
margin: 0 0 8px;
}
p {
font-size: 14px;
color: #909399;
margin: 0;
}
}
.login-footer {
text-align: center;
margin-top: 20px;
p {
font-size: 12px;
color: #c0c4cc;
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '@/api'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
log_type: '',
level: '',
app_code: '',
trace_id: '',
keyword: ''
})
// 详情对话框
const detailVisible = ref(false)
const currentLog = ref(null)
async function fetchList() {
loading.value = true
try {
const params = { ...query }
// 移除空值
Object.keys(params).forEach(key => {
if (params[key] === '') delete params[key]
})
const res = await api.get('/api/logs', { params })
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 handleReset() {
Object.assign(query, {
page: 1,
size: 20,
log_type: '',
level: '',
app_code: '',
trace_id: '',
keyword: ''
})
fetchList()
}
function handlePageChange(page) {
query.page = page
fetchList()
}
function showDetail(row) {
currentLog.value = row
detailVisible.value = true
}
function getLevelType(level) {
const map = {
debug: 'info',
info: 'success',
warning: 'warning',
error: 'danger'
}
return map[level] || 'info'
}
function getLogTypeText(type) {
const map = {
request: '请求日志',
error: '错误日志',
app: '应用日志',
biz: '业务日志',
audit: '审计日志'
}
return map[type] || type
}
function formatJson(obj) {
if (!obj) return ''
try {
if (typeof obj === 'string') {
obj = JSON.parse(obj)
}
return JSON.stringify(obj, null, 2)
} catch {
return String(obj)
}
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">日志查看</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-select v-model="query.log_type" placeholder="日志类型" clearable style="width: 120px">
<el-option label="请求日志" value="request" />
<el-option label="错误日志" value="error" />
<el-option label="应用日志" value="app" />
<el-option label="业务日志" value="biz" />
<el-option label="审计日志" value="audit" />
</el-select>
<el-select v-model="query.level" placeholder="级别" clearable style="width: 100px">
<el-option label="DEBUG" value="debug" />
<el-option label="INFO" value="info" />
<el-option label="WARNING" value="warning" />
<el-option label="ERROR" value="error" />
</el-select>
<el-input
v-model="query.app_code"
placeholder="应用代码"
clearable
style="width: 120px"
/>
<el-input
v-model="query.trace_id"
placeholder="Trace ID"
clearable
style="width: 200px"
/>
<el-input
v-model="query.keyword"
placeholder="关键词搜索"
clearable
style="width: 180px"
@keyup.enter="handleSearch"
/>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="log_type" label="类型" width="100">
<template #default="{ row }">
{{ getLogTypeText(row.log_type) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)" size="small">
{{ row.level?.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="message" label="消息" min-width="250" show-overflow-tooltip />
<el-table-column prop="trace_id" label="Trace ID" width="140" show-overflow-tooltip />
<el-table-column prop="path" label="路径" width="150" show-overflow-tooltip />
<el-table-column prop="status_code" label="状态码" width="80">
<template #default="{ row }">
<el-tag v-if="row.status_code" :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="80">
<template #default="{ row }">
{{ row.duration_ms ? row.duration_ms + 'ms' : '-' }}
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(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="detailVisible" title="日志详情" width="700px">
<template v-if="currentLog">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ getLogTypeText(currentLog.log_type) }}</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag :type="getLevelType(currentLog.level)" size="small">
{{ currentLog.level?.toUpperCase() }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="应用">{{ currentLog.app_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="租户">{{ currentLog.tenant_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="Trace ID">{{ currentLog.trace_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">{{ currentLog.path || '-' }}</el-descriptions-item>
<el-descriptions-item label="方法">{{ currentLog.method || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态码">{{ currentLog.status_code || '-' }}</el-descriptions-item>
<el-descriptions-item label="耗时">{{ currentLog.duration_ms ? currentLog.duration_ms + 'ms' : '-' }}</el-descriptions-item>
<el-descriptions-item label="IP">{{ currentLog.ip_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="时间" :span="2">{{ currentLog.log_time }}</el-descriptions-item>
<el-descriptions-item label="消息" :span="2">{{ currentLog.message || '-' }}</el-descriptions-item>
</el-descriptions>
<div v-if="currentLog.extra_data" style="margin-top: 16px">
<div style="font-weight: 500; margin-bottom: 8px">附加数据:</div>
<pre style="background: #f5f7fa; padding: 12px; border-radius: 4px; overflow: auto; max-height: 300px">{{ formatJson(currentLog.extra_data) }}</pre>
</div>
<div v-if="currentLog.stack_trace" style="margin-top: 16px">
<div style="font-weight: 500; margin-bottom: 8px">堆栈信息:</div>
<pre style="background: #fef0f0; color: #f56c6c; padding: 12px; border-radius: 4px; overflow: auto; max-height: 300px">{{ currentLog.stack_trace }}</pre>
</div>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,181 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
import dayjs from 'dayjs'
const loading = ref(false)
const query = reactive({
tenant_id: '',
app_code: '',
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD')
})
const stats = ref({
total_calls: 0,
total_tokens: 0,
total_cost: 0
})
const dailyData = ref([])
const chartRef = ref(null)
let chartInstance = null
async function fetchStats() {
loading.value = true
try {
const res = await api.get('/api/stats/daily', { params: query })
dailyData.value = res.data.items || []
// 计算汇总
let totalCalls = 0, totalTokens = 0, totalCost = 0
dailyData.value.forEach(item => {
totalCalls += item.ai_calls || 0
totalTokens += item.ai_tokens || 0
totalCost += parseFloat(item.ai_cost) || 0
})
stats.value = { total_calls: totalCalls, total_tokens: totalTokens, total_cost: totalCost }
updateChart()
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function updateChart() {
if (!chartInstance) return
const dates = dailyData.value.map(d => d.stat_date)
const calls = dailyData.value.map(d => d.ai_calls || 0)
const tokens = dailyData.value.map(d => d.ai_tokens || 0)
chartInstance.setOption({
title: { text: 'AI 调用趋势' },
tooltip: { trigger: 'axis' },
legend: { data: ['调用次数', 'Token 消耗'], top: 30 },
grid: { left: '3%', right: '4%', bottom: '3%', top: 80, containLabel: true },
xAxis: { type: 'category', data: dates },
yAxis: [
{ type: 'value', name: '调用次数' },
{ type: 'value', name: 'Token' }
],
series: [
{
name: '调用次数',
type: 'bar',
data: calls,
itemStyle: { color: '#409eff' }
},
{
name: 'Token 消耗',
type: 'line',
yAxisIndex: 1,
data: tokens,
smooth: true,
itemStyle: { color: '#67c23a' }
}
]
})
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
}
function handleSearch() {
fetchStats()
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
initChart()
fetchStats()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">统计分析</div>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="query.tenant_id" placeholder="租户ID" clearable style="width: 160px" />
<el-select v-model="query.app_code" placeholder="应用" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="tools" value="tools" />
<el-option label="interview" value="interview" />
</el-select>
<el-date-picker
v-model="query.start_date"
type="date"
placeholder="开始日期"
value-format="YYYY-MM-DD"
style="width: 150px"
/>
<span style="color: #909399"></span>
<el-date-picker
v-model="query.end_date"
type="date"
placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 150px"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">AI 调用总次数</div>
<div class="stat-value">{{ stats.total_calls.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">Token 消耗总量</div>
<div class="stat-value">{{ stats.total_tokens.toLocaleString() }}</div>
</div>
<div class="stat-card">
<div class="stat-title">累计费用</div>
<div class="stat-value">¥{{ stats.total_cost.toFixed(2) }}</div>
</div>
</div>
<!-- 图表 -->
<div style="background: #fff; border-radius: 8px; padding: 20px; margin-bottom: 20px">
<div ref="chartRef" style="height: 350px" v-loading="loading"></div>
</div>
<!-- 数据表格 -->
<div style="background: #fff; border-radius: 8px; padding: 20px">
<h4 style="margin: 0 0 16px">日统计明细</h4>
<el-table :data="dailyData" style="width: 100%" v-loading="loading">
<el-table-column prop="stat_date" label="日期" 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="ai_calls" label="调用次数" width="120">
<template #default="{ row }">{{ (row.ai_calls || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="ai_tokens" label="Token 消耗" width="150">
<template #default="{ row }">{{ (row.ai_tokens || 0).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="ai_cost" label="费用" width="100">
<template #default="{ row }">¥{{ parseFloat(row.ai_cost || 0).toFixed(4) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</template>

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>

View File

@@ -0,0 +1,105 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
const route = useRoute()
const router = useRouter()
const tenantId = route.params.id
const loading = ref(false)
const tenant = ref(null)
async function fetchDetail() {
loading.value = true
try {
const res = await api.get(`/api/tenants/${tenantId}`)
tenant.value = res.data
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function getStatusType(status) {
const map = { active: 'success', expired: 'danger', trial: 'warning' }
return map[status] || 'info'
}
function getStatusText(status) {
const map = { active: '活跃', expired: '已过期', trial: '试用' }
return map[status] || status
}
onMounted(() => {
fetchDetail()
})
</script>
<template>
<div class="page-container" v-loading="loading">
<div class="page-header">
<div class="title">
<el-button link @click="router.back()">
<el-icon><ArrowLeft /></el-icon>
</el-button>
租户详情
</div>
</div>
<template v-if="tenant">
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border style="margin-bottom: 20px">
<el-descriptions-item label="租户ID">{{ tenant.id }}</el-descriptions-item>
<el-descriptions-item label="租户代码">{{ tenant.code }}</el-descriptions-item>
<el-descriptions-item label="租户名称">{{ tenant.name }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(tenant.status)" size="small">
{{ getStatusText(tenant.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ tenant.expired_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ tenant.created_at }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ tenant.contact_info?.contact || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ tenant.contact_info?.phone || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 用量统计 -->
<el-descriptions title="用量统计" :column="3" border style="margin-bottom: 20px">
<el-descriptions-item label="AI 调用总次数">
{{ tenant.usage_summary?.total_calls?.toLocaleString() || 0 }}
</el-descriptions-item>
<el-descriptions-item label="Token 消耗">
{{ tenant.usage_summary?.total_tokens?.toLocaleString() || 0 }}
</el-descriptions-item>
<el-descriptions-item label="累计费用">
¥{{ tenant.usage_summary?.total_cost?.toFixed(2) || '0.00' }}
</el-descriptions-item>
</el-descriptions>
<!-- 订阅信息 -->
<div style="margin-bottom: 20px">
<h4 style="margin-bottom: 12px">应用订阅</h4>
<el-table :data="tenant.subscriptions" style="width: 100%">
<el-table-column prop="app_code" label="应用" width="150" />
<el-table-column prop="start_date" label="开始日期" width="120" />
<el-table-column prop="end_date" label="结束日期" width="120" />
<el-table-column prop="quota" label="配额">
<template #default="{ row }">
{{ row.quota ? JSON.stringify(row.quota) : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '有效' : '已过期' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!tenant.subscriptions?.length" description="暂无订阅" />
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,239 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const query = reactive({
page: 1,
size: 20,
status: '',
keyword: ''
})
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('')
const editingId = ref(null)
const formRef = ref(null)
const form = reactive({
code: '',
name: '',
status: 'active',
expired_at: null,
contact_info: {
contact: '',
phone: '',
email: ''
}
})
const rules = {
code: [{ required: true, message: '请输入租户代码', trigger: 'blur' }],
name: [{ required: true, message: '请输入租户名称', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/tenants', { 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, {
code: '',
name: '',
status: 'active',
expired_at: null,
contact_info: { contact: '', phone: '', email: '' }
})
dialogVisible.value = true
}
function handleEdit(row) {
editingId.value = row.id
dialogTitle.value = '编辑租户'
Object.assign(form, {
code: row.code,
name: row.name,
status: row.status,
expired_at: row.expired_at,
contact_info: row.contact_info || { contact: '', phone: '', email: '' }
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
if (editingId.value) {
await api.put(`/api/tenants/${editingId.value}`, form)
ElMessage.success('更新成功')
} else {
await api.post('/api/tenants', form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除租户 "${row.name}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/tenants/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function handleDetail(row) {
router.push(`/tenants/${row.id}`)
}
function getStatusType(status) {
const map = { active: 'success', expired: 'danger', trial: 'warning' }
return map[status] || 'info'
}
function getStatusText(status) {
const map = { active: '活跃', expired: '已过期', trial: '试用' }
return map[status] || status
}
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.keyword"
placeholder="搜索租户代码或名称"
clearable
style="width: 240px"
@keyup.enter="handleSearch"
/>
<el-select v-model="query.status" placeholder="状态" clearable style="width: 120px">
<el-option label="活跃" value="active" />
<el-option label="已过期" value="expired" />
<el-option label="试用" value="trial" />
</el-select>
<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="80" />
<el-table-column prop="code" label="代码" width="120" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="expired_at" label="过期时间" width="120" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleDetail(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="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="租户代码" prop="code">
<el-input v-model="form.code" :disabled="!!editingId" placeholder="唯一标识" />
</el-form-item>
<el-form-item label="租户名称" prop="name">
<el-input v-model="form.name" placeholder="公司/组织名称" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="活跃" value="active" />
<el-option label="试用" value="trial" />
<el-option label="已过期" value="expired" />
</el-select>
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker v-model="form.expired_at" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="联系人">
<el-input v-model="form.contact_info.contact" placeholder="联系人姓名" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="form.contact_info.phone" placeholder="联系电话" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.contact_info.email" placeholder="邮箱地址" />
</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>

View File

@@ -0,0 +1,169 @@
<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 dialogVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref(null)
const form = reactive({
username: '',
password: '',
nickname: '',
role: 'viewer'
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function fetchList() {
loading.value = true
try {
const res = await api.get('/api/auth/users')
tableData.value = res.data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
function handleCreate() {
dialogTitle.value = '新建用户'
Object.assign(form, {
username: '',
password: '',
nickname: '',
role: 'viewer'
})
dialogVisible.value = true
}
async function handleSubmit() {
await formRef.value.validate()
try {
await api.post('/api/auth/users', form)
ElMessage.success('创建成功')
dialogVisible.value = false
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
async function handleDelete(row) {
if (row.id === authStore.user?.id) {
ElMessage.warning('不能删除当前登录用户')
return
}
await ElMessageBox.confirm(`确定删除用户 "${row.username}" 吗?`, '提示', {
type: 'warning'
})
try {
await api.delete(`/api/auth/users/${row.id}`)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
// 错误已在拦截器处理
}
}
function getRoleTag(role) {
const map = {
admin: { type: 'danger', text: '管理员' },
operator: { type: 'warning', text: '操作员' },
viewer: { type: 'info', text: '只读' }
}
return map[role] || { type: 'info', text: role }
}
onMounted(() => {
fetchList()
})
</script>
<template>
<div class="page-container">
<div class="page-header">
<div class="title">用户管理</div>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建用户
</el-button>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="tableData" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="nickname" label="昵称" width="150" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="getRoleTag(row.role).type" size="small">
{{ getRoleTag(row.role).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<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="last_login_at" label="最后登录" width="180" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
type="danger"
link
size="small"
:disabled="row.id === authStore.user?.id"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新建对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="450px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="登录用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="登录密码" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="form.nickname" placeholder="显示名称" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width: 100%">
<el-option label="管理员" value="admin" />
<el-option label="操作员" value="operator" />
<el-option label="只读" value="viewer" />
</el-select>
</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>

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
sourcemap: false
}
})