Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba16635df | ||
|
|
29a4dd91ad | ||
|
|
764fd18fd2 | ||
|
|
bdd79e3302 | ||
|
|
48fdca12bd | ||
|
|
1cecf66d94 | ||
|
|
a0c857f3c2 | ||
|
|
e18f33d60f |
@@ -23,6 +23,10 @@ FRONTEND_PORT=3000
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# ---- JWT 认证 ----
|
||||||
|
# JWT 签名密钥(生产环境务必修改为随机字符串)
|
||||||
|
JWT_SECRET=ios-build-server-secret-key-change-in-production
|
||||||
|
|
||||||
# ---- 并发与清理 ----
|
# ---- 并发与清理 ----
|
||||||
# 最大并行打包数
|
# 最大并行打包数
|
||||||
MAX_CONCURRENT_BUILDS=2
|
MAX_CONCURRENT_BUILDS=2
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# iOS 自动打包服务
|
# 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-健康监测)
|
- [Watchdog 健康监测](#watchdog-健康监测)
|
||||||
- [macOS 开机自启](#macos-开机自启)
|
- [macOS 开机自启](#macos-开机自启)
|
||||||
- [开发模式](#开发模式)
|
- [开发模式](#开发模式)
|
||||||
@@ -114,8 +115,9 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|
|||||||
| 变量 | 默认值 | 说明 |
|
| 变量 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `BACKEND_PORT` | `8000` | 后端服务端口 |
|
| `BACKEND_PORT` | `8000` | 后端服务端口 |
|
||||||
| `ADMIN_USERNAME` | `admin` | 管理员用户名 |
|
| `ADMIN_USERNAME` | `admin` | 初始管理员用户名(首次启动自动创建) |
|
||||||
| `ADMIN_PASSWORD` | `admin123` | 管理员密码 |
|
| `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` | 最大并行打包数 |
|
| `MAX_CONCURRENT_BUILDS` | `2` | 最大并行打包数 |
|
||||||
| `BUILD_DIR_RETENTION_HOURS` | `24` | 打包目录保留时间(小时),超期自动清理 |
|
| `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 配置
|
### 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 健康监测
|
||||||
|
|
||||||
Watchdog 定期检查服务健康状态,发现异常自动重启。
|
Watchdog 定期检查服务健康状态,发现异常自动重启。
|
||||||
@@ -326,9 +370,9 @@ cd frontend && npx vitest run
|
|||||||
| `test_build_queue.py` | 并发控制/任务执行/取消 | 7 |
|
| `test_build_queue.py` | 并发控制/任务执行/取消 | 7 |
|
||||||
| `test_edge_cases.py` | 边界情况/无效参数/配置备份 | 14 |
|
| `test_edge_cases.py` | 边界情况/无效参数/配置备份 | 14 |
|
||||||
| `App.test.js` | 登录/退出/导航 | 8 |
|
| `App.test.js` | 登录/退出/导航 | 8 |
|
||||||
| `BuildView.vue.test.js` | 打包表单/任务列表/提交 | 8 |
|
| `BuildView.test.js` | 打包表单/任务列表/提交 | 8 |
|
||||||
| `ConfigView.vue.test.js` | 配置管理/分支增删/设置保存 | 8 |
|
| `ConfigView.test.js` | 配置管理/分支增删/设置保存 | 8 |
|
||||||
| `HistoryView.vue.test.js` | 历史列表/过滤/下载/二维码 | 11 |
|
| `HistoryView.test.js` | 历史列表/过滤/下载/二维码 | 11 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -362,11 +406,13 @@ BuildServer/
|
|||||||
│ ├── main.py # 入口,路由注册,静态文件服务
|
│ ├── main.py # 入口,路由注册,静态文件服务
|
||||||
│ ├── config.py # 配置加载(读取 .env)
|
│ ├── config.py # 配置加载(读取 .env)
|
||||||
│ ├── database.py # SQLAlchemy 数据库初始化
|
│ ├── database.py # SQLAlchemy 数据库初始化
|
||||||
│ ├── models.py # 数据库模型(Task, BuildConfig)
|
│ ├── models.py # 数据库模型(Task, BuildConfig, User)
|
||||||
│ ├── schemas.py # Pydantic 请求/响应模型
|
│ ├── schemas.py # Pydantic 请求/响应模型
|
||||||
|
│ ├── deps.py # JWT 认证依赖
|
||||||
│ ├── routers/
|
│ ├── routers/
|
||||||
│ │ ├── auth.py # 登录认证
|
│ │ ├── auth.py # 登录认证(JWT)
|
||||||
│ │ ├── config.py # 配置管理(App/Scheme/Server CRUD)
|
│ │ ├── users.py # 用户管理(仅管理员)
|
||||||
|
│ │ ├── config.py # 配置管理(App/Scheme/Server/Upload/Branch/Version CRUD)
|
||||||
│ │ ├── apps.py # 打包选择(只读)
|
│ │ ├── apps.py # 打包选择(只读)
|
||||||
│ │ └── tasks.py # 任务管理(创建/列表/取消/下载)
|
│ │ └── tasks.py # 任务管理(创建/列表/取消/下载)
|
||||||
│ └── services/
|
│ └── services/
|
||||||
@@ -456,13 +502,17 @@ tail -50 logs/server.log
|
|||||||
|
|
||||||
### Q: 如何修改管理员密码
|
### Q: 如何修改管理员密码
|
||||||
|
|
||||||
编辑 `.env`:
|
**方法一**:通过管理后台「用户管理」页面修改(推荐)
|
||||||
|
|
||||||
|
**方法二**:编辑 `.env` 后重启(仅影响初始管理员账号):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ADMIN_PASSWORD=new_password
|
ADMIN_PASSWORD=new_password
|
||||||
./deploy.sh restart
|
./deploy.sh restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
注意:`.env` 中的管理员账号仅在首次启动时创建,之后的密码修改请通过管理后台操作。
|
||||||
|
|
||||||
### Q: 多台 Mac 部署
|
### Q: 多台 Mac 部署
|
||||||
|
|
||||||
每台 Mac 上:
|
每台 Mac 上:
|
||||||
|
|||||||
+5
-1
@@ -49,7 +49,6 @@ COPY_ITEMS = [
|
|||||||
|
|
||||||
# 服务端口
|
# 服务端口
|
||||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
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'}"
|
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_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
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 # 列已存在则忽略
|
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():
|
def init_db():
|
||||||
"""初始化数据库"""
|
"""初始化数据库"""
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_migrate_db()
|
_migrate_db()
|
||||||
|
_seed_admin()
|
||||||
|
|||||||
@@ -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="无效的认证凭据")
|
||||||
+2
-1
@@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from .database import init_db
|
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 .services.log_streamer import log_streamer
|
||||||
from .config import BACKEND_PORT
|
from .config import BACKEND_PORT
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
# 路由
|
# 路由
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(apps.router)
|
app.include_router(apps.router)
|
||||||
app.include_router(tasks.router)
|
app.include_router(tasks.router)
|
||||||
|
|||||||
@@ -49,3 +49,14 @@ class BuildConfig(Base):
|
|||||||
key = Column(String, primary_key=True)
|
key = Column(String, primary_key=True)
|
||||||
value = Column(String)
|
value = Column(String)
|
||||||
updated_at = Column(TIMESTAMP, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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)
|
||||||
|
|||||||
+38
-11
@@ -1,19 +1,46 @@
|
|||||||
"""认证 API"""
|
"""认证 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 ..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"])
|
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)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(request: LoginRequest):
|
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||||
"""管理员登录"""
|
"""用户登录"""
|
||||||
if request.username == ADMIN_USERNAME and request.password == ADMIN_PASSWORD:
|
user = db.query(User).filter(User.username == request.username).first()
|
||||||
return LoginResponse(
|
if not user or not _verify_password(request.password, user.password_hash):
|
||||||
token="admin-token",
|
|
||||||
username=request.username,
|
|
||||||
is_admin=True
|
|
||||||
)
|
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
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 ..models import BuildConfig
|
||||||
from ..schemas import BuildConfigUpdate
|
from ..schemas import BuildConfigUpdate
|
||||||
from ..config import CONFIG_JSON_PATH, DEFAULT_MAX_CONCURRENT_BUILDS, DEFAULT_BUILD_DIR_RETENTION_HOURS
|
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()
|
_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.add(BuildConfig(key="build_base_dir", value=settings.build_base_dir))
|
||||||
|
|
||||||
db.commit()
|
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": "打包设置已更新"}
|
return {"message": "打包设置已更新"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Task
|
from ..models import Task
|
||||||
from ..schemas import TaskCreate, TaskResponse
|
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)
|
@router.post("", response_model=TaskResponse)
|
||||||
|
|||||||
@@ -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()
|
task.cancel()
|
||||||
|
|
||||||
def update_max_concurrent(self, new_max: int):
|
def update_max_concurrent(self, new_max: int):
|
||||||
"""更新最大并发数"""
|
"""更新最大并发数(运行时生效)"""
|
||||||
|
if new_max == self._max_concurrent:
|
||||||
|
return
|
||||||
|
old = self._max_concurrent
|
||||||
self._max_concurrent = new_max
|
self._max_concurrent = new_max
|
||||||
# 注意:运行时修改需要重建 semaphore,这里简化处理
|
# 调整信号量:增加并发时 release 额外的许可,减少时由自然消费收敛
|
||||||
# 实际使用时建议重启服务生效
|
if new_max > old:
|
||||||
|
for _ in range(new_max - old):
|
||||||
|
self._semaphore.release()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def queue_size(self) -> int:
|
def queue_size(self) -> int:
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, AsyncGenerator
|
from typing import Dict, AsyncGenerator
|
||||||
|
|
||||||
|
from ..config import LOG_QUEUE_MAX_SIZE
|
||||||
|
|
||||||
|
|
||||||
class LogStreamer:
|
class LogStreamer:
|
||||||
"""日志流管理器"""
|
"""日志流管理器"""
|
||||||
@@ -16,7 +18,7 @@ class LogStreamer:
|
|||||||
|
|
||||||
def create_queue(self, task_id: str) -> asyncio.Queue:
|
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._queues[task_id] = queue
|
||||||
self._log_lines[task_id] = []
|
self._log_lines[task_id] = []
|
||||||
return queue
|
return queue
|
||||||
|
|||||||
+64
-14
@@ -3,35 +3,46 @@
|
|||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>iOS 自动打包服务</h1>
|
<h1>iOS 自动打包服务</h1>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<nav class="nav">
|
<nav v-if="isLoggedIn" class="nav">
|
||||||
<router-link to="/build">打包</router-link>
|
<router-link to="/build">打包</router-link>
|
||||||
<router-link to="/history">历史</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>
|
</nav>
|
||||||
<div v-if="!isLoggedIn" class="user-info">
|
<div v-if="!isLoggedIn" class="user-info">
|
||||||
<button class="btn-login" @click="showLogin = true">登录</button>
|
<button class="btn-login" @click="showLogin = true">登录</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="user-info">
|
<div v-else class="user-info">
|
||||||
<div class="user-avatar">A</div>
|
<div class="user-avatar">{{ isAdmin ? 'A' : 'U' }}</div>
|
||||||
<span class="user-name">管理员</span>
|
<span class="user-name">{{ isAdmin ? '管理员' : '用户' }}</span>
|
||||||
<button class="btn-logout" @click="logout">退出</button>
|
<button class="btn-logout" @click="logout">退出</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<template v-if="isLoggedIn">
|
||||||
<router-view />
|
<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 v-if="showLogin" class="modal-overlay" @click.self="showLogin = false">
|
||||||
<div class="login-modal">
|
<div class="login-modal">
|
||||||
<h2>管理员登录</h2>
|
<h2>用户登录</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>用户名</label>
|
<label>用户名</label>
|
||||||
<input v-model="loginForm.username" type="text" placeholder="请输入用户名">
|
<input v-model="loginForm.username" type="text" placeholder="请输入用户名" @keyup.enter="login">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码">
|
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="login">
|
||||||
</div>
|
</div>
|
||||||
<button class="login-btn" @click="login">登录</button>
|
<button class="login-btn" @click="login">登录</button>
|
||||||
<div class="login-hint">默认账号: admin / admin123</div>
|
<div class="login-hint">默认账号: admin / admin123</div>
|
||||||
@@ -41,12 +52,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, provide } from 'vue'
|
||||||
|
|
||||||
const isLoggedIn = ref(false)
|
const isLoggedIn = ref(false)
|
||||||
|
const isAdmin = ref(false)
|
||||||
const showLogin = ref(false)
|
const showLogin = ref(false)
|
||||||
const loginForm = ref({ username: '', password: '' })
|
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 () => {
|
const login = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/login', {
|
const res = await fetch('/api/auth/login', {
|
||||||
@@ -55,9 +78,11 @@ const login = async () => {
|
|||||||
body: JSON.stringify(loginForm.value),
|
body: JSON.stringify(loginForm.value),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
isLoggedIn.value = true
|
isLoggedIn.value = true
|
||||||
|
isAdmin.value = data.is_admin
|
||||||
showLogin.value = false
|
showLogin.value = false
|
||||||
localStorage.setItem('isAdmin', 'true')
|
localStorage.setItem('authToken', data.token)
|
||||||
} else {
|
} else {
|
||||||
alert('用户名或密码错误')
|
alert('用户名或密码错误')
|
||||||
}
|
}
|
||||||
@@ -68,13 +93,33 @@ const login = async () => {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
isLoggedIn.value = false
|
isLoggedIn.value = false
|
||||||
localStorage.removeItem('isAdmin')
|
isAdmin.value = false
|
||||||
|
localStorage.removeItem('authToken')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查登录状态
|
// 检查登录状态(验证 token 有效性)
|
||||||
if (localStorage.getItem('isAdmin') === 'true') {
|
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
|
isLoggedIn.value = true
|
||||||
}
|
isAdmin.value = data.is_admin
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('authToken')
|
||||||
|
showLogin.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络错误时保持当前状态
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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.router-link-active { background: #16213e; color: white; }
|
||||||
.nav a:hover { color: white; }
|
.nav a:hover { color: white; }
|
||||||
.user-info { display: flex; align-items: center; gap: 10px; }
|
.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; }
|
.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-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 { 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; }
|
.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; }
|
.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 { background: white; border-radius: 12px; padding: 32px; width: 400px; }
|
||||||
.login-modal h2 { font-size: 20px; margin-bottom: 24px; text-align: center; color: #1a1a2e; }
|
.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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
import { ref, onMounted, nextTick, watch, onUnmounted, inject } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
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 apps = ref({})
|
||||||
const schemes = ref({})
|
const schemes = ref({})
|
||||||
const branches = ref(['main'])
|
const branches = ref(['main'])
|
||||||
@@ -164,7 +174,7 @@ let skipNextConnect = false
|
|||||||
|
|
||||||
const fetchTaskDetail = async (taskId) => {
|
const fetchTaskDetail = async (taskId) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tasks/${taskId}`)
|
const res = await authFetch(`/api/tasks/${taskId}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
completedTask.value = await res.json()
|
completedTask.value = await res.json()
|
||||||
}
|
}
|
||||||
@@ -175,7 +185,7 @@ const fetchTaskDetail = async (taskId) => {
|
|||||||
|
|
||||||
const fetchBuildLog = async (taskId) => {
|
const fetchBuildLog = async (taskId) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tasks/${taskId}/log`)
|
const res = await authFetch(`/api/tasks/${taskId}/log`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.log) {
|
if (data.log) {
|
||||||
@@ -257,10 +267,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
|
const [appsRes, schemesRes, branchesRes, tasksRes] = await Promise.all([
|
||||||
fetch('/api/apps'),
|
authFetch('/api/apps'),
|
||||||
fetch('/api/schemes'),
|
authFetch('/api/schemes'),
|
||||||
fetch('/api/branches'),
|
authFetch('/api/branches'),
|
||||||
fetch('/api/tasks'),
|
authFetch('/api/tasks'),
|
||||||
])
|
])
|
||||||
apps.value = await appsRes.json()
|
apps.value = await appsRes.json()
|
||||||
schemes.value = await schemesRes.json()
|
schemes.value = await schemesRes.json()
|
||||||
@@ -302,7 +312,7 @@ const submitTask = async () => {
|
|||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tasks', {
|
const res = await authFetch('/api/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(form.value),
|
body: JSON.stringify(form.value),
|
||||||
@@ -329,7 +339,7 @@ const selectTask = (taskId) => {
|
|||||||
const cancelTask = async () => {
|
const cancelTask = async () => {
|
||||||
if (!currentTaskId.value) return
|
if (!currentTaskId.value) return
|
||||||
if (!confirm('确定取消当前任务?')) 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) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
alert(err.detail || '取消失败')
|
alert(err.detail || '取消失败')
|
||||||
@@ -342,7 +352,7 @@ const cancelTask = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshTasks = 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()
|
const all = await res.json()
|
||||||
// 显示未完成的任务 + 当前选中的任务
|
// 显示未完成的任务 + 当前选中的任务
|
||||||
tasks.value = all.filter(t =>
|
tasks.value = all.filter(t =>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div v-if="!isLoggedIn" class="access-denied">
|
<div v-if="!isAdmin" class="access-denied">
|
||||||
<h3>需要管理员权限</h3>
|
<h3>需要管理员权限</h3>
|
||||||
<p>请先登录管理员账号以访问配置管理页面</p>
|
<p>{{ isLoggedIn ? '当前账号无管理员权限' : '请先登录管理员账号' }}</p>
|
||||||
<button class="btn-login" @click="$root.showLogin = true">登录管理员账号</button>
|
<button v-if="!isLoggedIn" class="btn-login" @click="showLogin = true">登录管理员账号</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="admin-page">
|
<div v-else class="admin-page">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<h3>配置管理</h3>
|
<h3>配置管理</h3>
|
||||||
<ul class="sidebar-menu">
|
<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 === 'servers' }" @click="tab = 'servers'">服务器环境</li>
|
||||||
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
<li :class="{ active: tab === 'apps' }" @click="tab = 'apps'">Apps 配置</li>
|
||||||
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
<li :class="{ active: tab === 'schemes' }" @click="tab = 'schemes'">Schemes 配置</li>
|
||||||
@@ -19,6 +20,42 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="main-content">
|
<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'">
|
<div v-if="tab === 'servers'">
|
||||||
<h2>服务器环境配置</h2>
|
<h2>服务器环境配置</h2>
|
||||||
@@ -275,6 +312,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -443,10 +526,30 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, inject } from 'vue'
|
||||||
|
|
||||||
const isLoggedIn = ref(localStorage.getItem('isAdmin') === 'true')
|
const showLogin = inject('showLogin')
|
||||||
const tab = ref('servers')
|
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 apps = ref({})
|
||||||
const schemes = ref({})
|
const schemes = ref({})
|
||||||
const servers = ref({})
|
const servers = ref({})
|
||||||
@@ -474,15 +577,16 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes] = await Promise.all([
|
const [appsRes, schemesRes, serversRes, branchesRes, buildRes, uploadRes, configRes, versionsRes, usersRes] = await Promise.all([
|
||||||
fetch('/api/config/apps'),
|
authFetch('/api/config/apps'),
|
||||||
fetch('/api/config/schemes'),
|
authFetch('/api/config/schemes'),
|
||||||
fetch('/api/config/servers'),
|
authFetch('/api/config/servers'),
|
||||||
fetch('/api/config/branches'),
|
authFetch('/api/config/branches'),
|
||||||
fetch('/api/config/build'),
|
authFetch('/api/config/build'),
|
||||||
fetch('/api/config/upload'),
|
authFetch('/api/config/upload'),
|
||||||
fetch('/api/config'),
|
authFetch('/api/config'),
|
||||||
fetch('/api/config/versions'),
|
authFetch('/api/config/versions'),
|
||||||
|
authFetch('/api/users'),
|
||||||
])
|
])
|
||||||
apps.value = await appsRes.json()
|
apps.value = await appsRes.json()
|
||||||
schemes.value = await schemesRes.json()
|
schemes.value = await schemesRes.json()
|
||||||
@@ -492,6 +596,88 @@ const loadData = async () => {
|
|||||||
uploadConfig.value = await uploadRes.json()
|
uploadConfig.value = await uploadRes.json()
|
||||||
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
jsonContent.value = JSON.stringify(await configRes.json(), null, 2)
|
||||||
versions.value = await versionsRes.json()
|
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 {
|
try {
|
||||||
if (editingServerName.value) {
|
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',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(serverForm.value),
|
body: JSON.stringify(serverForm.value),
|
||||||
@@ -519,7 +705,7 @@ const saveServer = async () => {
|
|||||||
throw new Error(err.detail || '保存失败')
|
throw new Error(err.detail || '保存失败')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/config/servers', {
|
const res = await authFetch('/api/config/servers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(serverForm.value),
|
body: JSON.stringify(serverForm.value),
|
||||||
@@ -540,7 +726,7 @@ const saveServer = async () => {
|
|||||||
const deleteServer = async (name) => {
|
const deleteServer = async (name) => {
|
||||||
if (!confirm(`确定删除环境「${name}」?`)) return
|
if (!confirm(`确定删除环境「${name}」?`)) return
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
const err = await res.json()
|
const err = await res.json()
|
||||||
throw new Error(err.detail || '删除失败')
|
throw new Error(err.detail || '删除失败')
|
||||||
@@ -626,13 +812,13 @@ const saveApp = async () => {
|
|||||||
try {
|
try {
|
||||||
let res
|
let res
|
||||||
if (editingAppId.value) {
|
if (editingAppId.value) {
|
||||||
res = await fetch(`/api/config/apps/${editingAppId.value}`, {
|
res = await authFetch(`/api/config/apps/${editingAppId.value}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(cleanData),
|
body: JSON.stringify(cleanData),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
res = await fetch('/api/config/apps', {
|
res = await authFetch('/api/config/apps', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(cleanData),
|
body: JSON.stringify(cleanData),
|
||||||
@@ -652,7 +838,7 @@ const saveApp = async () => {
|
|||||||
|
|
||||||
const deleteApp = async (id) => {
|
const deleteApp = async (id) => {
|
||||||
if (!confirm('确定删除此 App?')) return
|
if (!confirm('确定删除此 App?')) return
|
||||||
await fetch(`/api/config/apps/${id}`, { method: 'DELETE' })
|
await authFetch(`/api/config/apps/${id}`, { method: 'DELETE' })
|
||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,14 +857,14 @@ const saveScheme = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingSchemeId.value) {
|
if (editingSchemeId.value) {
|
||||||
const res = await fetch(`/api/config/schemes/${editingSchemeId.value}`, {
|
const res = await authFetch(`/api/config/schemes/${editingSchemeId.value}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(schemeForm.value),
|
body: JSON.stringify(schemeForm.value),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('保存失败')
|
if (!res.ok) throw new Error('保存失败')
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/config/schemes', {
|
const res = await authFetch('/api/config/schemes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(schemeForm.value),
|
body: JSON.stringify(schemeForm.value),
|
||||||
@@ -695,7 +881,7 @@ const saveScheme = async () => {
|
|||||||
|
|
||||||
const deleteScheme = async (id) => {
|
const deleteScheme = async (id) => {
|
||||||
if (!confirm('确定删除此 Scheme?')) return
|
if (!confirm('确定删除此 Scheme?')) return
|
||||||
await fetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
|
await authFetch(`/api/config/schemes/${id}`, { method: 'DELETE' })
|
||||||
await loadData()
|
await loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +890,7 @@ const addBranch = async () => {
|
|||||||
const name = newBranch.value.trim()
|
const name = newBranch.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/config/branches', {
|
const res = await authFetch('/api/config/branches', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
@@ -723,7 +909,7 @@ const addBranch = async () => {
|
|||||||
const deleteBranch = async (name) => {
|
const deleteBranch = async (name) => {
|
||||||
if (!confirm(`确定删除分支「${name}」?`)) return
|
if (!confirm(`确定删除分支「${name}」?`)) return
|
||||||
try {
|
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('删除失败')
|
if (!res.ok) throw new Error('删除失败')
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -733,7 +919,7 @@ const deleteBranch = async (name) => {
|
|||||||
|
|
||||||
// 其他
|
// 其他
|
||||||
const saveBuildSettings = async () => {
|
const saveBuildSettings = async () => {
|
||||||
await fetch('/api/config/build', {
|
await authFetch('/api/config/build', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(buildSettings.value),
|
body: JSON.stringify(buildSettings.value),
|
||||||
@@ -743,7 +929,7 @@ const saveBuildSettings = async () => {
|
|||||||
|
|
||||||
const saveVersions = async () => {
|
const saveVersions = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/config/versions', {
|
const res = await authFetch('/api/config/versions', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(versions.value),
|
body: JSON.stringify(versions.value),
|
||||||
@@ -763,7 +949,7 @@ const saveVersions = async () => {
|
|||||||
|
|
||||||
const saveUploadConfig = async () => {
|
const saveUploadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/config/upload', {
|
await authFetch('/api/config/upload', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(uploadConfig.value),
|
body: JSON.stringify(uploadConfig.value),
|
||||||
@@ -777,7 +963,7 @@ const saveUploadConfig = async () => {
|
|||||||
const saveJson = async () => {
|
const saveJson = async () => {
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(jsonContent.value)
|
const config = JSON.parse(jsonContent.value)
|
||||||
await fetch('/api/config', {
|
await authFetch('/api/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<template v-if="task.status === 'completed' && task.oss_url">
|
<template v-if="task.status === 'completed' && task.oss_url">
|
||||||
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
||||||
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
: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>
|
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="text-muted">-</span>
|
<span v-else class="text-muted">-</span>
|
||||||
@@ -75,6 +75,22 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
<Transition name="modal">
|
||||||
<div v-if="showLogModal" class="modal-overlay" @click.self="closeLogModal">
|
<div v-if="showLogModal" class="modal-overlay" @click.self="closeLogModal">
|
||||||
@@ -144,9 +160,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 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 filterBuildType = ref('')
|
||||||
const filterStatus = ref('')
|
const filterStatus = ref('')
|
||||||
const showLogModal = ref(false)
|
const showLogModal = ref(false)
|
||||||
@@ -154,8 +179,13 @@ const logTask = ref(null)
|
|||||||
const logLines = ref([])
|
const logLines = ref([])
|
||||||
const logContainer = ref(null)
|
const logContainer = ref(null)
|
||||||
const showVerboseLogs = ref(false)
|
const showVerboseLogs = ref(false)
|
||||||
|
const qrPreview = ref(null)
|
||||||
let logWs = null
|
let logWs = null
|
||||||
|
|
||||||
|
const showQrPreview = (task) => {
|
||||||
|
qrPreview.value = task
|
||||||
|
}
|
||||||
|
|
||||||
const filteredTasks = computed(() => {
|
const filteredTasks = computed(() => {
|
||||||
return tasks.value.filter(task => {
|
return tasks.value.filter(task => {
|
||||||
// 默认隐藏已取消的任务
|
// 默认隐藏已取消的任务
|
||||||
@@ -167,7 +197,7 @@ const filteredTasks = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await fetch('/api/tasks?limit=100')
|
const res = await authFetch('/api/tasks?limit=100')
|
||||||
tasks.value = await res.json()
|
tasks.value = await res.json()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -185,7 +215,7 @@ const viewLogs = async (taskId) => {
|
|||||||
} else {
|
} else {
|
||||||
// 已完成/失败的任务,先从 REST API 获取完整日志
|
// 已完成/失败的任务,先从 REST API 获取完整日志
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tasks/${taskId}/log`)
|
const res = await authFetch(`/api/tasks/${taskId}/log`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.log) {
|
if (data.log) {
|
||||||
@@ -268,7 +298,7 @@ const downloadObfMaps = (taskId) => {
|
|||||||
const deleteTask = async (taskId) => {
|
const deleteTask = async (taskId) => {
|
||||||
if (!confirm('确定要删除这条打包记录吗?')) return
|
if (!confirm('确定要删除这条打包记录吗?')) return
|
||||||
try {
|
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)
|
if (res.ok) tasks.value = tasks.value.filter(t => t.id !== taskId)
|
||||||
} catch {}
|
} 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, .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 th { background: #fafafa; font-weight: 500; color: #666; font-size: 13px; }
|
||||||
.config-table td { font-size: 14px; }
|
.config-table td { font-size: 14px; }
|
||||||
.config-table th:nth-child(1) { width: 16%; }
|
.config-table th:nth-child(1) { width: 14%; }
|
||||||
.config-table th:nth-child(2) { width: 18%; }
|
.config-table th:nth-child(2) { width: 14%; }
|
||||||
.config-table th:nth-child(3) { width: 10%; }
|
.config-table th:nth-child(3) { width: 8%; }
|
||||||
.config-table th:nth-child(4) { width: 12%; }
|
.config-table th:nth-child(4) { width: 10%; }
|
||||||
.config-table th:nth-child(5) { width: 12%; }
|
.config-table th:nth-child(5) { width: 10%; }
|
||||||
.config-table th:nth-child(6) { width: 18%; }
|
.config-table th:nth-child(6) { width: 16%; }
|
||||||
.config-table th:nth-child(7) { width: 14%; }
|
.config-table th:nth-child(7) { width: 28%; }
|
||||||
.config-table tr:hover { background: #fafafa; }
|
.config-table tr:hover { background: #fafafa; }
|
||||||
|
|
||||||
.build-type-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
.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; }
|
.qr-thumb:hover { border-color: #1890ff; }
|
||||||
.btn-danger { color: #ff4d4f; border-color: #ff4d4f; }
|
.btn-danger { color: #ff4d4f; border-color: #ff4d4f; }
|
||||||
.btn-danger:hover { background: #ff4d4f; color: white; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ uvicorn[standard]>=0.24.0
|
|||||||
sqlalchemy>=2.0.0
|
sqlalchemy>=2.0.0
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
|
PyJWT>=2.8.0
|
||||||
|
httpx>=0.25.0
|
||||||
|
|||||||
Reference in New Issue
Block a user