feat: add admin UI frontend and complete backend APIs
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- Add Vue 3 frontend with Element Plus - Implement login, dashboard, tenant management - Add app configuration, logs viewer, stats pages - Add user management for admins - Update Drone CI to build and deploy frontend - Frontend ports: 3001 (test), 4001 (prod)
This commit is contained in:
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 3001: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 4001: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/
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .routers import stats_router, logs_router, config_router, health_router
|
from .routers import stats_router, logs_router, config_router, health_router
|
||||||
|
from .routers.auth import router as auth_router
|
||||||
|
from .routers.tenants import router as tenants_router
|
||||||
|
from .routers.tenant_apps import router as tenant_apps_router
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -24,9 +27,12 @@ app.add_middleware(
|
|||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(auth_router, prefix="/api")
|
||||||
app.include_router(logs_router)
|
app.include_router(tenants_router, prefix="/api")
|
||||||
app.include_router(config_router)
|
app.include_router(tenant_apps_router, prefix="/api")
|
||||||
|
app.include_router(stats_router, prefix="/api")
|
||||||
|
app.include_router(logs_router, prefix="/api")
|
||||||
|
app.include_router(config_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
31
backend/app/models/tenant_app.py
Normal file
31
backend/app/models/tenant_app.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""租户应用配置模型"""
|
||||||
|
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))
|
||||||
|
|
||||||
|
# 企业微信配置
|
||||||
|
wechat_corp_id = Column(String(100))
|
||||||
|
wechat_agent_id = Column(String(50))
|
||||||
|
wechat_secret_encrypted = Column(Text)
|
||||||
|
|
||||||
|
# 鉴权配置
|
||||||
|
token_secret = Column(String(64))
|
||||||
|
token_required = Column(SmallInteger, default=0)
|
||||||
|
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)
|
||||||
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)
|
||||||
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}
|
||||||
240
backend/app/routers/tenant_apps.py
Normal file
240
backend/app/routers/tenant_apps.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""租户应用配置路由"""
|
||||||
|
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
|
||||||
|
from ..services.crypto import encrypt_config, decrypt_config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tenant-apps", tags=["应用配置"])
|
||||||
|
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
|
||||||
|
class TenantAppCreate(BaseModel):
|
||||||
|
tenant_id: str
|
||||||
|
app_code: str = "tools"
|
||||||
|
app_name: Optional[str] = None
|
||||||
|
wechat_corp_id: Optional[str] = None
|
||||||
|
wechat_agent_id: Optional[str] = None
|
||||||
|
wechat_secret: Optional[str] = None # 明文,存储时加密
|
||||||
|
token_secret: Optional[str] = None # 如果不传则自动生成
|
||||||
|
token_required: bool = False
|
||||||
|
allowed_origins: Optional[List[str]] = None
|
||||||
|
allowed_tools: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAppUpdate(BaseModel):
|
||||||
|
app_name: Optional[str] = None
|
||||||
|
wechat_corp_id: Optional[str] = None
|
||||||
|
wechat_agent_id: Optional[str] = None
|
||||||
|
wechat_secret: Optional[str] = None
|
||||||
|
token_secret: Optional[str] = None
|
||||||
|
token_required: Optional[bool] = 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) 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)
|
||||||
|
|
||||||
|
|
||||||
|
@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="该租户应用配置已存在")
|
||||||
|
|
||||||
|
# 自动生成 token_secret
|
||||||
|
token_secret = data.token_secret or secrets.token_hex(32)
|
||||||
|
|
||||||
|
# 加密 wechat_secret
|
||||||
|
wechat_secret_encrypted = None
|
||||||
|
if data.wechat_secret:
|
||||||
|
wechat_secret_encrypted = encrypt_config(data.wechat_secret)
|
||||||
|
|
||||||
|
app = TenantApp(
|
||||||
|
tenant_id=data.tenant_id,
|
||||||
|
app_code=data.app_code,
|
||||||
|
app_name=data.app_name,
|
||||||
|
wechat_corp_id=data.wechat_corp_id,
|
||||||
|
wechat_agent_id=data.wechat_agent_id,
|
||||||
|
wechat_secret_encrypted=wechat_secret_encrypted,
|
||||||
|
token_secret=token_secret,
|
||||||
|
token_required=1 if data.token_required else 0,
|
||||||
|
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, "token_secret": token_secret}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
# 处理 wechat_secret
|
||||||
|
if 'wechat_secret' in update_data:
|
||||||
|
if update_data['wechat_secret']:
|
||||||
|
app.wechat_secret_encrypted = encrypt_config(update_data['wechat_secret'])
|
||||||
|
del update_data['wechat_secret']
|
||||||
|
|
||||||
|
# 处理 JSON 字段
|
||||||
|
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
|
||||||
|
|
||||||
|
# 处理 token_required
|
||||||
|
if 'token_required' in update_data:
|
||||||
|
update_data['token_required'] = 1 if update_data['token_required'] 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_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)
|
||||||
|
):
|
||||||
|
"""重新生成 token_secret"""
|
||||||
|
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.token_secret = new_token
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "token_secret": new_token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{app_id}/wechat-secret")
|
||||||
|
async def get_wechat_secret(
|
||||||
|
app_id: int,
|
||||||
|
user: User = Depends(require_operator),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取解密的 wechat_secret(仅操作员以上)"""
|
||||||
|
app = db.query(TenantApp).filter(TenantApp.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="应用配置不存在")
|
||||||
|
|
||||||
|
secret = None
|
||||||
|
if app.wechat_secret_encrypted:
|
||||||
|
secret = decrypt_config(app.wechat_secret_encrypted)
|
||||||
|
|
||||||
|
return {"wechat_secret": secret}
|
||||||
|
|
||||||
|
|
||||||
|
def format_tenant_app(app: TenantApp, mask_secret: bool = True) -> dict:
|
||||||
|
"""格式化应用配置"""
|
||||||
|
result = {
|
||||||
|
"id": app.id,
|
||||||
|
"tenant_id": app.tenant_id,
|
||||||
|
"app_code": app.app_code,
|
||||||
|
"app_name": app.app_name,
|
||||||
|
"wechat_corp_id": app.wechat_corp_id,
|
||||||
|
"wechat_agent_id": app.wechat_agent_id,
|
||||||
|
"has_wechat_secret": bool(app.wechat_secret_encrypted),
|
||||||
|
"token_secret": "******" if mask_secret and app.token_secret else app.token_secret,
|
||||||
|
"token_required": bool(app.token_required),
|
||||||
|
"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
|
||||||
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
|
||||||
|
|||||||
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 ci
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
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')
|
||||||
86
frontend/src/router/index.js
Normal file
86
frontend/src/router/index.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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: '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
|
||||||
|
}
|
||||||
|
})
|
||||||
294
frontend/src/views/app-config/index.vue
Normal file
294
frontend/src/views/app-config/index.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<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: '',
|
||||||
|
app_code: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对话框
|
||||||
|
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_corp_id: '',
|
||||||
|
wechat_agent_id: '',
|
||||||
|
wechat_secret: '',
|
||||||
|
token_required: false,
|
||||||
|
allowed_tools: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolOptions = [
|
||||||
|
{ 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: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
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_corp_id: '',
|
||||||
|
wechat_agent_id: '',
|
||||||
|
wechat_secret: '',
|
||||||
|
token_required: false,
|
||||||
|
allowed_tools: []
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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_corp_id: row.wechat_corp_id || '',
|
||||||
|
wechat_agent_id: row.wechat_agent_id || '',
|
||||||
|
wechat_secret: '', // 不回显密钥
|
||||||
|
token_required: row.token_required,
|
||||||
|
allowed_tools: row.allowed_tools || []
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await formRef.value.validate()
|
||||||
|
|
||||||
|
const data = { ...form }
|
||||||
|
// 如果没有输入新密钥,不传这个字段
|
||||||
|
if (!data.wechat_secret) {
|
||||||
|
delete data.wechat_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`创建成功,Token Secret: ${res.data.token_secret}`)
|
||||||
|
}
|
||||||
|
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('重新生成 Token Secret 将使旧的签名失效,确定继续?', '提示', {
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/tenant-apps/${row.id}/regenerate-token`)
|
||||||
|
ElMessage.success(`新 Token Secret: ${res.data.token_secret}`)
|
||||||
|
fetchList()
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleViewSecret(row) {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/tenant-apps/${row.id}/wechat-secret`)
|
||||||
|
if (res.data.wechat_secret) {
|
||||||
|
ElMessageBox.alert(res.data.wechat_secret, '微信 Secret', {
|
||||||
|
confirmButtonText: '关闭'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ElMessage.info('未配置微信 Secret')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 错误已在拦截器处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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 prop="wechat_corp_id" label="企业ID" width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="wechat_agent_id" label="应用ID" width="100" />
|
||||||
|
<el-table-column label="微信密钥" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.has_wechat_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="Token 验证" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.token_required ? 'warning' : 'info'" size="small">
|
||||||
|
{{ row.token_required ? '必须' : '可选' }}
|
||||||
|
</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="240" 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="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="如: tenant_001" />
|
||||||
|
</el-form-item>
|
||||||
|
<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="应用名称">
|
||||||
|
<el-input v-model="form.app_name" placeholder="显示名称" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">企业微信配置</el-divider>
|
||||||
|
|
||||||
|
<el-form-item label="企业 ID">
|
||||||
|
<el-input v-model="form.wechat_corp_id" placeholder="ww开头的企业ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="应用 ID">
|
||||||
|
<el-input v-model="form.wechat_agent_id" placeholder="自建应用的 AgentId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="应用 Secret">
|
||||||
|
<el-input v-model="form.wechat_secret" type="password" show-password :placeholder="editingId ? '留空则不修改' : '应用的 Secret'" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider content-position="left">鉴权配置</el-divider>
|
||||||
|
|
||||||
|
<el-form-item label="强制 Token 验证">
|
||||||
|
<el-switch v-model="form.token_required" />
|
||||||
|
<span style="margin-left: 12px; color: #909399; font-size: 12px">开启后 URL 必须携带有效签名</span>
|
||||||
|
</el-form-item>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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>
|
||||||
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