Compare commits
8 Commits
f5919a8229
...
0ba16635df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba16635df | ||
|
|
29a4dd91ad | ||
|
|
764fd18fd2 | ||
|
|
bdd79e3302 | ||
|
|
48fdca12bd | ||
|
|
1cecf66d94 | ||
|
|
a0c857f3c2 | ||
|
|
e18f33d60f |
@ -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
35
CLAUDE.md
Normal 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
|
||||
70
README.md
70
README.md
@ -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 上:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
20
backend/deps.py
Normal 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="无效的认证凭据")
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"]}
|
||||
|
||||
@ -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": "打包设置已更新"}
|
||||
|
||||
|
||||
|
||||
@ -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
103
backend/routers/users.py
Normal 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": "用户已删除"}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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 =>
|
||||
|
||||
@ -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">×</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">×</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),
|
||||
|
||||
@ -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">×</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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user