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
|
||||
|
||||
steps:
|
||||
# 构建后端镜像
|
||||
- name: build-backend
|
||||
image: docker:dind
|
||||
volumes:
|
||||
@@ -19,6 +20,17 @@ steps:
|
||||
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
|
||||
- docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest
|
||||
|
||||
# 构建前端镜像
|
||||
- name: build-frontend
|
||||
image: docker:dind
|
||||
volumes:
|
||||
- name: docker-sock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- docker build -t platform-frontend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend .
|
||||
- docker tag platform-frontend:${DRONE_COMMIT_SHA:0:8} platform-frontend:latest
|
||||
|
||||
# 部署测试环境
|
||||
- name: deploy-test
|
||||
image: docker:dind
|
||||
volumes:
|
||||
@@ -34,13 +46,15 @@ steps:
|
||||
CONFIG_ENCRYPT_KEY:
|
||||
from_secret: config_encrypt_key
|
||||
commands:
|
||||
- docker stop platform-backend-test || true
|
||||
- docker rm platform-backend-test || true
|
||||
- docker stop platform-backend-test platform-frontend-test || true
|
||||
- docker rm platform-backend-test platform-frontend-test || true
|
||||
- docker run -d --name platform-backend-test -p 8001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
|
||||
- docker run -d --name platform-frontend-test -p 3001:80 --restart unless-stopped platform-frontend:latest
|
||||
when:
|
||||
branch:
|
||||
- develop
|
||||
|
||||
# 部署生产环境
|
||||
- name: deploy-prod
|
||||
image: docker:dind
|
||||
volumes:
|
||||
@@ -56,9 +70,10 @@ steps:
|
||||
CONFIG_ENCRYPT_KEY:
|
||||
from_secret: config_encrypt_key
|
||||
commands:
|
||||
- docker stop platform-backend-prod || true
|
||||
- docker rm platform-backend-prod || true
|
||||
- docker stop platform-backend-prod platform-frontend-prod || true
|
||||
- docker rm platform-backend-prod platform-frontend-prod || true
|
||||
- docker run -d --name platform-backend-prod -p 9001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
|
||||
- docker run -d --name platform-frontend-prod -p 4001:80 --restart unless-stopped platform-frontend:latest
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@ venv/
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
logs/
|
||||
/logs/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
dist/
|
||||
|
||||
@@ -4,6 +4,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .routers import stats_router, logs_router, config_router, health_router
|
||||
from .routers.auth import router as auth_router
|
||||
from .routers.tenants import router as tenants_router
|
||||
from .routers.tenant_apps import router as tenant_apps_router
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -24,9 +27,12 @@ app.add_middleware(
|
||||
|
||||
# 注册路由
|
||||
app.include_router(health_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(logs_router)
|
||||
app.include_router(config_router)
|
||||
app.include_router(auth_router, prefix="/api")
|
||||
app.include_router(tenants_router, prefix="/api")
|
||||
app.include_router(tenant_apps_router, prefix="/api")
|
||||
app.include_router(stats_router, prefix="/api")
|
||||
app.include_router(logs_router, prefix="/api")
|
||||
app.include_router(config_router, prefix="/api")
|
||||
|
||||
|
||||
@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())
|
||||
decrypted = f.decrypt(encrypted)
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
# 别名
|
||||
encrypt_config = encrypt_value
|
||||
decrypt_config = decrypt_value
|
||||
|
||||
@@ -7,5 +7,6 @@ pydantic-settings>=2.0.0
|
||||
cryptography>=42.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt>=4.0.0
|
||||
python-multipart>=0.0.6
|
||||
httpx>=0.26.0
|
||||
|
||||
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