fix: 修改hub构建方式

This commit is contained in:
fawney19
2026-03-02 04:05:40 +08:00
parent b7ef900181
commit 5b9d9452c9
13 changed files with 1349 additions and 406 deletions

View File

@@ -11,6 +11,7 @@ ENV/
.uv/
*.egg-info/
dist/
!aether-hub/dist/aether-hub
build/
*.egg

91
.github/workflows/build-hub.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: Build aether-hub
on:
push:
tags: ['hub-v*']
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-gnu
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-gnu
use_cross: true
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: aether-hub -> target
key: ${{ matrix.target }}
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build
working-directory: aether-hub
shell: bash
run: |
if [ "${{ matrix.use_cross }}" = "true" ]; then
cross build --release --target ${{ matrix.target }}
else
cargo build --release --target ${{ matrix.target }}
fi
- name: Package
shell: bash
run: |
cd aether-hub/target/${{ matrix.target }}/release
chmod +x aether-hub
tar czf ../../../../aether-hub-${{ matrix.name }}.tar.gz aether-hub
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: aether-hub-${{ matrix.name }}
path: aether-hub-*.tar.gz
if-no-files-found: error
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
path: artifacts
- name: Generate checksums
working-directory: artifacts
run: sha256sum aether-hub-* > SHA256SUMS.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "aether-hub ${{ github.ref_name }}"
generate_release_notes: true
files: |
artifacts/aether-hub-*
artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true

View File

@@ -14,8 +14,8 @@ on:
env:
REGISTRY: ghcr.io
BASE_IMAGE_NAME: fawney19/aether-base
HUB_IMAGE_NAME: fawney19/aether-hub
APP_IMAGE_NAME: fawney19/aether
GITHUB_REPO: fawney19/Aether
# Base image hash inputs:
# - Dockerfile.base
# - pyproject.toml (dependency fingerprint only; ignores tool/optional deps)
@@ -175,61 +175,56 @@ jobs:
cache-to: type=gha,mode=max,scope=base
platforms: linux/amd64,linux/arm64
build-hub:
download-hub:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
hub_binary_image: ${{ steps.hub-ref.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for hub image
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.HUB_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=pre,enable=${{ contains(github.ref, '-') }}
type=raw,value=fix,enable=${{ contains(github.ref, '-fix') }}
type=raw,value=${{ github.sha }}
type=sha,prefix=
flavor: |
latest=auto
- name: Build and push hub image
uses: docker/build-push-action@v5
with:
context: ./aether-hub
file: ./aether-hub/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=hub
cache-to: type=gha,mode=max,scope=hub
platforms: linux/amd64,linux/arm64
- name: Export hub binary image reference
id: hub-ref
- name: Get latest hub release tag
id: hub-tag
run: |
echo "image=${{ env.REGISTRY }}/${{ env.HUB_IMAGE_NAME }}:${{ github.sha }}" >> $GITHUB_OUTPUT
TAG=$(curl -sL "https://api.github.com/repos/${{ env.GITHUB_REPO }}/releases" | \
python3 -c "
import json, sys
releases = json.load(sys.stdin)
for r in releases:
tag = r.get('tag_name', '')
if tag.startswith('hub-v') and not r.get('draft') and not r.get('prerelease'):
print(tag)
break
")
if [ -z "$TAG" ]; then
echo "No hub release found"
exit 1
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Hub release tag: $TAG"
- name: Download hub binaries
run: |
TAG="${{ steps.hub-tag.outputs.tag }}"
BASE_URL="https://github.com/${{ env.GITHUB_REPO }}/releases/download/$TAG"
mkdir -p hub-binaries/amd64 hub-binaries/arm64
curl -L --fail -o hub-binaries/amd64/aether-hub-linux-amd64.tar.gz "$BASE_URL/aether-hub-linux-amd64.tar.gz"
curl -L --fail -o hub-binaries/arm64/aether-hub-linux-arm64.tar.gz "$BASE_URL/aether-hub-linux-arm64.tar.gz"
tar xzf hub-binaries/amd64/aether-hub-linux-amd64.tar.gz -C hub-binaries/amd64
tar xzf hub-binaries/arm64/aether-hub-linux-arm64.tar.gz -C hub-binaries/arm64
- name: Upload amd64 binary
uses: actions/upload-artifact@v4
with:
name: hub-binary-amd64
path: hub-binaries/amd64/aether-hub
- name: Upload arm64 binary
uses: actions/upload-artifact@v4
with:
name: hub-binary-arm64
path: hub-binaries/arm64/aether-hub
build-app:
needs: [check-base-changes, build-base, build-hub]
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') && needs.build-hub.result == 'success'
needs: [check-base-changes, build-base, download-hub]
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') && needs.download-hub.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -296,17 +291,53 @@ jobs:
version_tuple = __version_tuple__
EOF
- name: Build and push app image
- name: Download hub binary for amd64
uses: actions/download-artifact@v4
with:
name: hub-binary-amd64
path: aether-hub/dist-amd64
- name: Download hub binary for arm64
uses: actions/download-artifact@v4
with:
name: hub-binary-arm64
path: aether-hub/dist-arm64
- name: Prepare hub binary for build
run: |
# 为当前构建平台准备二进制(多架构构建每个平台分别运行)
# 使用 amd64 作为默认Buildx 会处理多架构
mkdir -p aether-hub/dist
cp aether-hub/dist-amd64/aether-hub aether-hub/dist/aether-hub
chmod +x aether-hub/dist/aether-hub
- name: Build and push app image (amd64)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.app
push: true
build-args: |
HUB_BINARY_IMAGE=${{ needs.build-hub.outputs.hub_binary_image }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache-filters: builder
cache-from: type=gha,scope=app
cache-to: type=gha,mode=min,scope=app
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=app-amd64
cache-to: type=gha,mode=min,scope=app-amd64
platforms: linux/amd64
- name: Swap hub binary for arm64
run: |
cp aether-hub/dist-arm64/aether-hub aether-hub/dist/aether-hub
chmod +x aether-hub/dist/aether-hub
- name: Build and push app image (arm64)
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache-filters: builder
cache-from: type=gha,scope=app-arm64
cache-to: type=gha,mode=min,scope=app-arm64
platforms: linux/arm64

4
.gitignore vendored
View File

@@ -226,6 +226,10 @@ test.py
.deps-hash
.code-hash
.migration-hash
.hub-hash
# Hub prebuilt binaries
aether-hub/dist/
# Version file (auto-generated by hatch-vcs)
src/_version.py

View File

@@ -2,8 +2,6 @@
# 运行镜像:从 base 提取产物到精简运行时
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
# 用于 GitHub Actions CI官方源
ARG HUB_BINARY_IMAGE=ghcr.io/fawney19/aether-hub:latest
FROM ${HUB_BINARY_IMAGE} AS hub-bin
FROM aether-base:latest AS builder
WORKDIR /app
@@ -28,7 +26,9 @@ COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/pytho
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
COPY --from=hub-bin /usr/local/bin/aether-hub /usr/local/bin/
# Hub 预编译二进制(从 GitHub Release 下载)
COPY aether-hub/dist/aether-hub /usr/local/bin/aether-hub
RUN chmod +x /usr/local/bin/aether-hub
# 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
RUN chmod -R 755 /usr/share/nginx/html

View File

@@ -2,8 +2,6 @@
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
# 用于本地/国内服务器部署
ARG HUB_BINARY_IMAGE=aether-hub:local
FROM ${HUB_BINARY_IMAGE} AS hub-bin
FROM aether-base:latest AS builder
@@ -35,7 +33,10 @@ COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/pytho
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
COPY --from=hub-bin /usr/local/bin/aether-hub /usr/local/bin/
# Hub 预编译二进制(从 GitHub Release 下载)
COPY aether-hub/dist/aether-hub /usr/local/bin/aether-hub
RUN chmod +x /usr/local/bin/aether-hub
# 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html

131
aether-hub/build.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/bin/bash
# 本地构建 aether-hub 多架构二进制
#
# 前置条件:
# cargo install cross --git https://github.com/cross-rs/cross
# Docker 或 Podman 运行中cross 需要容器环境)
#
# 用法:
# ./build.sh # 构建 linux/amd64 + linux/arm64
# ./build.sh amd64 # 仅构建 linux/amd64
# ./build.sh arm64 # 仅构建 linux/arm64
# ./build.sh --upload v0.1.0 # 构建并上传到 GitHub Release需要 gh CLI
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
# 解析参数
UPLOAD=false
TAG=""
BUILD_TARGETS=""
while [ $# -gt 0 ]; do
case "$1" in
--upload)
UPLOAD=true
TAG="$2"
shift 2
;;
amd64|arm64)
BUILD_TARGETS="$BUILD_TARGETS $1"
shift
;;
*)
echo "用法: $0 [amd64|arm64] [--upload <tag>]"
exit 1
;;
esac
done
# 默认构建所有平台
if [ -z "$BUILD_TARGETS" ]; then
BUILD_TARGETS="amd64 arm64"
fi
# 检查 cross 是否安装
if ! command -v cross &> /dev/null; then
echo "❌ 需要安装 cross: cargo install cross --git https://github.com/cross-rs/cross"
exit 1
fi
# 创建输出目录
mkdir -p "$DIST_DIR"
echo "🔨 开始构建 aether-hub..."
echo " 目标平台: $BUILD_TARGETS"
echo ""
ARTIFACTS=""
for arch in $BUILD_TARGETS; do
# 映射架构到 Rust target
case "$arch" in
amd64) target="x86_64-unknown-linux-gnu" ;;
arm64) target="aarch64-unknown-linux-gnu" ;;
*) echo "❌ 未知架构: $arch"; exit 1 ;;
esac
echo ">>> 构建 $arch ($target)..."
cd "$SCRIPT_DIR"
cross build --release --target "$target" --locked
# 提取二进制
BIN="target/$target/release/aether-hub"
if [ ! -f "$BIN" ]; then
echo "❌ 未找到二进制文件: $BIN"
exit 1
fi
# 打包
ARCHIVE="$DIST_DIR/aether-hub-linux-$arch.tar.gz"
tar czf "$ARCHIVE" -C "target/$target/release" aether-hub
ARTIFACTS="$ARTIFACTS $ARCHIVE"
SIZE=$(du -h "$ARCHIVE" | cut -f1)
echo "$arch 构建完成: $ARCHIVE ($SIZE)"
echo ""
done
# 生成校验和
cd "$DIST_DIR"
shasum -a 256 aether-hub-*.tar.gz > SHA256SUMS.txt
echo "📋 SHA256 校验和:"
cat SHA256SUMS.txt
echo ""
# 上传到 GitHub Release
if [ "$UPLOAD" = true ]; then
if [ -z "$TAG" ]; then
echo "❌ --upload 需要指定 tag例如: ./build.sh --upload hub-v0.1.0"
exit 1
fi
if ! command -v gh &> /dev/null; then
echo "❌ 需要安装 GitHub CLI: brew install gh"
exit 1
fi
echo "📦 上传到 GitHub Release: $TAG"
cd "$PROJECT_DIR"
# 创建 tag如果不存在
if ! git rev-parse "$TAG" > /dev/null 2>&1; then
git tag "$TAG"
git push origin "$TAG"
fi
# 创建 Release 并上传
gh release create "$TAG" \
--title "aether-hub ${TAG#hub-}" \
--generate-notes \
$ARTIFACTS \
"$DIST_DIR/SHA256SUMS.txt"
echo "✅ 上传完成!"
fi
echo "🎉 全部完成!"

146
deploy.sh
View File

@@ -4,7 +4,7 @@
# 用法:
# 部署/更新: ./deploy.sh (自动检测所有变化)
# 强制重建: ./deploy.sh --rebuild-base
# 强制重建 Hub: ./deploy.sh --rebuild-hub
# 更新 Hub: ./deploy.sh --update-hub
# 强制全部重建: ./deploy.sh --force
set -e
@@ -29,10 +29,14 @@ compose_up() {
# 缓存文件
HASH_FILE=".deps-hash"
HUB_HASH_FILE=".hub-hash"
CODE_HASH_FILE=".code-hash"
MIGRATION_HASH_FILE=".migration-hash"
# Hub 二进制配置
GITHUB_REPO="fawney19/Aether"
HUB_DIST_DIR="aether-hub/dist"
HUB_VERSION_FILE="$HUB_DIST_DIR/.version"
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致):
# - [build-system] requires / build-backend
# - [project] requires-python / dependencies
@@ -75,14 +79,83 @@ calc_code_hash() {
} | md5sum | cut -d' ' -f1
}
# 计算 Hub 文件的哈希值(本地构建 aether-hub:local
calc_hub_hash() {
{
cat aether-hub/Dockerfile 2>/dev/null
cat aether-hub/Cargo.toml 2>/dev/null
cat aether-hub/Cargo.lock 2>/dev/null
find aether-hub/src -type f -name "*.rs" 2>/dev/null | sort | xargs cat 2>/dev/null
} | md5sum | cut -d' ' -f1
# 检测目标平台架构
detect_arch() {
local machine
machine=$(uname -m)
case "$machine" in
x86_64|amd64) echo "amd64" ;;
aarch64|arm64) echo "arm64" ;;
*) echo "❌ 不支持的架构: $machine" >&2; exit 1 ;;
esac
}
# 获取最新 hub release tag
get_latest_hub_tag() {
curl -sL "https://api.github.com/repos/$GITHUB_REPO/releases" | \
python3 -c "
import json, sys
releases = json.load(sys.stdin)
for r in releases:
tag = r.get('tag_name', '')
if tag.startswith('hub-v') and not r.get('draft') and not r.get('prerelease'):
print(tag)
break
" 2>/dev/null
}
# 下载 Hub 预编译二进制
download_hub() {
local tag="$1"
local arch
arch=$(detect_arch)
if [ -z "$tag" ]; then
echo ">>> 正在查询最新 Hub 版本..."
tag=$(get_latest_hub_tag)
if [ -z "$tag" ]; then
echo "❌ 无法获取最新 Hub Release请检查网络或手动指定版本"
exit 1
fi
fi
echo ">>> Hub 版本: $tag, 架构: $arch"
# 检查是否已下载
if [ -f "$HUB_VERSION_FILE" ] && [ "$(cat "$HUB_VERSION_FILE")" = "$tag-$arch" ] && [ -f "$HUB_DIST_DIR/aether-hub" ]; then
echo ">>> Hub 二进制已是最新 ($tag), 跳过下载."
return 0
fi
mkdir -p "$HUB_DIST_DIR"
local archive="aether-hub-linux-$arch.tar.gz"
local url="https://github.com/$GITHUB_REPO/releases/download/$tag/$archive"
echo ">>> 下载 Hub 二进制: $url"
curl -L --fail -o "$HUB_DIST_DIR/$archive" "$url" || {
echo "❌ 下载失败: $url"
exit 1
}
# 解压
tar xzf "$HUB_DIST_DIR/$archive" -C "$HUB_DIST_DIR"
chmod +x "$HUB_DIST_DIR/aether-hub"
rm -f "$HUB_DIST_DIR/$archive"
# 记录版本
echo "$tag-$arch" > "$HUB_VERSION_FILE"
echo "✅ Hub 二进制下载完成."
}
# 确保 Hub 二进制存在
ensure_hub_binary() {
if [ ! -f "$HUB_DIST_DIR/aether-hub" ]; then
echo ">>> Hub 二进制不存在,正在下载..."
download_hub
return 0
fi
return 1
}
# 计算迁移文件的哈希值
@@ -114,17 +187,7 @@ check_code_changed() {
return 0
}
# 检查 Hub 是否变化
check_hub_changed() {
local current_hash=$(calc_hub_hash)
if [ -f "$HUB_HASH_FILE" ]; then
local saved_hash=$(cat "$HUB_HASH_FILE")
if [ "$current_hash" = "$saved_hash" ]; then
return 1
fi
fi
return 0
}
# 检查迁移是否变化
check_migration_changed() {
@@ -140,7 +203,6 @@ check_migration_changed() {
# 保存哈希
save_deps_hash() { calc_deps_hash > "$HASH_FILE"; }
save_hub_hash() { calc_hub_hash > "$HUB_HASH_FILE"; }
save_code_hash() { calc_code_hash > "$CODE_HASH_FILE"; }
save_migration_hash() { calc_migration_hash > "$MIGRATION_HASH_FILE"; }
@@ -151,12 +213,6 @@ build_base() {
save_deps_hash
}
# 构建 Hub 镜像(本地)
build_hub() {
echo ">>> Building hub image (local)..."
docker build --pull=false --build-arg CARGO_MIRROR=1 -f aether-hub/Dockerfile -t aether-hub:local ./aether-hub
save_hub_hash
}
# 生成版本文件
generate_version_file() {
@@ -180,7 +236,7 @@ EOF
build_app() {
echo ">>> Building app image (code only)..."
generate_version_file
docker build --pull=false --build-arg HUB_BINARY_IMAGE=aether-hub:local -f Dockerfile.app.local -t aether-app:latest .
docker build --pull=false -f Dockerfile.app.local -t aether-app:latest .
save_code_hash
}
@@ -230,8 +286,8 @@ print('Old version cleared')
# 强制全部重建
if [ "$1" = "--force" ] || [ "$1" = "-f" ]; then
echo ">>> Force rebuilding everything..."
download_hub
build_base
build_hub
build_app
compose_up --force-recreate
sleep 3
@@ -249,10 +305,11 @@ if [ "$1" = "--rebuild-base" ] || [ "$1" = "-r" ]; then
exit 0
fi
# 强制重建 Hub 镜像
if [ "$1" = "--rebuild-hub" ]; then
build_hub
echo ">>> Hub image rebuilt. Run ./deploy.sh to deploy."
# 更新 Hub 二进制
if [ "$1" = "--update-hub" ]; then
rm -f "$HUB_VERSION_FILE"
download_hub
echo ">>> Hub binary updated. Run ./deploy.sh to deploy."
exit 0
fi
@@ -263,7 +320,7 @@ git pull
# 标记是否需要重启
NEED_RESTART=false
BASE_REBUILT=false
HUB_REBUILT=false
HUB_UPDATED=false
# 检查基础镜像是否存在,或依赖是否变化
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
@@ -280,19 +337,12 @@ else
echo ">>> Dependencies unchanged."
fi
# 检查 Hub 镜像是否存在,或 Hub 代码是否变化
if ! docker image inspect aether-hub:local >/dev/null 2>&1; then
echo ">>> Hub image not found, building..."
build_hub
HUB_REBUILT=true
NEED_RESTART=true
elif check_hub_changed; then
echo ">>> Hub changed, rebuilding hub image..."
build_hub
HUB_REBUILT=true
# 确保 Hub 二进制存在
if ensure_hub_binary; then
HUB_UPDATED=true
NEED_RESTART=true
else
echo ">>> Hub unchanged."
echo ">>> Hub binary present."
fi
# 检查代码或迁移是否变化,或者 base 重建了app 依赖 base
@@ -310,8 +360,8 @@ elif [ "$BASE_REBUILT" = true ]; then
echo ">>> Base image rebuilt, rebuilding app image..."
build_app
NEED_RESTART=true
elif [ "$HUB_REBUILT" = true ]; then
echo ">>> Hub image rebuilt, rebuilding app image..."
elif [ "$HUB_UPDATED" = true ]; then
echo ">>> Hub binary updated, rebuilding app image..."
build_app
NEED_RESTART=true
elif check_code_changed; then

View File

@@ -1,7 +1,7 @@
# Aether 部署配置 - 本地构建
# 使用方法:
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
# 首次构建 hub: docker build -f aether-hub/Dockerfile -t aether-hub:local ./aether-hub
# Hub 二进制: cd aether-hub && ./build.sh 或 deploy.sh 自动下载
# 启动服务: docker compose -f docker-compose.build.yml up -d --build
services:
@@ -43,8 +43,6 @@ services:
build:
context: .
dockerfile: Dockerfile.app.local
args:
HUB_BINARY_IMAGE: ${HUB_BINARY_IMAGE:-aether-hub:local}
image: aether-app:latest
container_name: aether-app
env_file:

View File

@@ -28,6 +28,48 @@ export interface ProviderOAuthCompleteResponseWithKey {
email?: string | null
}
export interface OAuthBatchImportResultItem {
index: number
status: 'success' | 'error'
key_id?: string
key_name?: string
auth_method?: string
error?: string
replaced?: boolean
}
export type OAuthBatchImportTaskStatus = 'submitted' | 'processing' | 'completed' | 'failed'
export interface OAuthBatchImportTaskStartResponse {
task_id: string
status: OAuthBatchImportTaskStatus
total: number
processed: number
success: number
failed: number
progress_percent: number
message?: string | null
}
export interface OAuthBatchImportTaskStatusResponse {
task_id: string
provider_id: string
provider_type: string
status: OAuthBatchImportTaskStatus
total: number
processed: number
success: number
failed: number
progress_percent: number
message?: string | null
error?: string | null
error_samples: OAuthBatchImportResultItem[]
created_at: number
started_at?: number | null
finished_at?: number | null
updated_at: number
}
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data
@@ -56,6 +98,26 @@ export async function importProviderRefreshToken(
return resp.data
}
export async function startBatchImportOAuthTask(
providerId: string,
credentials: string,
proxyNodeId?: string
): Promise<OAuthBatchImportTaskStartResponse> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import/tasks`, {
credentials,
proxy_node_id: proxyNodeId || undefined,
})
return resp.data
}
export async function getBatchImportOAuthTaskStatus(
providerId: string,
taskId: string
): Promise<OAuthBatchImportTaskStatusResponse> {
const resp = await client.get(`/api/admin/provider-oauth/providers/${providerId}/batch-import/tasks/${taskId}`)
return resp.data
}
// Device Authorization (AWS SSO OIDC)
export interface DeviceAuthorizeRequest {

View File

@@ -467,6 +467,51 @@
或选择 JSON 文件导入
</button>
</div>
<div
v-if="importTask"
class="rounded-xl border border-border bg-muted/20 p-3 space-y-2"
>
<div class="flex items-center justify-between text-xs">
<span class="font-medium">
{{ getImportTaskStatusText(importTask.status) }}
</span>
<span class="font-mono tabular-nums">
{{ importTask.progress_percent }}%
</span>
</div>
<div class="h-1.5 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
:style="{ width: `${Math.max(0, Math.min(importTask.progress_percent, 100))}%` }"
/>
</div>
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span>进度 {{ importTask.processed }}/{{ importTask.total }}</span>
<span>成功 {{ importTask.success }} · 失败 {{ importTask.failed }}</span>
</div>
<p
v-if="importTask.message"
class="text-[11px] text-muted-foreground"
>
{{ importTask.message }}
</p>
<div
v-if="importTask.error_samples.length > 0"
class="space-y-1"
>
<p class="text-[11px] text-destructive">
最近错误
</p>
<p
v-for="item in importTask.error_samples.slice(0, 3)"
:key="`${item.index}-${item.error || item.status}`"
class="text-[11px] text-destructive/90"
>
#{{ item.index + 1 }} {{ item.error || '导入失败' }}
</p>
</div>
</div>
</div>
</div>
</div>
@@ -490,7 +535,7 @@
:disabled="!canImport"
@click="handleImport"
>
{{ importing ? '导入中...' : '导入' }}
{{ importing ? (importTask ? `导入中 ${importTask.progress_percent}%` : '导入中...') : '导入' }}
</Button>
</template>
</Dialog>
@@ -518,11 +563,16 @@ import {
startProviderLevelOAuth,
completeProviderLevelOAuth,
importProviderRefreshToken,
batchImportOAuth,
startBatchImportOAuthTask,
getBatchImportOAuthTaskStatus,
startDeviceAuthorize,
pollDeviceAuthorize,
getAwsRegions,
} from '@/api/endpoints'
import type {
OAuthBatchImportTaskStatus,
OAuthBatchImportTaskStatusResponse,
} from '@/api/endpoints/provider_oauth'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
@@ -665,6 +715,9 @@ const importing = ref(false)
const isDragging = ref(false)
const showManualInput = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const importTask = ref<OAuthBatchImportTaskStatusResponse | null>(null)
let importPollTimer: ReturnType<typeof setTimeout> | null = null
const importPolling = ref(false)
const isOpen = computed(() => props.open)
@@ -691,6 +744,78 @@ const canImport = computed(() => {
return importText.value.trim().length > 0 && !importing.value
})
function stopImportPolling() {
if (importPollTimer) {
clearTimeout(importPollTimer)
importPollTimer = null
}
importPolling.value = false
}
function getImportTaskStatusText(status: OAuthBatchImportTaskStatus): string {
switch (status) {
case 'submitted':
return '任务已提交'
case 'processing':
return '正在导入'
case 'completed':
return '导入完成'
case 'failed':
return '导入失败'
default:
return '处理中'
}
}
function scheduleImportPoll(taskId: string, delayMs = 1200) {
stopImportPolling()
importPollTimer = setTimeout(() => {
void pollImportTaskStatus(taskId)
}, delayMs)
}
async function pollImportTaskStatus(taskId: string) {
if (!props.providerId || importPolling.value) return
importPolling.value = true
try {
const task = await getBatchImportOAuthTaskStatus(props.providerId, taskId)
importTask.value = task
if (task.status === 'completed') {
stopImportPolling()
importing.value = false
if (task.success > 0) {
if (task.failed > 0) {
success(`批量导入完成:成功 ${task.success} 个,失败 ${task.failed}`)
} else {
success(`批量导入成功:${task.success} 个账号已添加`)
}
emit('saved')
handleClose()
} else {
showError(task.error || '批量导入失败', '导入失败')
}
return
}
if (task.status === 'failed') {
stopImportPolling()
importing.value = false
showError(task.error || task.message || '批量导入失败', '导入失败')
return
}
scheduleImportPoll(taskId)
} catch {
if (importing.value) {
scheduleImportPoll(taskId, 2000)
}
} finally {
importPolling.value = false
}
}
function stopDevicePolling() {
if (devicePollTimer) {
clearTimeout(devicePollTimer)
@@ -715,11 +840,13 @@ function resetDevice() {
function resetForm() {
oauth.value = createInitialOAuthState()
stopImportPolling()
stopDevicePolling()
totp.stop()
device.value = createInitialDeviceState()
importText.value = ''
importing.value = false
importTask.value = null
isDragging.value = false
showManualInput.value = false
proxyPopoverOpen.value = false
@@ -938,25 +1065,32 @@ async function handleImport() {
}
importing.value = true
let keepImporting = false
try {
const proxyNodeId = selectedProxyNodeId.value || undefined
// 检测是否为批量导入
if (isBatchImport(inputText)) {
// 批量导入
const result = await batchImportOAuth(props.providerId, inputText, proxyNodeId)
if (result.success > 0) {
if (result.failed > 0) {
success(`批量导入完成:成功 ${result.success} 个,失败 ${result.failed}`)
} else {
success(`批量导入成功:${result.success} 个账号已添加`)
}
emit('saved')
handleClose()
} else {
// 全部失败,显示第一个错误
const firstError = result.results.find(r => r.status === 'error')
showError(firstError?.error || '批量导入失败', '导入失败')
const task = await startBatchImportOAuthTask(props.providerId, inputText, proxyNodeId)
importTask.value = {
task_id: task.task_id,
provider_id: props.providerId,
provider_type: props.providerType || '',
status: task.status,
total: task.total,
processed: task.processed,
success: task.success,
failed: task.failed,
progress_percent: task.progress_percent,
message: task.message || null,
error: null,
error_samples: [],
created_at: Math.floor(Date.now() / 1000),
started_at: null,
finished_at: null,
updated_at: Math.floor(Date.now() / 1000),
}
keepImporting = true
scheduleImportPoll(task.task_id, 400)
} else {
// 单条导入
const parsed = parseImportText(inputText)
@@ -976,7 +1110,9 @@ async function handleImport() {
const errorMessage = parseApiError(err, '导入失败')
showError(errorMessage, '错误')
} finally {
importing.value = false
if (!keepImporting) {
importing.value = false
}
}
}
@@ -1080,6 +1216,7 @@ async function pollDevice() {
}
onBeforeUnmount(() => {
stopImportPolling()
stopDevicePolling()
})

View File

@@ -702,6 +702,7 @@ const editingModel = ref<GlobalModelResponse | null>(null)
// 数据
const globalModels = ref<GlobalModelResponse[]>([])
const providers = ref<ProviderWithEndpointsSummary[]>([])
const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000
// 模型目录分页
const catalogCurrentPage = ref(1)
@@ -1026,9 +1027,25 @@ watch([searchQuery, capabilityFilters], () => {
async function loadGlobalModels() {
loading.value = true
try {
const response = await listGlobalModels()
// API 返回 { models: [...], total: number }
globalModels.value = response.models || []
const allModels: GlobalModelResponse[] = []
let skip = 0
while (true) {
const response = await listGlobalModels({
skip,
limit: GLOBAL_MODELS_FETCH_PAGE_SIZE,
})
const pageModels = response.models || []
allModels.push(...pageModels)
if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) {
break
}
skip += pageModels.length
}
globalModels.value = allModels
} catch (err: unknown) {
log.error('加载模型失败:', err)
showError(parseApiError(err, '加载模型失败'), '加载模型失败')

File diff suppressed because it is too large Load Diff