feat: add admin UI frontend and complete backend APIs
Some checks failed
continuous-integration/drone/push Build is failing

- Add Vue 3 frontend with Element Plus
- Implement login, dashboard, tenant management
- Add app configuration, logs viewer, stats pages
- Add user management for admins
- Update Drone CI to build and deploy frontend
- Frontend ports: 3001 (test), 4001 (prod)
This commit is contained in:
111
2026-01-23 15:48:50 +08:00
parent daa8125c58
commit b89d5ddee9
31 changed files with 3115 additions and 8 deletions

View File

@@ -10,6 +10,7 @@ trigger:
- push
steps:
# 构建后端镜像
- name: build-backend
image: docker:dind
volumes:
@@ -19,6 +20,17 @@ steps:
- docker build -t platform-backend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.backend .
- docker tag platform-backend:${DRONE_COMMIT_SHA:0:8} platform-backend:latest
# 构建前端镜像
- name: build-frontend
image: docker:dind
volumes:
- name: docker-sock
path: /var/run/docker.sock
commands:
- docker build -t platform-frontend:${DRONE_COMMIT_SHA:0:8} -f deploy/Dockerfile.frontend .
- docker tag platform-frontend:${DRONE_COMMIT_SHA:0:8} platform-frontend:latest
# 部署测试环境
- name: deploy-test
image: docker:dind
volumes:
@@ -34,13 +46,15 @@ steps:
CONFIG_ENCRYPT_KEY:
from_secret: config_encrypt_key
commands:
- docker stop platform-backend-test || true
- docker rm platform-backend-test || true
- docker stop platform-backend-test platform-frontend-test || true
- docker rm platform-backend-test platform-frontend-test || true
- docker run -d --name platform-backend-test -p 8001:8000 --restart unless-stopped -e DATABASE_URL=$DATABASE_URL -e API_KEY=$API_KEY -e JWT_SECRET=$JWT_SECRET -e CONFIG_ENCRYPT_KEY=$CONFIG_ENCRYPT_KEY platform-backend:latest
- docker run -d --name platform-frontend-test -p 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
View File

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

View File

@@ -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("/")

View 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)

View File

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

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

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;"]

View File

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

13
frontend/index.html Normal file
View File

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

26
frontend/package.json Normal file
View File

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

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

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

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

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,175 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import api from '@/api'
const stats = ref({
totalTenants: 0,
activeTenants: 0,
todayCalls: 0,
todayTokens: 0
})
const recentLogs = ref([])
const chartRef = ref(null)
let chartInstance = null
async function fetchStats() {
try {
// 获取租户统计
const tenantsRes = await api.get('/api/tenants', { params: { size: 1 } })
stats.value.totalTenants = tenantsRes.data.total || 0
// 获取统计数据
const statsRes = await api.get('/api/stats/summary')
if (statsRes.data) {
stats.value.todayCalls = statsRes.data.today_calls || 0
stats.value.todayTokens = statsRes.data.today_tokens || 0
}
} catch (e) {
console.error('获取统计失败:', e)
}
}
async function fetchRecentLogs() {
try {
const res = await api.get('/api/logs', { params: { size: 10, log_type: 'request' } })
recentLogs.value = res.data.items || []
} catch (e) {
console.error('获取日志失败:', e)
}
}
function initChart() {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
const option = {
title: {
text: '近7天 AI 调用趋势',
textStyle: { fontSize: 14, fontWeight: 500 }
},
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
},
yAxis: {
type: 'value'
},
series: [
{
name: '调用次数',
type: 'line',
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' }
])
},
lineStyle: { color: '#409eff' },
itemStyle: { color: '#409eff' },
data: [120, 132, 101, 134, 90, 230, 210]
}
]
}
chartInstance.setOption(option)
}
function handleResize() {
chartInstance?.resize()
}
onMounted(() => {
fetchStats()
fetchRecentLogs()
initChart()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
})
</script>
<template>
<div class="dashboard">
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-title">租户总数</div>
<div class="stat-value">{{ stats.totalTenants }}</div>
</div>
<div class="stat-card">
<div class="stat-title">活跃租户</div>
<div class="stat-value">{{ stats.activeTenants || '-' }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 AI 调用</div>
<div class="stat-value">{{ stats.todayCalls }}</div>
</div>
<div class="stat-card">
<div class="stat-title">今日 Token 消耗</div>
<div class="stat-value">{{ stats.todayTokens.toLocaleString() }}</div>
</div>
</div>
<!-- 图表区域 -->
<div class="chart-section">
<div class="chart-container" ref="chartRef"></div>
</div>
<!-- 最近日志 -->
<div class="page-container" style="margin-top: 20px">
<div class="page-header">
<div class="title">最近请求日志</div>
</div>
<el-table :data="recentLogs" style="width: 100%" size="small">
<el-table-column prop="app_code" label="应用" width="100" />
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="方法" width="80" />
<el-table-column prop="status_code" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status_code < 400 ? 'success' : 'danger'" size="small">
{{ row.status_code }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="100">
<template #default="{ row }">
{{ row.duration_ms }}ms
</template>
</el-table-column>
<el-table-column prop="log_time" label="时间" width="180" />
</el-table>
</div>
</div>
</template>
<style lang="scss" scoped>
.dashboard {
.chart-section {
background: #fff;
border-radius: 8px;
padding: 20px;
}
.chart-container {
height: 300px;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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