Compare commits

...

8 Commits

Author SHA1 Message Date
shen
0ba16635df docs: 更新文档反映多用户系统,移除冗余钉钉通知
- README 补充多用户管理、JWT 认证、Web 管理配置等文档
- .env.example 添加 JWT_SECRET 配置
- 移除 notification.py(钉钉通知由 AutoPacking 处理)
- 添加 CLAUDE.md 项目上下文
2026-06-08 22:07:35 +08:00
shen
29a4dd91ad fix: 调整历史表列宽,操作列增至 28% 避免按钮显示不全 2026-06-08 21:21:27 +08:00
shen
764fd18fd2 fix: 二维码放大弹窗关闭按钮定位到右上角 2026-06-08 21:18:16 +08:00
shen
bdd79e3302 feat: 点击二维码缩略图可放大查看 2026-06-08 21:16:09 +08:00
shen
48fdca12bd feat: 多用户管理系统,管理员可分配用户
- 新增 users 数据库表,存储用户名、密码哈希、角色
- 启动时自动从 .env 创建初始管理员账号
- 登录改为数据库校验,不再硬编码比对
- 新增 /api/users 管理接口(仅管理员)
- ConfigView 新增用户管理页(创建、改密码、切换角色、删除)
2026-06-08 21:08:16 +08:00
shen
1cecf66d94 feat: 全站登录保护,管理后台仅管理员可访问
- tasks 路由级认证保护,所有 API 需登录
- 未登录时显示登录页面,不暴露导航和功能
- ConfigView 仅管理员可访问,普通用户显示权限提示
- BuildView/HistoryView 所有 API 请求携带 token
2026-06-08 20:59:13 +08:00
shen
a0c857f3c2 fix: 钉钉通知仅在打包成功时发送 2026-06-08 20:43:21 +08:00
shen
e18f33d60f fix: 认证系统、登录兼容性、运行时配置、钉钉通知及代码清理
- 实现 JWT 认证中间件,保护配置管理和任务删除接口
- 修复 ConfigView 登录按钮(Vue 3 inject 替代 $root)
- 页面刷新时通过 /api/auth/me 校验 token 有效性
- max_concurrent_builds 修改后运行时即时生效
- 实现钉钉 webhook 通知(构建成功/失败自动推送)
- 删除未使用的 useWebSocket composable
- log_streamer 使用 LOG_QUEUE_MAX_SIZE 配置常量
2026-06-08 20:35:05 +08:00
20 changed files with 672 additions and 140 deletions

View File

@ -23,6 +23,10 @@ FRONTEND_PORT=3000
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# ---- JWT 认证 ----
# JWT 签名密钥(生产环境务必修改为随机字符串)
JWT_SECRET=ios-build-server-secret-key-change-in-production
# ---- 并发与清理 ----
# 最大并行打包数
MAX_CONCURRENT_BUILDS=2

35
CLAUDE.md Normal file
View File

@ -0,0 +1,35 @@
# iOS Build Server
FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store builds with web UI, real-time logs, multi-user auth, and health monitoring.
## Tech Stack
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy (SQLite), JWT auth
- **Frontend**: Vue 3, Vite, Vue Router
- **Tests**: pytest (backend), vitest (frontend)
- **Deploy**: macOS launchd, deploy.sh management script
## Key Commands
```bash
./start.sh # Dev mode (backend + frontend hot reload)
./deploy.sh build # Install deps + build frontend
./deploy.sh start/stop/restart/status
./deploy.sh test # Run all tests (backend + frontend)
```
## Architecture
- `backend/routers/` — API routes: auth, users, config, apps, tasks
- `backend/services/` — build_service (packaging), build_queue (async queue), log_streamer (WebSocket)
- `backend/deps.py` — JWT auth dependency used by all protected routes
- `backend/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
## Conventions
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
- Admin-only routes: user management (`/api/users/*`)
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
- Chinese UI and documentation throughout

View File

@ -1,6 +1,6 @@
# iOS 自动打包服务
基于 FastAPI + Vue 3 的 iOS 应用自动打包服务,支持 Ad_Hoc 和 App_Store 两种打包类型,提供 Web 界面操作、实时日志流、构建历史管理和健康监测。
基于 FastAPI + Vue 3 的 iOS 应用自动打包服务,支持 Ad_Hoc 和 App_Store 两种打包类型,提供 Web 界面操作、实时日志流、构建历史管理、多用户权限和健康监测。
## 目录
@ -8,6 +8,7 @@
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [服务管理](#服务管理)
- [多用户管理](#多用户管理)
- [Watchdog 健康监测](#watchdog-健康监测)
- [macOS 开机自启](#macos-开机自启)
- [开发模式](#开发模式)
@ -114,8 +115,9 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `BACKEND_PORT` | `8000` | 后端服务端口 |
| `ADMIN_USERNAME` | `admin` | 管理员用户名 |
| `ADMIN_PASSWORD` | `admin123` | 管理员密码 |
| `ADMIN_USERNAME` | `admin` | 初始管理员用户名(首次启动自动创建) |
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
### 打包配置
@ -123,6 +125,20 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|------|--------|------|
| `MAX_CONCURRENT_BUILDS` | `2` | 最大并行打包数 |
| `BUILD_DIR_RETENTION_HOURS` | `24` | 打包目录保留时间(小时),超期自动清理 |
| `BUILD_TIMEOUT_HOURS` | `1` | 打包超时时间(小时),超时自动标记失败 |
### Web 管理配置
以下配置通过管理后台 Web 界面管理(存储在 `config.json`
| 配置项 | 说明 |
|--------|------|
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
| Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表 |
| 版本号 | App_Ver 和 Build_Ver写入 AutoPacking 脚本) |
| 上传配置 | OSS / WebDAV 文件上传设置 |
### Watchdog 配置
@ -194,6 +210,34 @@ logs/
---
## 多用户管理
系统支持多用户管理,基于 JWT 认证。首次启动时自动创建 `.env` 中配置的管理员账号。
### 用户角色
| 角色 | 权限 |
|------|------|
| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) |
| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) |
### 管理用户
在管理后台「用户管理」页面(仅管理员可见):
- 创建用户:设置用户名、密码(至少 6 位)、是否管理员
- 修改密码:管理员可修改任意用户密码
- 切换角色:在管理员/普通用户之间切换
- 删除用户:不能删除自己
### 认证流程
1. `POST /api/auth/login` — 用户名+密码登录,返回 JWT token
2. 后续请求在 `Authorization: Bearer <token>` 头中携带 token
3. Token 有效期 24 小时,过期需重新登录
---
## Watchdog 健康监测
Watchdog 定期检查服务健康状态,发现异常自动重启。
@ -326,9 +370,9 @@ cd frontend && npx vitest run
| `test_build_queue.py` | 并发控制/任务执行/取消 | 7 |
| `test_edge_cases.py` | 边界情况/无效参数/配置备份 | 14 |
| `App.test.js` | 登录/退出/导航 | 8 |
| `BuildView.vue.test.js` | 打包表单/任务列表/提交 | 8 |
| `ConfigView.vue.test.js` | 配置管理/分支增删/设置保存 | 8 |
| `HistoryView.vue.test.js` | 历史列表/过滤/下载/二维码 | 11 |
| `BuildView.test.js` | 打包表单/任务列表/提交 | 8 |
| `ConfigView.test.js` | 配置管理/分支增删/设置保存 | 8 |
| `HistoryView.test.js` | 历史列表/过滤/下载/二维码 | 11 |
---
@ -362,11 +406,13 @@ BuildServer/
│ ├── main.py # 入口,路由注册,静态文件服务
│ ├── config.py # 配置加载(读取 .env
│ ├── database.py # SQLAlchemy 数据库初始化
│ ├── models.py # 数据库模型Task, BuildConfig
│ ├── models.py # 数据库模型Task, BuildConfig, User
│ ├── schemas.py # Pydantic 请求/响应模型
│ ├── deps.py # JWT 认证依赖
│ ├── routers/
│ │ ├── auth.py # 登录认证
│ │ ├── config.py # 配置管理App/Scheme/Server CRUD
│ │ ├── auth.py # 登录认证JWT
│ │ ├── users.py # 用户管理(仅管理员)
│ │ ├── config.py # 配置管理App/Scheme/Server/Upload/Branch/Version CRUD
│ │ ├── apps.py # 打包选择(只读)
│ │ └── tasks.py # 任务管理(创建/列表/取消/下载)
│ └── services/
@ -456,13 +502,17 @@ tail -50 logs/server.log
### Q: 如何修改管理员密码
编辑 `.env`
**方法一**:通过管理后台「用户管理」页面修改(推荐)
**方法二**:编辑 `.env` 后重启(仅影响初始管理员账号):
```bash
ADMIN_PASSWORD=new_password
./deploy.sh restart
```
注意:`.env` 中的管理员账号仅在首次启动时创建,之后的密码修改请通过管理后台操作。
### Q: 多台 Mac 部署
每台 Mac 上:

View File

@ -49,7 +49,6 @@ COPY_ITEMS = [
# 服务端口
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "3000"))
# 数据库路径
DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}"
@ -69,3 +68,8 @@ BUILD_TIMEOUT_HOURS = int(os.getenv("BUILD_TIMEOUT_HOURS", "1"))
# 管理员账号配置
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
# JWT 配置
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24

View File

@ -34,7 +34,24 @@ def _migrate_db():
pass # 列已存在则忽略
def _seed_admin():
"""初始化管理员账号(从 .env 配置)"""
from .models import User
from .config import ADMIN_USERNAME, ADMIN_PASSWORD
import hashlib
db = SessionLocal()
try:
if not db.query(User).filter(User.username == ADMIN_USERNAME).first():
pw_hash = hashlib.sha256(ADMIN_PASSWORD.encode()).hexdigest()
db.add(User(id="admin-001", username=ADMIN_USERNAME, password_hash=pw_hash, is_admin=True))
db.commit()
finally:
db.close()
def init_db():
"""初始化数据库"""
Base.metadata.create_all(bind=engine)
_migrate_db()
_seed_admin()

20
backend/deps.py Normal file
View File

@ -0,0 +1,20 @@
"""认证依赖"""
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import JWT_SECRET, JWT_ALGORITHM
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""验证 JWT token返回用户信息"""
token = credentials.credentials
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")

View File

@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .database import init_db
from .routers import config, apps, tasks, auth
from .routers import config, apps, tasks, auth, users
from .services.log_streamer import log_streamer
from .config import BACKEND_PORT
@ -38,6 +38,7 @@ app.add_middleware(
# 路由
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(config.router)
app.include_router(apps.router)
app.include_router(tasks.router)

View File

@ -49,3 +49,14 @@ class BuildConfig(Base):
key = Column(String, primary_key=True)
value = Column(String)
updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base):
"""用户表"""
__tablename__ = "users"
id = Column(String, primary_key=True)
username = Column(String, unique=True, nullable=False)
password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.utcnow)

View File

@ -1,19 +1,46 @@
"""认证 API"""
from fastapi import APIRouter
import jwt
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..schemas import LoginRequest, LoginResponse
from ..config import ADMIN_USERNAME, ADMIN_PASSWORD
from ..config import JWT_SECRET, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from ..deps import get_current_user
from ..database import get_db
from ..models import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _create_token(username: str, is_admin: bool) -> str:
"""生成 JWT token"""
payload = {
"sub": username,
"is_admin": is_admin,
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def _verify_password(password: str, password_hash: str) -> bool:
"""校验密码"""
import hashlib
return hashlib.sha256(password.encode()).hexdigest() == password_hash
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest):
"""管理员登录"""
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
return LoginResponse(
token="admin-token",
username=request.username,
is_admin=True
)
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="用户名或密码错误")
async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""用户登录"""
user = db.query(User).filter(User.username == request.username).first()
if not user or not _verify_password(request.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = _create_token(user.username, user.is_admin)
return LoginResponse(token=token, username=user.username, is_admin=user.is_admin)
@router.get("/me")
async def get_me(user: dict = Depends(get_current_user)):
"""校验 token 有效性,返回当前用户信息"""
return {"username": user["username"], "is_admin": user["is_admin"]}

View File

@ -10,8 +10,9 @@ from ..database import get_db
from ..models import BuildConfig
from ..schemas import BuildConfigUpdate
from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS
from ..deps import get_current_user
router = APIRouter(prefix="/api/config", tags=["config"])
router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(get_current_user)])
_config_lock = asyncio.Lock()
@ -314,6 +315,12 @@ async def update_build_settings(settings: BuildConfigUpdate, db: Session = Depen
db.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir))
db.commit()
# 运行时更新并发数
if settings.max_concurrent_builds is not None:
from ..services.build_queue import build_queue
build_queue.update_max_concurrent(settings.max_concurrent_builds)
return {"message": "打包设置已更新"}

View File

@ -13,8 +13,9 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Task
from ..schemas import TaskCreate, TaskResponse
from ..deps import get_current_user
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
@router.post("", response_model=TaskResponse)

103
backend/routers/users.py Normal file
View File

@ -0,0 +1,103 @@
"""用户管理 API仅管理员"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取用户列表"""
_require_admin(user)
users = db.query(User).order_by(User.created_at.desc()).all()
return [{"id": u.id, "username": u.username, "is_admin": u.is_admin, "created_at": str(u.created_at) if u.created_at else None} for u in users]
@router.post("")
async def create_user(data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建用户"""
_require_admin(user)
username = data.get("username", "").strip()
password = data.get("password", "").strip()
is_admin = data.get("is_admin", False)
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
is_admin=is_admin,
)
db.add(new_user)
db.commit()
return {"message": "用户已创建", "id": new_user.id}
@router.put("/{user_id}/password")
async def change_password(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""修改密码(管理员可改任意用户)"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
target.password_hash = _hash_password(new_password)
db.commit()
return {"message": "密码已更新"}
@router.put("/{user_id}/role")
async def change_role(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""切换管理员状态"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
target.is_admin = data.get("is_admin", not target.is_admin)
db.commit()
return {"message": "角色已更新", "is_admin": target.is_admin}
@router.delete("/{user_id}")
async def delete_user(user_id: str, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除用户"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
if target.username == user["username"]:
raise HTTPException(status_code=400, detail="不能删除自己")
db.delete(target)
db.commit()
return {"message": "用户已删除"}

View File

@ -62,10 +62,15 @@ class BuildQueue:
task.cancel()
def update_max_concurrent(self, new_max: int):
"""更新最大并发数"""
"""更新最大并发数(运行时生效)"""
if new_max == self._max_concurrent:
return
old = self._max_concurrent
self._max_concurrent = new_max
# 注意:运行时修改需要重建 semaphore这里简化处理
# 实际使用时建议重启服务生效
# 调整信号量:增加并发时 release 额外的许可,减少时由自然消费收敛
if new_max > old:
for _ in range(new_max - old):
self._semaphore.release()
@property
def queue_size(self) -> int:

View File

@ -5,6 +5,8 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, AsyncGenerator
from ..config import LOG_QUEUE_MAX_SIZE
class LogStreamer:
"""日志流管理器"""
@ -16,7 +18,7 @@ class LogStreamer:
def create_queue(self, task_id: str) -> asyncio.Queue:
"""创建任务的日志队列"""
queue = asyncio.Queue(maxsize=1000)
queue = asyncio.Queue(maxsize=LOG_QUEUE_MAX_SIZE)
self._queues[task_id] = queue
self._log_lines[task_id] = []
return queue

View File

@ -3,35 +3,46 @@
<header class="header">
<h1>iOS 自动打包服务</h1>
<div class="header-right">
<nav class="nav">
<nav v-if="isLoggedIn" class="nav">
<router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link>
<router-link v-if="isLoggedIn" to="/admin">管理</router-link>
<router-link v-if="isAdmin" to="/admin">管理</router-link>
</nav>
<div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button>
</div>
<div v-else class="user-info">
<div class="user-avatar">A</div>
<span class="user-name">管理员</span>
<div class="user-avatar">{{ isAdmin ? 'A' : 'U' }}</div>
<span class="user-name">{{ isAdmin ? '管理员' : '用户' }}</span>
<button class="btn-logout" @click="logout">退出</button>
</div>
</div>
</header>
<router-view />
<template v-if="isLoggedIn">
<router-view />
</template>
<template v-else>
<div class="login-required">
<div class="login-card">
<h2>请先登录</h2>
<p>使用此服务需要登录账号</p>
<button class="login-btn" @click="showLogin = true">立即登录</button>
</div>
</div>
</template>
<!-- 登录弹窗 -->
<div v-if="showLogin" class="modal-overlay" @click.self="showLogin = false">
<div class="login-modal">
<h2>管理员登录</h2>
<h2>用户登录</h2>
<div class="form-group">
<label>用户名</label>
<input v-model="loginForm.username" type="text" placeholder="请输入用户名">
<input v-model="loginForm.username" type="text" placeholder="请输入用户名" @keyup.enter="login">
</div>
<div class="form-group">
<label>密码</label>
<input v-model="loginForm.password" type="password" placeholder="请输入密码">
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="login">
</div>
<button class="login-btn" @click="login">登录</button>
<div class="login-hint">默认账号: admin / admin123</div>
@ -41,12 +52,24 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, provide } from 'vue'
const isLoggedIn = ref(false)
const isAdmin = ref(false)
const showLogin = ref(false)
const loginForm = ref({ username: '', password: '' })
// 使
provide('showLogin', showLogin)
provide('isLoggedIn', isLoggedIn)
provide('isAdmin', isAdmin)
// token
const getToken = () => localStorage.getItem('authToken') || ''
// token API 使
provide('getToken', getToken)
const login = async () => {
try {
const res = await fetch('/api/auth/login', {
@ -55,9 +78,11 @@ const login = async () => {
body: JSON.stringify(loginForm.value),
})
if (res.ok) {
const data = await res.json()
isLoggedIn.value = true
isAdmin.value = data.is_admin
showLogin.value = false
localStorage.setItem('isAdmin', 'true')
localStorage.setItem('authToken', data.token)
} else {
alert('用户名或密码错误')
}
@ -68,13 +93,33 @@ const login = async () => {
const logout = () => {
isLoggedIn.value = false
localStorage.removeItem('isAdmin')
isAdmin.value = false
localStorage.removeItem('authToken')
}
//
if (localStorage.getItem('isAdmin') === 'true') {
isLoggedIn.value = true
}
// token
onMounted(async () => {
const token = localStorage.getItem('authToken')
if (!token) {
showLogin.value = true
return
}
try {
const res = await fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
isLoggedIn.value = true
isAdmin.value = data.is_admin
} else {
localStorage.removeItem('authToken')
showLogin.value = true
}
} catch {
//
}
})
</script>
<style>
@ -88,12 +133,17 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b
.nav a.router-link-active { background: #16213e; color: white; }
.nav a:hover { color: white; }
.user-info { display: flex; align-items: center; gap: 10px; }
.user-avatar { width: 32px; height: 32px; background: #1890ff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.user-avatar { width: 32px; height: 32px; background: #1890ff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; }
.user-name { font-size: 14px; }
.btn-login { background: #1890ff; color: white; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
.btn-logout { background: transparent; color: #a0a0a0; border: 1px solid #444; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-size: 13px; }
.btn-logout:hover { border-color: #ff4d4f; color: #ff4d4f; }
.login-required { display: flex; align-items: center; justify-content: center; min-height: calc(100vh - 64px); }
.login-card { background: white; border-radius: 12px; padding: 48px; text-align: center; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
.login-card h2 { font-size: 20px; color: #1a1a2e; margin-bottom: 8px; }
.login-card p { color: #999; margin-bottom: 24px; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.login-modal { background: white; border-radius: 12px; padding: 32px; width: 400px; }
.login-modal h2 { font-size: 20px; margin-bottom: 24px; text-align: center; color: #1a1a2e; }

View File

@ -1,42 +0,0 @@
import { ref, onUnmounted } from 'vue'
export function useWebSocket(taskId) {
const logs = ref([])
const connected = ref(false)
let ws = null
const connect = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.level !== 'heartbeat') {
logs.value.push(msg)
}
}
ws.onclose = () => {
connected.value = false
}
ws.onerror = () => {
connected.value = false
}
}
const disconnect = () => {
if (ws) {
ws.close()
ws = null
}
}
onUnmounted(disconnect)
return { logs, connected, connect, disconnect }
}

View File

@ -138,9 +138,19 @@
</template>
<script setup>
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
import { useRoute } from 'vue-router'
const getToken = inject('getToken')
const authFetch = (url, options = {}) => {
const token = getToken()
if (token) {
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` }
}
return fetch(url, options)
}
const apps = ref({})
const schemes = ref({})
const branches = ref(['main'])
@ -164,7 +174,7 @@ let skipNextConnect = false
const fetchTaskDetail = async (taskId) => {
try {
const res = await fetch(`/api/tasks/${taskId}`)
const res = await authFetch(`/api/tasks/${taskId}`)
if (res.ok) {
completedTask.value = await res.json()
}
@ -175,7 +185,7 @@ const fetchTaskDetail = async (taskId) => {
const fetchBuildLog = async (taskId) => {
try {
const res = await fetch(`/api/tasks/${taskId}/log`)
const res = await authFetch(`/api/tasks/${taskId}/log`)
if (res.ok) {
const data = await res.json()
if (data.log) {
@ -257,10 +267,10 @@ onUnmounted(() => {
onMounted(async () => {
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
fetch('/api/apps'),
fetch('/api/schemes'),
fetch('/api/branches'),
fetch('/api/tasks'),
authFetch('/api/apps'),
authFetch('/api/schemes'),
authFetch('/api/branches'),
authFetch('/api/tasks'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
@ -302,7 +312,7 @@ const submitTask = async () => {
}
submitting.value = true
try {
const res = await fetch('/api/tasks', {
const res = await authFetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
@ -329,7 +339,7 @@ const selectTask = (taskId) => {
const cancelTask = async () => {
if (!currentTaskId.value) return
if (!confirm('确定取消当前任务?')) return
const res = await fetch(`/api/tasks/${currentTaskId.value}`, { method: 'DELETE' })
const res = await authFetch(`/api/tasks/${currentTaskId.value}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
alert(err.detail || '取消失败')
@ -342,7 +352,7 @@ const cancelTask = async () => {
}
const refreshTasks = async () => {
const res = await fetch('/api/tasks?limit=50')
const res = await authFetch('/api/tasks?limit=50')
const all = await res.json()
// +
tasks.value = all.filter(t =>

View File

@ -1,14 +1,15 @@
<template>
<div class="container">
<div v-if="!isLoggedIn" class="access-denied">
<div v-if="!isAdmin" class="access-denied">
<h3>需要管理员权限</h3>
<p>请先登录管理员账号以访问配置管理页面</p>
<button class="btn-login" @click="$root.showLogin = true">登录管理员账号</button>
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
</div>
<div v-else class="admin-page">
<div class="sidebar">
<h3>配置管理</h3>
<ul class="sidebar-menu">
<li :class="{ active: tab === 'users' }" @click="tab = 'users'">用户管理</li>
<li :class="{ active: tab === 'servers' }" @click="tab = 'servers'">服务器环境</li>
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
@ -19,6 +20,42 @@
</ul>
</div>
<div class="main-content">
<!-- 用户管理 -->
<div v-if="tab === 'users'">
<h2>用户管理</h2>
<p style="color: #666; margin-bottom: 16px; font-size: 14px;">管理员可以创建和管理普通用户账号</p>
<button class="btn btn-primary" style="width: auto; margin-bottom: 16px;" @click="openUserModal()">+ 新增用户</button>
<table class="config-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><strong>{{ u.username }}</strong></td>
<td>
<span :class="['build-type-badge', u.is_admin ? 'badge-appstore' : 'badge-adhoc']">
{{ u.is_admin ? '管理员' : '普通用户' }}
</span>
</td>
<td>{{ u.created_at || '-' }}</td>
<td class="action-btns">
<button class="action-btn" @click="openPasswordModal(u)">改密码</button>
<button class="action-btn" @click="toggleUserRole(u)">{{ u.is_admin ? '取消管理员' : '设为管理员' }}</button>
<button class="action-btn delete" @click="deleteUser(u)">删除</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="4" style="text-align: center; color: #999;">暂无用户</td>
</tr>
</tbody>
</table>
</div>
<!-- 服务器环境配置 -->
<div v-if="tab === 'servers'">
<h2>服务器环境配置</h2>
@ -275,6 +312,52 @@
</div>
</div>
<!-- 新增用户弹窗 -->
<div v-if="showUserModal" class="modal-overlay" @click.self="showUserModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>新增用户</h3>
<button class="modal-close" @click="showUserModal = false">&times;</button>
</div>
<div class="form-group">
<label>用户名 *</label>
<input v-model="userForm.username" type="text" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 6 位">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.is_admin" style="width: 16px; height: 16px;">
管理员权限
</label>
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showUserModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="createUser">创建</button>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
<div class="modal-content" style="width: 400px;">
<div class="modal-header">
<h3>修改密码 - {{ passwordTarget?.username }}</h3>
<button class="modal-close" @click="showPasswordModal = false">&times;</button>
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 6 位">
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="changePassword">确认</button>
</div>
</div>
</div>
<!-- 服务器环境编辑弹窗 -->
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
<div class="modal-content">
@ -443,10 +526,30 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, inject } from 'vue'
const isLoggedIn = ref(localStorage.getItem('isAdmin') === 'true')
const tab = ref('servers')
const showLogin = inject('showLogin')
const getToken = inject('getToken')
const isLoggedIn = inject('isLoggedIn')
const isAdmin = inject('isAdmin')
const tab = ref('users')
//
const users = ref([])
const showUserModal = ref(false)
const userForm = ref({ username: '', password: '', is_admin: false })
const showPasswordModal = ref(false)
const passwordTarget = ref(null)
const newPassword = ref('')
// fetch
const authFetch = (url, options = {}) => {
const token = getToken()
if (token) {
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` }
}
return fetch(url, options)
}
const apps = ref({})
const schemes = ref({})
const servers = ref({})
@ -474,15 +577,16 @@ onMounted(async () => {
})
const loadData = async () => {
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes] = await Promise.all([
fetch('/api/config/apps'),
fetch('/api/config/schemes'),
fetch('/api/config/servers'),
fetch('/api/config/branches'),
fetch('/api/config/build'),
fetch('/api/config/upload'),
fetch('/api/config'),
fetch('/api/config/versions'),
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
authFetch('/api/config/apps'),
authFetch('/api/config/schemes'),
authFetch('/api/config/servers'),
authFetch('/api/config/branches'),
authFetch('/api/config/build'),
authFetch('/api/config/upload'),
authFetch('/api/config'),
authFetch('/api/config/versions'),
authFetch('/api/users'),
])
apps.value = await appsRes.json()
schemes.value = await schemesRes.json()
@ -492,6 +596,88 @@ const loadData = async () => {
uploadConfig.value = await uploadRes.json()
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
versions.value = await versionsRes.json()
if (usersRes.ok) users.value = await usersRes.json()
}
//
const openUserModal = () => {
userForm.value = { username: '', password: '', is_admin: false }
showUserModal.value = true
}
const createUser = async () => {
if (!userForm.value.username || !userForm.value.password) {
alert('请填写用户名和密码')
return
}
try {
const res = await authFetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userForm.value),
})
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '创建失败')
}
showUserModal.value = false
await loadData()
} catch (e) {
alert('创建失败: ' + e.message)
}
}
const openPasswordModal = (u) => {
passwordTarget.value = u
newPassword.value = ''
showPasswordModal.value = true
}
const changePassword = async () => {
if (!newPassword.value || newPassword.value.length < 6) {
alert('密码至少 6 位')
return
}
try {
const res = await authFetch(`/api/users/${passwordTarget.value.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword.value }),
})
if (!res.ok) throw new Error('修改失败')
showPasswordModal.value = false
alert('密码已更新')
} catch (e) {
alert('修改失败: ' + e.message)
}
}
const toggleUserRole = async (u) => {
try {
const res = await authFetch(`/api/users/${u.id}/role`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_admin: !u.is_admin }),
})
if (!res.ok) throw new Error('操作失败')
await loadData()
} catch (e) {
alert('操作失败: ' + e.message)
}
}
const deleteUser = async (u) => {
if (!confirm(`确定删除用户「${u.username}」?`)) return
try {
const res = await authFetch(`/api/users/${u.id}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '删除失败')
}
await loadData()
} catch (e) {
alert('删除失败: ' + e.message)
}
}
//
@ -509,7 +695,7 @@ const saveServer = async () => {
try {
if (editingServerName.value) {
const res = await fetch(`/api/config/servers/${encodeURIComponent(editingServerName.value)}`, {
const res = await authFetch(`/api/config/servers/${encodeURIComponent(editingServerName.value)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serverForm.value),
@ -519,7 +705,7 @@ const saveServer = async () => {
throw new Error(err.detail || '保存失败')
}
} else {
const res = await fetch('/api/config/servers', {
const res = await authFetch('/api/config/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serverForm.value),
@ -540,7 +726,7 @@ const saveServer = async () => {
const deleteServer = async (name) => {
if (!confirm(`确定删除环境「${name}」?`)) return
try {
const res = await fetch(`/api/config/servers/${encodeURIComponent(name)}`, { method: 'DELETE' })
const res = await authFetch(`/api/config/servers/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) {
const err = await res.json()
throw new Error(err.detail || '删除失败')
@ -626,13 +812,13 @@ const saveApp = async () => {
try {
let res
if (editingAppId.value) {
res = await fetch(`/api/config/apps/${editingAppId.value}`, {
res = await authFetch(`/api/config/apps/${editingAppId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cleanData),
})
} else {
res = await fetch('/api/config/apps', {
res = await authFetch('/api/config/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cleanData),
@ -652,7 +838,7 @@ const saveApp = async () => {
const deleteApp = async (id) => {
if (!confirm('确定删除此 App')) return
await fetch(`/api/config/apps/${id}`, { method: 'DELETE' })
await authFetch(`/api/config/apps/${id}`, { method: 'DELETE' })
await loadData()
}
@ -671,14 +857,14 @@ const saveScheme = async () => {
try {
if (editingSchemeId.value) {
const res = await fetch(`/api/config/schemes/${editingSchemeId.value}`, {
const res = await authFetch(`/api/config/schemes/${editingSchemeId.value}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(schemeForm.value),
})
if (!res.ok) throw new Error('保存失败')
} else {
const res = await fetch('/api/config/schemes', {
const res = await authFetch('/api/config/schemes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(schemeForm.value),
@ -695,7 +881,7 @@ const saveScheme = async () => {
const deleteScheme = async (id) => {
if (!confirm('确定删除此 Scheme')) return
await fetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
await authFetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
await loadData()
}
@ -704,7 +890,7 @@ const addBranch = async () => {
const name = newBranch.value.trim()
if (!name) return
try {
const res = await fetch('/api/config/branches', {
const res = await authFetch('/api/config/branches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
@ -723,7 +909,7 @@ const addBranch = async () => {
const deleteBranch = async (name) => {
if (!confirm(`确定删除分支「${name}」?`)) return
try {
const res = await fetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
const res = await authFetch(`/api/config/branches/${encodeURIComponent(name)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('删除失败')
await loadData()
} catch (e) {
@ -733,7 +919,7 @@ const deleteBranch = async (name) => {
//
const saveBuildSettings = async () => {
await fetch('/api/config/build', {
await authFetch('/api/config/build', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildSettings.value),
@ -743,7 +929,7 @@ const saveBuildSettings = async () => {
const saveVersions = async () => {
try {
const res = await fetch('/api/config/versions', {
const res = await authFetch('/api/config/versions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(versions.value),
@ -763,7 +949,7 @@ const saveVersions = async () => {
const saveUploadConfig = async () => {
try {
await fetch('/api/config/upload', {
await authFetch('/api/config/upload', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(uploadConfig.value),
@ -777,7 +963,7 @@ const saveUploadConfig = async () => {
const saveJson = async () => {
try {
const config = JSON.parse(jsonContent.value)
await fetch('/api/config', {
await authFetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),

View File

@ -56,7 +56,7 @@
<template v-if="task.status === 'completed' && task.oss_url">
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="task._showQr = !task._showQr">
@click="showQrPreview(task)">
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a>
</template>
<span v-else class="text-muted">-</span>
@ -75,6 +75,22 @@
</table>
</div>
<!-- 二维码放大弹窗 -->
<div v-if="qrPreview" class="modal-overlay" @click.self="qrPreview = null">
<div class="qr-preview-modal">
<div class="modal-header">
<h3>{{ qrPreview.app_name }} - 下载二维码</h3>
<button class="modal-close" @click="qrPreview = null">&times;</button>
</div>
<img :src="qrPreview.qr_code_path" alt="QR Code" class="qr-preview-img">
<div class="qr-preview-info">
<p><strong>App:</strong> {{ qrPreview.app_name }}</p>
<p><strong>Scheme:</strong> {{ qrPreview.scheme_name }}</p>
<p><strong>类型:</strong> {{ qrPreview.build_type }}</p>
</div>
</div>
</div>
<!-- 日志弹窗 -->
<Transition name="modal">
<div v-if="showLogModal" class="modal-overlay" @click.self="closeLogModal">
@ -144,9 +160,18 @@
</template>
<script setup>
import { ref, computed, onMounted, nextTick, onUnmounted } from 'vue'
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
const getToken = inject('getToken')
const tasks = ref([])
const authFetch = (url, options = {}) => {
const token = getToken()
if (token) {
options.headers = { ...options.headers, 'Authorization': `Bearer ${token}` }
}
return fetch(url, options)
}
const filterBuildType = ref('')
const filterStatus = ref('')
const showLogModal = ref(false)
@ -154,8 +179,13 @@ const logTask = ref(null)
const logLines = ref([])
const logContainer = ref(null)
const showVerboseLogs = ref(false)
const qrPreview = ref(null)
let logWs = null
const showQrPreview = (task) => {
qrPreview.value = task
}
const filteredTasks = computed(() => {
return tasks.value.filter(task => {
//
@ -167,7 +197,7 @@ const filteredTasks = computed(() => {
})
onMounted(async () => {
const res = await fetch('/api/tasks?limit=100')
const res = await authFetch('/api/tasks?limit=100')
tasks.value = await res.json()
})
@ -185,7 +215,7 @@ const viewLogs = async (taskId) => {
} else {
// / REST API
try {
const res = await fetch(`/api/tasks/${taskId}/log`)
const res = await authFetch(`/api/tasks/${taskId}/log`)
if (res.ok) {
const data = await res.json()
if (data.log) {
@ -268,7 +298,7 @@ const downloadObfMaps = (taskId) => {
const deleteTask = async (taskId) => {
if (!confirm('确定要删除这条打包记录吗?')) return
try {
const res = await fetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
const res = await authFetch(`/api/tasks/${taskId}/delete`, { method: 'DELETE' })
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
} catch {}
}
@ -329,13 +359,13 @@ const errorCategoryLabel = (cat) => {
.config-table th, .config-table td { padding: 12px; text-align: left; border-bottom: 1px solid #f0f0f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.config-table th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
.config-table td { font-size: 14px; }
.config-table th:nth-child(1) { width: 16%; }
.config-table th:nth-child(2) { width: 18%; }
.config-table th:nth-child(3) { width: 10%; }
.config-table th:nth-child(4) { width: 12%; }
.config-table th:nth-child(5) { width: 12%; }
.config-table th:nth-child(6) { width: 18%; }
.config-table th:nth-child(7) { width: 14%; }
.config-table th:nth-child(1) { width: 14%; }
.config-table th:nth-child(2) { width: 14%; }
.config-table th:nth-child(3) { width: 8%; }
.config-table th:nth-child(4) { width: 10%; }
.config-table th:nth-child(5) { width: 10%; }
.config-table th:nth-child(6) { width: 16%; }
.config-table th:nth-child(7) { width: 28%; }
.config-table tr:hover { background: #fafafa; }
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
@ -458,4 +488,13 @@ const errorCategoryLabel = (cat) => {
.qr-thumb:hover { border-color: #1890ff; }
.btn-danger { color: #ff4d4f; border-color: #ff4d4f; }
.btn-danger:hover { background: #ff4d4f; color: white; }
/* 二维码放大弹窗 */
.qr-preview-modal { background: white; border-radius: 12px; padding: 24px; width: 380px; text-align: center; position: relative; }
.qr-preview-modal .modal-header { display: flex; justify-content: center; align-items: center; position: relative; margin-bottom: 16px; }
.qr-preview-modal .modal-close { position: absolute; right: 0; top: 50%; transform: translateY(-50%); background: none; border: none; font-size: 20px; cursor: pointer; color: #999; }
.qr-preview-modal .modal-close:hover { color: #333; }
.qr-preview-img { width: 260px; height: 260px; border: 1px solid #f0f0f0; border-radius: 8px; margin: 16px 0; }
.qr-preview-info { text-align: left; padding: 12px 16px; background: #fafafa; border-radius: 8px; }
.qr-preview-info p { margin: 6px 0; font-size: 13px; color: #555; }
</style>

View File

@ -3,3 +3,5 @@ uvicorn[standard]>=0.24.0
sqlalchemy>=2.0.0
pydantic>=2.0.0
python-multipart>=0.0.6
PyJWT>=2.8.0
httpx>=0.25.0