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
|
- push
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# 构建后端镜像
|
||||||
- name: build-backend
|
- name: build-backend
|
||||||
image: docker:dind
|
image: docker:dind
|
||||||
volumes:
|
volumes:
|
||||||
@@ -19,6 +20,17 @@ steps:
|
|||||||
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
|
- 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
|
- 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
|
- name: deploy-test
|
||||||
image: docker:dind
|
image: docker:dind
|
||||||
volumes:
|
volumes:
|
||||||
@@ -34,13 +46,15 @@ steps:
|
|||||||
CONFIG_ENCRYPT_KEY:
|
CONFIG_ENCRYPT_KEY:
|
||||||
from_secret: config_encrypt_key
|
from_secret: config_encrypt_key
|
||||||
commands:
|
commands:
|
||||||
- docker stop platform-backend-test || true
|
- docker stop platform-backend-test platform-frontend-test || true
|
||||||
- docker rm platform-backend-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-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:
|
when:
|
||||||
branch:
|
branch:
|
||||||
- develop
|
- develop
|
||||||
|
|
||||||
|
# 部署生产环境
|
||||||
- name: deploy-prod
|
- name: deploy-prod
|
||||||
image: docker:dind
|
image: docker:dind
|
||||||
volumes:
|
volumes:
|
||||||
@@ -56,9 +70,10 @@ steps:
|
|||||||
CONFIG_ENCRYPT_KEY:
|
CONFIG_ENCRYPT_KEY:
|
||||||
from_secret: config_encrypt_key
|
from_secret: config_encrypt_key
|
||||||
commands:
|
commands:
|
||||||
- docker stop platform-backend-prod || true
|
- docker stop platform-backend-prod platform-frontend-prod || true
|
||||||
- docker rm platform-backend-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-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:
|
when:
|
||||||
branch:
|
branch:
|
||||||
- main
|
- main
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@ venv/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*.log
|
*.log
|
||||||
logs/
|
/logs/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
dist/
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .routers import stats_router, logs_router, config_router, health_router
|
from .routers import stats_router, logs_router, config_router, health_router
|
||||||
|
from .routers.auth import router as auth_router
|
||||||
settings = get_settings()
|
from .routers.tenants import router as tenants_router
|
||||||
|
from .routers.tenant_apps import router as tenant_apps_router
|
||||||
app = FastAPI(
|
from .routers.tenant_wechat_apps import router as tenant_wechat_apps_router
|
||||||
title=settings.APP_NAME,
|
from .routers.apps import router as apps_router
|
||||||
version=settings.APP_VERSION,
|
|
||||||
description="平台基础设施服务 - 统计/日志/配置管理"
|
settings = get_settings()
|
||||||
)
|
|
||||||
|
app = FastAPI(
|
||||||
# CORS
|
title=settings.APP_NAME,
|
||||||
app.add_middleware(
|
version=settings.APP_VERSION,
|
||||||
CORSMiddleware,
|
description="平台基础设施服务 - 统计/日志/配置管理"
|
||||||
allow_origins=["*"],
|
)
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
# CORS
|
||||||
allow_headers=["*"],
|
app.add_middleware(
|
||||||
)
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
# 注册路由
|
allow_credentials=True,
|
||||||
app.include_router(health_router)
|
allow_methods=["*"],
|
||||||
app.include_router(stats_router)
|
allow_headers=["*"],
|
||||||
app.include_router(logs_router)
|
)
|
||||||
app.include_router(config_router)
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(health_router)
|
||||||
@app.get("/")
|
app.include_router(auth_router, prefix="/api")
|
||||||
async def root():
|
app.include_router(tenants_router, prefix="/api")
|
||||||
return {
|
app.include_router(tenant_apps_router, prefix="/api")
|
||||||
"service": settings.APP_NAME,
|
app.include_router(tenant_wechat_apps_router, prefix="/api")
|
||||||
"version": settings.APP_VERSION,
|
app.include_router(apps_router, prefix="/api")
|
||||||
"docs": "/docs"
|
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 .tenant import Tenant, Subscription, Config
|
||||||
from .stats import AICallEvent, TenantUsageDaily
|
from .tenant_app import TenantApp
|
||||||
from .logs import PlatformLog
|
from .tenant_wechat_app import TenantWechatApp
|
||||||
|
from .app import App
|
||||||
__all__ = [
|
from .stats import AICallEvent, TenantUsageDaily
|
||||||
"Tenant",
|
from .logs import PlatformLog
|
||||||
"Subscription",
|
|
||||||
"Config",
|
__all__ = [
|
||||||
"AICallEvent",
|
"Tenant",
|
||||||
"TenantUsageDaily",
|
"Subscription",
|
||||||
"PlatformLog"
|
"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.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..models.logs import PlatformLog
|
from ..models.logs import PlatformLog
|
||||||
from ..schemas.logs import LogCreate, LogResponse, BatchLogRequest
|
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()
|
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")):
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
|
||||||
"""验证API Key"""
|
"""验证API Key"""
|
||||||
if x_api_key != settings.API_KEY:
|
if x_api_key != settings.API_KEY:
|
||||||
@@ -43,3 +54,62 @@ async def batch_write_logs(
|
|||||||
db.add_all(logs)
|
db.add_all(logs)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"success": True, "count": len(logs)}
|
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.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..models.stats import AICallEvent
|
from ..models.stats import AICallEvent
|
||||||
from ..schemas.stats import AICallEventCreate, AICallEventResponse, BatchReportRequest
|
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()
|
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")):
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")):
|
||||||
"""验证API Key"""
|
"""验证API Key"""
|
||||||
if x_api_key != settings.API_KEY:
|
if x_api_key != settings.API_KEY:
|
||||||
@@ -43,3 +55,79 @@ async def batch_report_ai_calls(
|
|||||||
db.add_all(events)
|
db.add_all(events)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"success": True, "count": len(events)}
|
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())
|
encrypted = base64.urlsafe_b64decode(encrypted_value.encode())
|
||||||
decrypted = f.decrypt(encrypted)
|
decrypted = f.decrypt(encrypted)
|
||||||
return decrypted.decode()
|
return decrypted.decode()
|
||||||
|
|
||||||
|
|
||||||
|
# 别名
|
||||||
|
encrypt_config = encrypt_value
|
||||||
|
decrypt_config = decrypt_value
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ pydantic-settings>=2.0.0
|
|||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
passlib[bcrypt]>=1.7.4
|
passlib[bcrypt]>=1.7.4
|
||||||
|
bcrypt>=4.0.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
httpx>=0.26.0
|
httpx>=0.26.0
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖(使用阿里云镜像)
|
||||||
COPY backend/requirements.txt .
|
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
|
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