Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4bd7c8251 | ||
|
|
f815b29c51 | ||
|
|
39f33d7ac5 | ||
|
|
2a9f62bef8 | ||
|
|
b018844078 | ||
|
|
64f07a9af5 | ||
|
|
d108b168dd | ||
|
|
4e954af55c | ||
|
|
531d9522c5 | ||
|
|
b89d5ddee9 |
23
.drone.yml
23
.drone.yml
@@ -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
2
.gitignore
vendored
@@ -13,7 +13,7 @@ venv/
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
logs/
|
||||
/logs/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
dist/
|
||||
|
||||
129
README.md
Normal file
129
README.md
Normal 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` - 环境配置详情
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
26
backend/app/models/app.py
Normal 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)
|
||||
28
backend/app/models/tenant_app.py
Normal file
28
backend/app/models/tenant_app.py
Normal 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)
|
||||
23
backend/app/models/tenant_wechat_app.py
Normal file
23
backend/app/models/tenant_wechat_app.py
Normal 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)
|
||||
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal 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
275
backend/app/routers/apps.py
Normal 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
223
backend/app/routers/auth.py
Normal 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}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
208
backend/app/routers/tenant_apps.py
Normal file
208
backend/app/routers/tenant_apps.py
Normal 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
|
||||
198
backend/app/routers/tenant_wechat_apps.py
Normal file
198
backend/app/routers/tenant_wechat_apps.py
Normal 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
|
||||
}
|
||||
301
backend/app/routers/tenants.py
Normal file
301
backend/app/routers/tenants.py
Normal 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}
|
||||
89
backend/app/services/auth.py
Normal file
89
backend/app/services/auth.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
deploy/Dockerfile.frontend
Normal file
21
deploy/Dockerfile.frontend
Normal 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;"]
|
||||
34
deploy/nginx/frontend.conf
Normal file
34
deploy/nginx/frontend.conf
Normal 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
13
frontend/index.html
Normal 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
26
frontend/package.json
Normal 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
23
frontend/src/App.vue
Normal 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
40
frontend/src/api/index.js
Normal 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
|
||||
185
frontend/src/assets/styles/main.scss
Normal file
185
frontend/src/assets/styles/main.scss
Normal 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;
|
||||
}
|
||||
105
frontend/src/components/Layout.vue
Normal file
105
frontend/src/components/Layout.vue
Normal 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
23
frontend/src/main.js
Normal 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')
|
||||
98
frontend/src/router/index.js
Normal file
98
frontend/src/router/index.js
Normal 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
|
||||
60
frontend/src/stores/auth.js
Normal file
60
frontend/src/stores/auth.js
Normal 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
|
||||
}
|
||||
})
|
||||
502
frontend/src/views/app-config/index.vue
Normal file
502
frontend/src/views/app-config/index.vue
Normal 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>
|
||||
316
frontend/src/views/apps/index.vue
Normal file
316
frontend/src/views/apps/index.vue
Normal 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>
|
||||
175
frontend/src/views/dashboard/index.vue
Normal file
175
frontend/src/views/dashboard/index.vue
Normal 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>
|
||||
137
frontend/src/views/login/index.vue
Normal file
137
frontend/src/views/login/index.vue
Normal 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>
|
||||
236
frontend/src/views/logs/index.vue
Normal file
236
frontend/src/views/logs/index.vue
Normal 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>
|
||||
181
frontend/src/views/stats/index.vue
Normal file
181
frontend/src/views/stats/index.vue
Normal 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>
|
||||
238
frontend/src/views/tenant-wechat-apps/index.vue
Normal file
238
frontend/src/views/tenant-wechat-apps/index.vue
Normal 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>
|
||||
105
frontend/src/views/tenants/detail.vue
Normal file
105
frontend/src/views/tenants/detail.vue
Normal 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>
|
||||
239
frontend/src/views/tenants/index.vue
Normal file
239
frontend/src/views/tenants/index.vue
Normal 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>
|
||||
169
frontend/src/views/users/index.vue
Normal file
169
frontend/src/views/users/index.vue
Normal 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
25
frontend/vite.config.js
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user