Revert "Build WebUI during Vercel deployment and enforce Cache-Control for admin UI"

This commit is contained in:
CJACK.
2026-02-04 18:25:04 +08:00
committed by GitHub
parent 2007e0cde3
commit 58d54adee2
6 changed files with 304 additions and 40 deletions

76
.github/workflows/build-webui.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
# 自动构建 WebUI 并提交构建产物
# 触发条件webui 目录下的文件变更
name: Build WebUI
on:
push:
branches:
- main
paths:
- 'webui/**'
- '.github/workflows/build-webui.yml'
pull_request:
branches:
- main
paths:
- 'webui/**'
# 允许手动触发
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
# 只在主仓库运行,避免 fork 仓库运行
if: github.repository == 'CJackHwang/ds2api'
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: webui/package-lock.json
- name: Install dependencies
working-directory: webui
run: npm ci
- name: Build WebUI
working-directory: webui
run: npm run build
- name: Check for changes
id: check_changes
run: |
git add static/admin
if git diff --staged --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Commit and push changes
if: steps.check_changes.outputs.changed == 'true' && github.event_name == 'push'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "chore: auto-build WebUI [skip ci]"
git push
- name: Upload build artifacts (for PR review)
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: webui-build
path: static/admin
retention-days: 7

View File

@@ -131,8 +131,8 @@ WebUI 开发服务器会启动在 `http://localhost:5173`,并自动代理 API
WebUI 构建产物位于 `static/admin/` 目录。
**自动构建(推荐)**
-前由 Vercel 在部署时执行 WebUI 构建(见 `vercel.json``buildCommand`
- GitHub Actions 的 WebUI 自动构建流程已关闭
- `webui/` 目录下的文件变更并推送到 `main` 分支时GitHub Actions 会自动构建并提交产物
- PR 合并时会自动触发构建
**手动构建**
```bash
@@ -145,7 +145,7 @@ npm install
npm run build
```
> **贡献者注意**:修改 WebUI 后无需手动构建,Vercel 部署会自动构建
> **贡献者注意**:修改 WebUI 后无需手动构建,CI 会自动处理
---

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Admin 账号管理模块 - 账号测试与导入"""
"""Admin 账号管理模块 - 账号验证和测试"""
import asyncio
import json
import base64
@@ -24,6 +24,145 @@ from .auth import verify_admin
router = APIRouter()
# ----------------------------------------------------------------------
# 账号验证
# ----------------------------------------------------------------------
async def validate_single_account(account: dict) -> dict:
"""验证单个账号的有效性"""
acc_id = get_account_identifier(account)
result = {
"account": acc_id,
"valid": False,
"has_token": bool(account.get("token", "").strip()),
"message": "",
}
def _is_token_invalid(status_code: int, data: dict) -> bool:
msg = (data.get("msg") or data.get("message") or "").lower()
code = data.get("code")
return status_code in {401, 403} or code in {40001, 40002, 40003} or "token" in msg or "unauthorized" in msg
def _create_session(token: str) -> dict:
headers = {**BASE_HEADERS, "authorization": f"Bearer {token}"}
try:
session_resp = cffi_requests.post(
DEEPSEEK_CREATE_SESSION_URL,
headers=headers,
json={"agent": "chat"},
impersonate="safari15_3",
timeout=15,
)
except Exception as e:
return {"success": False, "message": f"请求异常: {e}", "status_code": 0, "data": {}}
try:
data = session_resp.json()
except Exception:
data = {}
finally:
session_resp.close()
if session_resp.status_code == 200 and data.get("code") == 0:
return {
"success": True,
"session_id": data.get("data", {}).get("biz_data", {}).get("id"),
"status_code": session_resp.status_code,
"data": data,
}
return {
"success": False,
"message": data.get("msg") or f"HTTP {session_resp.status_code}",
"status_code": session_resp.status_code,
"data": data,
}
try:
token = account.get("token", "").strip()
if token:
session_result = _create_session(token)
if session_result["success"]:
result["valid"] = True
result["message"] = "Token 有效"
return result
if _is_token_invalid(session_result["status_code"], session_result["data"]):
token = ""
account["token"] = ""
if not token:
try:
login_deepseek_via_account(account)
token = account.get("token", "").strip()
session_result = _create_session(token)
if session_result["success"]:
result["valid"] = True
result["has_token"] = True
result["message"] = "登录成功并验证通过"
else:
result["message"] = f"登录成功但验证失败: {session_result['message']}"
except Exception as e:
result["valid"] = False
result["message"] = f"登录失败: {str(e)}"
except Exception as e:
result["message"] = f"验证出错: {str(e)}"
return result
@router.post("/accounts/validate")
async def validate_account(request: Request, _: bool = Depends(verify_admin)):
"""验证单个账号"""
data = await request.json()
identifier = data.get("identifier", "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="需要账号标识email 或 mobile")
account = None
for acc in CONFIG.get("accounts", []):
if acc.get("email") == identifier or acc.get("mobile") == identifier:
account = acc
break
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
result = await validate_single_account(account)
if result["valid"] and result["has_token"]:
save_config(CONFIG)
return JSONResponse(content=result)
@router.post("/accounts/validate-all")
async def validate_all_accounts(_: bool = Depends(verify_admin)):
"""批量验证所有账号"""
accounts = CONFIG.get("accounts", [])
if not accounts:
return JSONResponse(content={
"total": 0, "valid": 0, "invalid": 0, "results": [],
})
results = []
valid_count = 0
for acc in accounts:
result = await validate_single_account(acc)
results.append(result)
if result["valid"]:
valid_count += 1
await asyncio.sleep(0.5)
save_config(CONFIG)
return JSONResponse(content={
"total": len(accounts),
"valid": valid_count,
"invalid": len(accounts) - valid_count,
"results": results,
})
# ----------------------------------------------------------------------
# 账号 API 测试
# ----------------------------------------------------------------------

View File

@@ -290,19 +290,14 @@ async def webui(request: Request, path: str = ""):
if path and "." in path:
file_path = os.path.join(STATIC_ADMIN_DIR, path)
if os.path.isfile(file_path):
cache_control = "public, max-age=31536000, immutable"
if path.startswith("assets/"):
headers = {"Cache-Control": cache_control}
else:
headers = {"Cache-Control": "no-store, must-revalidate"}
return FileResponse(file_path, headers=headers)
return FileResponse(file_path)
return HTMLResponse(content="Not Found", status_code=404)
# 否则返回 index.htmlSPA 路由)
index_path = os.path.join(STATIC_ADMIN_DIR, "index.html")
if os.path.isfile(index_path):
headers = {"Cache-Control": "no-store, must-revalidate"}
return FileResponse(index_path, headers=headers)
return FileResponse(index_path)
return HTMLResponse(content="index.html not found", status_code=404)

View File

@@ -6,31 +6,10 @@
"use": "@vercel/python"
}
],
"buildCommand": "bash scripts/build-webui.sh",
"rewrites": [
"routes": [
{
"source": "/(.*)",
"destination": "/app.py"
}
],
"headers": [
{
"source": "/admin/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/admin/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "no-store, must-revalidate"
}
]
"src": "/(.*)",
"dest": "app.py"
}
]
}
}

View File

@@ -2,8 +2,12 @@ import { useState, useEffect } from 'react'
import {
Plus,
Trash2,
RefreshCw,
CheckCircle2,
AlertCircle,
Search,
Play,
MoreHorizontal,
X,
Server,
ShieldCheck,
@@ -19,6 +23,8 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
const [copiedKey, setCopiedKey] = useState(null)
const [newAccount, setNewAccount] = useState({ email: '', mobile: '', password: '' })
const [loading, setLoading] = useState(false)
const [validating, setValidating] = useState({})
const [validatingAll, setValidatingAll] = useState(false)
const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
@@ -127,6 +133,60 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
}
}
const validateAccount = async (identifier) => {
setValidating(prev => ({ ...prev, [identifier]: true }))
try {
const res = await apiFetch('/admin/accounts/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }),
})
const data = await res.json()
onMessage(data.valid ? 'success' : 'error', `${identifier}: ${data.message}`)
onRefresh()
} catch (e) {
onMessage('error', 'Validation failed: ' + e.message)
} finally {
setValidating(prev => ({ ...prev, [identifier]: false }))
}
}
const validateAllAccounts = async () => {
if (!confirm('校验所有账号?这可能需要一些时间。')) return
const accounts = config.accounts || []
if (accounts.length === 0) return
setValidatingAll(true)
setBatchProgress({ current: 0, total: accounts.length, results: [] })
let validCount = 0
const results = []
for (let i = 0; i < accounts.length; i++) {
const acc = accounts[i]
const id = acc.email || acc.mobile
try {
const res = await apiFetch('/admin/accounts/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),
})
const data = await res.json()
results.push({ id, success: data.valid, message: data.message })
if (data.valid) validCount++
} catch (e) {
results.push({ id, success: false, message: e.message })
}
setBatchProgress({ current: i + 1, total: accounts.length, results: [...results] })
}
onMessage('success', `Completed: ${validCount}/${accounts.length} valid`)
onRefresh()
setValidatingAll(false)
}
const testAccount = async (identifier) => {
setTesting(prev => ({ ...prev, [identifier]: true }))
try {
@@ -287,12 +347,20 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
<div className="flex flex-wrap gap-2">
<button
onClick={testAllAccounts}
disabled={testingAll || !config.accounts?.length}
disabled={testingAll || validatingAll || !config.accounts?.length}
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
>
{testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />}
测试全部
</button>
<button
onClick={validateAllAccounts}
disabled={validatingAll || testingAll || !config.accounts?.length}
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
>
{validatingAll ? <span className="animate-spin mr-2"></span> : <CheckCircle2 className="w-3 h-3 mr-2" />}
校验全部
</button>
<button
onClick={() => setShowAddAccount(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
@@ -304,10 +372,10 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
</div>
{/* Batch Progress */}
{testingAll && batchProgress.total > 0 && (
{(testingAll || validatingAll) && batchProgress.total > 0 && (
<div className="p-4 border-b border-border bg-muted/30">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium">正在测试所有账号...</span>
<span className="font-medium">{testingAll ? '正在测试所有账号...' : '正在校验所有账号...'}</span>
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
</div>
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
@@ -362,6 +430,13 @@ export default function AccountManager({ config, onRefresh, onMessage, authFetch
>
{testing[id] ? '正在测试...' : '测试'}
</button>
<button
onClick={() => validateAccount(id)}
disabled={validating[id]}
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
>
{validating[id] ? '正在校验...' : '校验'}
</button>
<button
onClick={() => deleteAccount(id)}
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"