7 Commits
Author SHA1 Message Date
shenandshen 29a4dd91ad fix: 调整历史表列宽,操作列增至 28% 避免按钮显示不全 2026-06-08 21:21:27 +08:00
shenandshen 764fd18fd2 fix: 二维码放大弹窗关闭按钮定位到右上角 2026-06-08 21:18:16 +08:00
shenandshen bdd79e3302 feat: 点击二维码缩略图可放大查看 2026-06-08 21:16:09 +08:00
shenandshen 48fdca12bd feat: 多用户管理系统,管理员可分配用户
- 新增 users 数据库表,存储用户名、密码哈希、角色
- 启动时自动从 .env 创建初始管理员账号
- 登录改为数据库校验,不再硬编码比对
- 新增 /api/users 管理接口(仅管理员)
- ConfigView 新增用户管理页(创建、改密码、切换角色、删除)
2026-06-08 21:08:16 +08:00
shenandshen 1cecf66d94 feat: 全站登录保护,管理后台仅管理员可访问
- tasks 路由级认证保护,所有 API 需登录
- 未登录时显示登录页面,不暴露导航和功能
- ConfigView 仅管理员可访问,普通用户显示权限提示
- BuildView/HistoryView 所有 API 请求携带 token
2026-06-08 20:59:13 +08:00
shenandshen a0c857f3c2 fix: 钉钉通知仅在打包成功时发送 2026-06-08 20:43:21 +08:00
shenandshen e18f33d60f fix: 认证系统、登录兼容性、运行时配置、钉钉通知及代码清理
- 实现 JWT 认证中间件,保护配置管理和任务删除接口
- 修复 ConfigView 登录按钮(Vue 3 inject 替代 $root)
- 页面刷新时通过 /api/auth/me 校验 token 有效性
- max_concurrent_builds 修改后运行时即时生效
- 实现钉钉 webhook 通知(构建成功/失败自动推送)
- 删除未使用的 useWebSocket composable
- log_streamer 使用 LOG_QUEUE_MAX_SIZE 配置常量
2026-06-08 20:35:05 +08:00
19 changed files with 671 additions and 130 deletions
+5 -1
View File
@@ -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
+17
View File
@@ -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()
+20
View File
@@ -0,0 +1,20 @@
"""认证依赖"""
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import JWT_SECRET, JWT_ALGORITHM
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""验证 JWT token,返回用户信息"""
token = credentials.credentials
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return {"username": payload["sub"], "is_admin": payload.get("is_admin", False)}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭据")
+2 -1
View File
@@ -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)
+11
View File
@@ -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
View File
@@ -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"]}
+8 -1
View File
@@ -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": "打包设置已更新"}
+2 -1
View File
@@ -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)
+103
View File
@@ -0,0 +1,103 @@
"""用户管理 API(仅管理员)"""
import uuid
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User
from ..deps import get_current_user
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(get_current_user)])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
@router.get("")
async def list_users(user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取用户列表"""
_require_admin(user)
users = db.query(User).order_by(User.created_at.desc()).all()
return [{"id": u.id, "username": u.username, "is_admin": u.is_admin, "created_at": str(u.created_at) if u.created_at else None} for u in users]
@router.post("")
async def create_user(data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建用户"""
_require_admin(user)
username = data.get("username", "").strip()
password = data.get("password", "").strip()
is_admin = data.get("is_admin", False)
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
if len(password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
if db.query(User).filter(User.username == username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
new_user = User(
id=str(uuid.uuid4()),
username=username,
password_hash=_hash_password(password),
is_admin=is_admin,
)
db.add(new_user)
db.commit()
return {"message": "用户已创建", "id": new_user.id}
@router.put("/{user_id}/password")
async def change_password(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""修改密码(管理员可改任意用户)"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
new_password = data.get("password", "").strip()
if not new_password or len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少 6 位")
target.password_hash = _hash_password(new_password)
db.commit()
return {"message": "密码已更新"}
@router.put("/{user_id}/role")
async def change_role(user_id: str, data: dict, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""切换管理员状态"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
target.is_admin = data.get("is_admin", not target.is_admin)
db.commit()
return {"message": "角色已更新", "is_admin": target.is_admin}
@router.delete("/{user_id}")
async def delete_user(user_id: str, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除用户"""
_require_admin(user)
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="用户不存在")
if target.username == user["username"]:
raise HTTPException(status_code=400, detail="不能删除自己")
db.delete(target)
db.commit()
return {"message": "用户已删除"}
+8 -3
View File
@@ -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
View File
@@ -309,6 +309,11 @@ async def run_build_task(task_id: str):
if task.oss_url: if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}") await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# 钉钉通知
from .notification import notify_build_result
dingtalk = full_config.get("upload", {}).get("dingtalk", {})
await notify_build_result(task, dingtalk)
# 带超时执行打包 # 带超时执行打包
await asyncio.wait_for(_do_build(), timeout=timeout_seconds) await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
+3 -1
View File
@@ -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
+93
View File
@@ -0,0 +1,93 @@
"""钉钉通知服务"""
import asyncio
import hashlib
import hmac
import base64
import time
import urllib.parse
import json
import logging
logger = logging.getLogger(__name__)
def _sign(secret: str) -> tuple[str, str]:
"""生成钉钉加签参数"""
timestamp = str(round(time.time() * 1000))
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
return timestamp, sign
async def send_dingtalk(webhook_url: str, title: str, text: str, secret: str = ""):
"""发送钉钉 Markdown 通知"""
import httpx
url = webhook_url
if secret:
timestamp, sign = _sign(secret)
url = f"{url}&timestamp={timestamp}&sign={sign}"
payload = {
"msgtype": "markdown",
"markdown": {"title": title, "text": text},
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=payload)
data = resp.json()
if data.get("errcode") != 0:
logger.warning(f"钉钉通知发送失败: {data}")
except Exception as e:
logger.warning(f"钉钉通知异常: {e}")
async def notify_build_result(task, dingtalk_config: dict):
"""发送打包结果通知"""
if not dingtalk_config.get("enabled") or not dingtalk_config.get("webhook_url"):
return
status = task.status
app_name = task.app_name
build_type = task.build_type
scheme_name = task.scheme_name
if status == "completed":
title = f"{app_name} 打包成功"
lines = [
f"### ✅ {app_name} 打包成功",
f"- **应用**: {app_name}",
f"- **类型**: {build_type}",
f"- **Scheme**: {scheme_name}",
f"- **分支**: {task.branch}",
]
if task.oss_url:
lines.append(f"- **下载链接**: [点击下载]({task.oss_url})")
text = "\n".join(lines)
elif status == "failed":
title = f"{app_name} 打包失败"
error = task.error_message or "未知错误"
lines = [
f"### ❌ {app_name} 打包失败",
f"- **应用**: {app_name}",
f"- **类型**: {build_type}",
f"- **Scheme**: {scheme_name}",
f"- **分支**: {task.branch}",
f"- **错误**: {error}",
]
text = "\n".join(lines)
else:
return
await send_dingtalk(
dingtalk_config["webhook_url"],
title,
text,
dingtalk_config.get("secret", ""),
)
+64 -14
View File
@@ -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; }
-42
View File
@@ -1,42 +0,0 @@
import { ref, onUnmounted } from 'vue'
export function useWebSocket(taskId) {
const logs = ref([])
const connected = ref(false)
let ws = null
const connect = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${protocol}//${location.host}/ws/tasks/${taskId}`)
ws.onopen = () => {
connected.value = true
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.level !== 'heartbeat') {
logs.value.push(msg)
}
}
ws.onclose = () => {
connected.value = false
}
ws.onerror = () => {
connected.value = false
}
}
const disconnect = () => {
if (ws) {
ws.close()
ws = null
}
}
onUnmounted(disconnect)
return { logs, connected, connect, disconnect }
}
+20 -10
View File
@@ -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 =>
+216 -30
View File
@@ -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">&times;</button>
</div>
<div class="form-group">
<label>用户名 *</label>
<input v-model="userForm.username" type="text" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码 *</label>
<input v-model="userForm.password" type="password" placeholder="至少 6 位">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="userForm.is_admin" style="width: 16px; height: 16px;">
管理员权限
</label>
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showUserModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="createUser">创建</button>
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div v-if="showPasswordModal" class="modal-overlay" @click.self="showPasswordModal = false">
<div class="modal-content" style="width: 400px;">
<div class="modal-header">
<h3>修改密码 - {{ passwordTarget?.username }}</h3>
<button class="modal-close" @click="showPasswordModal = false">&times;</button>
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" placeholder="至少 6 位">
</div>
<div style="margin-top: 20px; display: flex; gap: 12px; justify-content: flex-end;">
<button class="action-btn" style="padding: 8px 16px;" @click="showPasswordModal = false">取消</button>
<button class="btn btn-primary" style="width: auto; padding: 8px 24px;" @click="changePassword">确认</button>
</div>
</div>
</div>
<!-- 服务器环境编辑弹窗 --> <!-- 服务器环境编辑弹窗 -->
<div v-if="showServerModal" class="modal-overlay" @click.self="showServerModal = false"> <div 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),
+51 -12
View File
@@ -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">&times;</button>
</div>
<img :src="qrPreview.qr_code_path" alt="QR Code" class="qr-preview-img">
<div class="qr-preview-info">
<p><strong>App:</strong> {{ qrPreview.app_name }}</p>
<p><strong>Scheme:</strong> {{ qrPreview.scheme_name }}</p>
<p><strong>类型:</strong> {{ qrPreview.build_type }}</p>
</div>
</div>
</div>
<!-- 日志弹窗 --> <!-- 日志弹窗 -->
<Transition name="modal"> <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>
+2
View File
@@ -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