Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h48m47s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h48m2s
CI/CD Pipeline / Deploy Staging (push) Failing after 52h46m33s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 52h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped

This commit is contained in:
CI Test
2026-07-07 08:05:42 +08:00
24 changed files with 1420 additions and 607 deletions
+9 -30
View File
@@ -602,7 +602,6 @@ jobs:
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
# Retry up to 5 times with backoff for transient 5xx errors
last_err = None
for attempt in range(5):
try:
@@ -639,7 +638,7 @@ jobs:
tar.extract(member, '.')
PY
- name: Build runtime image artifact
- name: Build and push all images (api + worker + web, with buildx cache)
shell: sh
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -648,22 +647,6 @@ jobs:
chmod +x scripts/build_release_images.sh
REGISTRY_TOKEN="${REGISTRY_TOKEN}" scripts/build_release_images.sh "${GITHUB_REF_NAME}"
- name: Build production web artifact
shell: sh
run: |
set -eu
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc 'npm ci && npm run build'
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "xiaoxia-saas-web:${GITHUB_REF_NAME}" \
.
test -f apps/web/dist/index.html
- name: Package release source artifact
shell: sh
run: |
@@ -671,9 +654,8 @@ jobs:
mkdir -p dist/release-artifacts
tar --exclude=.git --exclude=apps/web/node_modules --exclude=./dist \
-czf "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" .
docker save -o "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" "xiaoxia-saas-web:${GITHUB_REF_NAME}"
- name: Upload runtime image and release artifacts
- name: Upload source artifact to production
shell: sh
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
@@ -684,7 +666,6 @@ jobs:
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
production_user="${PRODUCTION_SSH_USER:-root}"
mkdir -p ~/.ssh
# Prefer host key if available (more reliable), fallback to secrets
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using host SSH key: $key_path"
@@ -698,12 +679,8 @@ jobs:
exit 1
fi
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
scp -i "$key_path" "dist/release-images/xiaoxia-runtime-images-${GITHUB_REF_NAME}.tar" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/runtime-images-${GITHUB_REF_NAME}.tar"
scp -i "$key_path" "dist/release-artifacts/xiaoxia-release-${GITHUB_REF_NAME}.tar.gz" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/release-${GITHUB_REF_NAME}.tar.gz"
scp -i "$key_path" "dist/release-artifacts/xiaoxia-web-${GITHUB_REF_NAME}.tar" \
"$production_user@$production_host:/var/lib/xiaoxia-saas-production/web-${GITHUB_REF_NAME}.tar"
- name: Cleanup old Docker images
if: always()
@@ -734,32 +711,34 @@ jobs:
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
echo "openssh-client installed"
- name: Deploy production over SSH
- name: Deploy production over SSH (pull images from registry)
shell: sh
env:
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
production_host="${PRODUCTION_SSH_HOST:-47.98.113.167}"
production_user="${PRODUCTION_SSH_USER:-root}"
mkdir -p ~/.ssh
# Prefer host key if available (more reliable), fallback to secrets
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
key_path="/root/.ssh/xiaoxia_runtime_builder"
echo "Using host SSH key: $key_path"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
key_path="$HOME/.ssh/id_ed25519"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$key_path"
chmod 600 "$key_path"
echo "Using secret SSH key: $key_path"
else
echo "ERROR: No SSH key available"
exit 1
fi
ssh-keyscan -H "$production_host" >> ~/.ssh/known_hosts
echo 'c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3J1bnRpbWUtaW1hZ2VzLSR7UkVMRUFTRV9WRVJTSU9OfS50YXIiCnRlc3QgLWYgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3dlYi0ke1JFTEVBU0VfVkVSU0lPTn0udGFyIgpta2RpciAtcCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbgpvbGRfYXNzZXRzX2Rpcj0iL3RtcC94aWFveGlhLXByZXZpb3VzLXdlYi1hc3NldHMtJHtSRUxFQVNFX1ZFUlNJT059IgpybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKbWtkaXIgLXAgIiRvbGRfYXNzZXRzX2RpciIKaWYgZG9ja2VyIGluc3BlY3QgeGlhb3hpYS13ZWItcHJvZHVjdGlvbiA+L2Rldi9udWxsIDI+JjE7IHRoZW4KICBkb2NrZXIgY3AgeGlhb3hpYS13ZWItcHJvZHVjdGlvbjovdXNyL3NoYXJlL25naW54L2h0bWwvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvIDI+L2Rldi9udWxsIHx8IHRydWUKZmkKaWYgWyAtZCAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzIF07IHRoZW4KICBjcCAtYSAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvL2FwcHMvd2ViL2Rpc3QvYXNzZXRzLy4gIiRvbGRfYXNzZXRzX2RpciIvCmZpCnJtIC1yZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KdGFyIC14emYgIiRyZWxlYXNlX3RhciIgLUMgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9pbmRleC5odG1sCmlmIFsgLWQgIiRvbGRfYXNzZXRzX2RpciIgXTsgdGhlbgogIG1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMKICBmb3IgYXNzZXQgaW4gIiRvbGRfYXNzZXRzX2RpciIvKjsgZG8KICAgIFsgLWUgIiRhc3NldCIgXSB8fCBjb250aW51ZQogICAgbmFtZT0iJChiYXNlbmFtZSAiJGFzc2V0IikiCiAgICBpZiBbICEgLWUgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiIF07IHRoZW4KICAgICAgY3AgLWEgIiRhc3NldCIgIi92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvJG5hbWUiCiAgICBmaQogIGRvbmUKICBybSAtcmYgIiRvbGRfYXNzZXRzX2RpciIKZmkKdGVzdCAtZiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi8uZW52CmNwIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby8uZW52CiMgRW5zdXJlIGlzb2xhdGVkIHByb2R1Y3Rpb24gbmV0d29yayBleGlzdHMgYmVmb3JlIGRlcGxveQpkb2NrZXIgbmV0d29yayBjcmVhdGUgeGlhb3hpYS1uZXQtcHJvZHVjdGlvbiAyPi9kZXYvbnVsbCB8fCB0cnVlCkhPU1RfUFJFRklYPSBFTlY9cHJvZHVjdGlvbiBXRUJfSU1BR0U9InhpYW94aWEtc2Fhcy13ZWI6JHtSRUxFQVNFX1ZFUlNJT059IiBXRUJfSU1BR0VfVEFSPSIvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi93ZWItJHtSRUxFQVNFX1ZFUlNJT059LnRhciIgc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg==' | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' sh"
# 部署脚本(base64 编码避免转义问题)
DEPLOY_B64="c2V0IC1ldQpyZWxlYXNlX3Rhcj0iL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVsZWFzZS0ke1JFTEVBU0VfVkVSU0lPTn0udGFyLmd6Igp0ZXN0IC1mICIkcmVsZWFzZV90YXIiCm1rZGlyIC1wIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uCm9sZF9hc3NldHNfZGlyPSIvdG1wL3hpYW94aWEtcHJldmlvdXMtd2ViLWFzc2V0cy0ke1JFTEVBU0VfVkVSU0lPTn0iCnJtIC1yZiAiJG9sZF9hc3NldHNfZGlyIgpta2RpciAtcCAiJG9sZF9hc3NldHNfZGlyIgppZiBkb2NrZXIgaW5zcGVjdCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uID4vZGV2L251bGwgMj4mMTsgdGhlbgogIGRvY2tlciBjcCB4aWFveGlhLXdlYi1wcm9kdWN0aW9uOi91c3Ivc2hhcmUvbmdpbngvaHRtbC9hc3NldHMvLiAiJG9sZF9hc3NldHNfZGlyLyIgMj4vZGV2L251bGwgfHwgdHJ1ZQpmaQppZiBbIC1kIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMgXTsgdGhlbgogIGNwIC1hIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8vYXBwcy93ZWIvZGlzdC9hc3NldHMvLiAiJG9sZF9hc3NldHNfZGlyLyIKZmkKcm0gLXJmIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uL3JlcG8KbWtkaXIgLXAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwbwp0YXIgLXh6ZiAiJHJlbGVhc2VfdGFyIiAtQyAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvCnRlc3QgLWYgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2luZGV4Lmh0bWwKaWYgWyAtZCAiJG9sZF9hc3NldHNfZGlyIiBdOyB0aGVuCiAgbWtkaXIgLXAgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2Fzc2V0cwogIGZvciBhc3NldCBpbiAiJG9sZF9hc3NldHNfZGlyIi8qOyBkbwogICAgWyAtZSAiJGFzc2V0IiBdIHx8IGNvbnRpbnVlCiAgICBuYW1lPSIkKGJhc2VuYW1lICIkYXNzZXQiKSIKICAgIGlmIFsgISAtZSAiL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2Fzc2V0cy8kbmFtZSIgXTsgdGhlbgogICAgICBjcCAtYSAiJGFzc2V0IiAiL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9hcHBzL3dlYi9kaXN0L2Fzc2V0cy8kbmFtZSIKICAgIGZpCiAgZG9uZQogIHJtIC1yZiAiJG9sZF9hc3NldHNfZGlyIgpmaQp0ZXN0IC1mIC92YXIvbGliL3hpYW94aWEtc2Fhcy1wcm9kdWN0aW9uLy5lbnYKY3AgL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vLmVudiAvdmFyL2xpYi94aWFveGlhLXNhYXMtcHJvZHVjdGlvbi9yZXBvLy5lbnYKZG9ja2VyIG5ldHdvcmsgY3JlYXRlIHhpYW94aWEtbmV0LXByb2R1Y3Rpb24gMj4vZGV2L251bGwgfHwgdHJ1ZQpSRUdJU1RSWV9UT0tFTj0iJHtSRUdJU1RSWV9UT0tFTn0iIFJFTEVBU0VfVkVSU0lPTj0iJHtSRUxFQVNFX1ZFUlNJT059IiBIT1NUX1BSRUZJWD0gRU5WPXByb2R1Y3Rpb24gc2ggL3Zhci9saWIveGlhb3hpYS1zYWFzLXByb2R1Y3Rpb24vcmVwby9pbmZyYS9kb2NrZXIvZGVwbG95LXByb2R1Y3Rpb24uc2gKaT0wCndoaWxlIFsgIiRpIiAtbHQgMzAgXTsgZG8KICBpZiB3Z2V0IC1xTy0gaHR0cDovLzEyNy4wLjAuMTo4MDAxL2hlYWx0aDsgdGhlbgogICAgZXhpdCAwCiAgZmkKICBpPSQoKGkgKyAxKSkKICBzbGVlcCAyCmRvbmUKZXhpdCAxCg=="
echo "$DEPLOY_B64" | base64 -d | ssh -i "$key_path" "$production_user@$production_host" "RELEASE_VERSION='${GITHUB_REF_NAME}' REGISTRY_TOKEN='${REGISTRY_TOKEN}' sh"
production-e2e:
name: Production Browser E2E
@@ -0,0 +1,56 @@
"""Add user profile fields (name, avatar, updated_at)
Revision ID: 026
Revises: 025
Create Date: 2026-07-05
补录用户资料字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查,避免在已有字段的库上执行报错。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "name"):
op.add_column("users", sa.Column("name", sa.String(100), nullable=True))
if not _column_exists("users", "avatar"):
op.add_column("users", sa.Column("avatar", sa.String(500), nullable=True))
if not _column_exists("users", "updated_at"):
op.add_column(
"users",
sa.Column(
"updated_at",
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("users", "updated_at")
op.drop_column("users", "avatar")
op.drop_column("users", "name")
@@ -0,0 +1,44 @@
"""Add user ban fields (ban_reason, ban_at)
Revision ID: 027
Revises: 026
Create Date: 2026-07-05
补录用户封禁字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "ban_reason"):
op.add_column("users", sa.Column("ban_reason", sa.Text(), nullable=True))
if not _column_exists("users", "ban_at"):
op.add_column("users", sa.Column("ban_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "ban_at")
op.drop_column("users", "ban_reason")
@@ -0,0 +1,44 @@
"""Add user admin fields (admin_status, admin_remarks)
Revision ID: 028
Revises: 027
Create Date: 2026-07-05
补录管理员备注字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "admin_status"):
op.add_column("users", sa.Column("admin_status", sa.String(50), nullable=True))
if not _column_exists("users", "admin_remarks"):
op.add_column("users", sa.Column("admin_remarks", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "admin_remarks")
op.drop_column("users", "admin_status")
+40
View File
@@ -0,0 +1,40 @@
"""Add user phone field
Revision ID: 029
Revises: 028
Create Date: 2026-07-05
补录用户手机号字段。生产数据库已手动添加过该字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "phone"):
op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))
def downgrade() -> None:
op.drop_column("users", "phone")
+98 -1
View File
@@ -6,13 +6,14 @@ app.dependencies and authentication behavior lives in application use cases.
"""
import logging
import os
from typing import Optional
import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr
@@ -284,3 +285,99 @@ def _translate_auth_error(error: str | None) -> str:
"Display name is required": "显示名称不能为空",
}
return translations.get(error or "", error or "注册失败")
class WechatSyncRequest(BaseModel):
openid: str
unionid: Optional[str] = None
nickname: Optional[str] = None
avatar_url: Optional[str] = None
source: str = "miniapp"
class WechatSyncResponse(BaseModel):
access_token: str
token: str
refresh_token: str
user_id: str
user: dict
user_info: dict
is_new_user: bool
expires_in: int
def _get_internal_api_keys() -> list[str]:
"""获取内部 API Key 列表
优先级:
1. INTERNAL_API_KEYS 环境变量
2. /app/generated/internal_api_keys.txt 文件 (volume 持久化)
"""
env_keys = os.environ.get("INTERNAL_API_KEYS", "")
if env_keys:
return [k.strip() for k in env_keys.split(",") if k.strip()]
# 从持久化文件读取
try:
with open("/app/generated/internal_api_keys.txt", "r") as f:
content = f.read().strip()
if content:
return [k.strip() for k in content.split(",") if k.strip()]
except Exception:
logger.debug("Failed to read internal API keys from file", exc_info=True)
return []
def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool:
"""验证内部 API Key
- 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key
- 未配置且非生产环境:放行(方便开发)
- 未配置且生产环境:拒绝
"""
env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower()
key_list = _get_internal_api_keys()
if not key_list:
if env in ("production", "prod"):
raise HTTPException(status_code=401, detail="内部接口未配置 API Key")
return True
if x_api_key and x_api_key.strip() in key_list:
return True
raise HTTPException(status_code=401, detail="无效的 API Key")
@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False)
async def wechat_sync(
request: WechatSyncRequest,
user_repository: UserRepository = Depends(get_user_repository),
_: bool = Depends(_verify_internal_api_key),
):
"""
微信同步登录/注册(系统级内部接口)
由 BFF 层通过 API Key 调用,不直接面向终端用户。
根据 openid 查找或创建用户,返回 SaaS token。
"""
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as UseCaseRequest
from packages.application.auth.wechat_sync_use_case import (
WechatSyncUseCase,
)
use_case = WechatSyncUseCase(user_repository=user_repository)
use_case_request = UseCaseRequest(
openid=request.openid,
unionid=request.unionid,
nickname=request.nickname,
avatar_url=request.avatar_url,
source=request.source,
)
response, error = use_case.execute(use_case_request)
if error:
raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict())
+12
View File
@@ -1,6 +1,7 @@
/**
* 认证相关 API
*/
import axios from "axios";
import apiClient from "./client";
// 类型定义
@@ -71,6 +72,17 @@ export const login = async (data: LoginRequest): Promise<LoginResponse> => {
return response.data;
};
// 刷新 access_token(使用裸 axios 避免拦截器递归)
export const refreshAccessToken = async (
refreshToken: string,
): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? "";
const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken,
});
return response.data;
};
// 注册
export const register = async (
data: RegisterRequest,
+85 -4
View File
@@ -5,6 +5,7 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { message } from "antd";
import { useAuthStore } from "@/store/authStore";
import { refreshAccessToken } from "./auth";
// 创建 Axios 实例
const apiClient = axios.create({
@@ -15,6 +16,24 @@ const apiClient = axios.create({
},
});
// ── Token 刷新队列 ─────────────────────────────────────────
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: unknown | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// 请求拦截器:添加 Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
@@ -29,15 +48,77 @@ apiClient.interceptors.request.use(
},
);
// 响应拦截器:统一错误提示 + 处理未授权状态
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
apiClient.interceptors.response.use(
(response) => response,
async (
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
) => {
// 401 → 清除登录态
if (error.response?.status === 401) {
useAuthStore.getState().clearAuth();
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// 401 → 尝试刷新 Token
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry
) {
const refreshToken = useAuthStore.getState().refreshToken;
// 无 refresh_token → 直接登出
if (!refreshToken) {
useAuthStore.getState().clearAuth();
window.location.href = "/";
return Promise.reject(error);
}
// 已在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return apiClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const data = await refreshAccessToken(refreshToken);
const newAccessToken = data.access_token;
const newRefreshToken = data.refresh_token ?? refreshToken;
// 更新 Zustand + localStorage
useAuthStore
.getState()
.setAuth(
useAuthStore.getState().user!,
newAccessToken,
newRefreshToken,
);
// 处理排队的请求
processQueue(null, newAccessToken);
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
return apiClient(originalRequest);
} catch (refreshError) {
// 刷新失败 → 登出
processQueue(refreshError, null);
useAuthStore.getState().clearAuth();
window.location.href = "/";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// 提取后端返回的错误信息(detail / message / msg
@@ -16,7 +16,6 @@ import Sidebar from "./Sidebar";
import "./MainLayout.css";
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
// eslint-disable-next-line react-refresh/only-export-components
export interface SidebarContextValue {
collapsed: boolean;
}
+1 -1
View File
@@ -75,7 +75,7 @@ export const useLogout = () => {
} finally {
clearAuth();
queryClient.clear();
navigate("/login");
navigate("/");
}
};
+9
View File
@@ -25,6 +25,15 @@ body {
background-color: var(--bg-secondary);
}
/* ── 自定义字体 ── */
@font-face {
font-family: "华康俪金黑";
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 滚动条 - V21 样式 */
::-webkit-scrollbar {
width: 8px;
@@ -847,8 +847,8 @@
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
gap: 8px;
padding: 6px 16px;
overflow-x: auto;
overflow-y: hidden;
background: var(--bg-primary, #ffffff);
@@ -864,11 +864,11 @@
border-radius: 3px;
}
/* 水平片段卡片 — 统一高度96px、圆角、hover高亮 */
/* 水平片段卡片 — 统一高度64px、圆角、hover高亮 */
.ep-clip-card {
width: 100px;
min-width: 100px;
height: 96px;
height: 64px;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e2e2e2);
border-radius: var(--radius-sm, 14px);
@@ -903,7 +903,7 @@
}
.ep-clip-thumbnail {
height: 52px;
height: 34px;
background: linear-gradient(
135deg,
var(--bg-secondary, #f8fafc),
@@ -917,7 +917,7 @@
}
.ep-clip-info {
padding: 6px 8px;
padding: 4px 6px;
flex: 1;
display: flex;
flex-direction: column;
@@ -993,7 +993,7 @@
gap: 8px;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
min-height: 96px;
min-height: 64px;
}
.ep-track-empty-icon {
@@ -1033,7 +1033,7 @@
.ep-track-add-card {
min-width: 60px;
height: 96px;
height: 64px;
border: 2px dashed var(--line, #e2e8f0);
border-radius: 12px;
display: flex;
@@ -1064,10 +1064,7 @@
background: none;
}
.ep-track-add-card-wrapper > .ep-add-clip-picker {
top: calc(100% + 6px);
right: 0;
}
/* picker 已改为 Portal 渲染,不再需要相对定位 */
/* 添加片段按钮 + 选择器 */
.ep-add-clip-wrapper {
@@ -1094,81 +1091,139 @@
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* ── "+" 卡片类型+时长选择器 ── */
.ep-add-clip-picker {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 260px;
max-height: 320px;
width: 180px;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius-md, 18px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
overflow: hidden;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* fixed 定位版本 — 不受任何父容器 overflow 裁剪 */
.ep-add-clip-picker--portal {
position: fixed !important;
z-index: 1000;
}
.ep-add-clip-picker-title {
padding: 10px 14px 6px;
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #64748b);
color: var(--text-primary, #1e293b);
padding-bottom: 6px;
border-bottom: 1px solid var(--line, #e2e8f0);
}
.ep-add-clip-picker-empty {
padding: 20px 14px;
text-align: center;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
}
.ep-add-clip-picker-list {
overflow-y: auto;
max-height: 260px;
padding: 4px 0;
}
.ep-add-clip-picker-item {
.ep-add-clip-type-row,
.ep-add-clip-duration-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.ep-add-clip-type-label {
font-size: 12px;
color: var(--text-primary, #0f172a);
transition: background var(--transition-fast, 0.15s ease);
}
.ep-add-clip-picker-item:hover {
background: var(--bg-hover, #f1f5f9);
}
.ep-add-clip-picker-icon {
font-size: 16px;
color: var(--text-secondary, #64748b);
font-weight: 500;
flex-shrink: 0;
min-width: 36px;
}
.ep-add-clip-picker-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
.ep-add-clip-type-btn {
padding: 5px 10px;
border-radius: var(--radius-xs, 8px);
border: 1px solid var(--line, #e2e8f0);
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #64748b);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.ep-add-clip-picker-type {
flex-shrink: 0;
font-size: 10px;
.ep-add-clip-type-btn:hover {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.ep-add-clip-type-btn.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
color: var(--primary, #6366f1);
font-weight: 500;
}
.ep-add-clip-duration-unit {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
background: var(--bg-secondary, #f8fafc);
padding: 2px 6px;
border-radius: 4px;
}
.ep-add-clip-confirm-btn {
width: 100%;
height: 30px;
background: var(--primary, #6366f1);
color: var(--text-inverse, #ffffff);
border: none;
border-radius: var(--radius-xs, 8px);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 4px;
}
.ep-add-clip-confirm-btn:hover {
background: var(--primary-hover, #4f46e5);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* ── 片段详情 — 类型选择器 ── */
.ep-clip-type-selector {
display: flex;
gap: 6px;
}
.ep-clip-type-btn {
padding: 5px 12px;
border-radius: var(--radius-xs, 8px);
border: 1px solid var(--line, #e2e8f0);
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #64748b);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
}
.ep-clip-type-btn:hover:not(.disabled) {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.ep-clip-type-btn.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
color: var(--primary, #6366f1);
font-weight: 500;
}
.ep-clip-type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 一镜到底提示 */
@@ -1385,53 +1440,57 @@
color: var(--primary, #6366f1);
}
/* 标题预设样式按钮方形 T 字预览 */
.ep-title-presets {
display: flex;
/* ── 标题预设样式 — 剪映风格方形网格,示例文字"标题"预览完整效果 ── */
.ep-title-presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ep-title-preset-btn {
flex: 1;
min-width: 44px;
height: 48px;
background: var(--bg-primary, #ffffff);
.ep-title-preset-card {
aspect-ratio: 1;
background: #ffffff;
border: 2px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-xs, 8px);
color: var(--text-primary, #1e293b);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 6px;
gap: 4px;
padding: 6px 4px;
transition: all 0.15s ease;
overflow: hidden;
}
.ep-title-preset-btn:hover {
.ep-title-preset-card:hover {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
background: #f8fafc;
transform: translateY(-1px);
}
.ep-title-preset-btn.active {
.ep-title-preset-card.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
box-shadow: 0 0 0 1px var(--primary, #6366f1);
background: #eef2ff;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
.ep-title-preset-t {
.ep-title-preset-preview-text {
line-height: 1.2;
text-align: center;
word-break: keep-all;
white-space: nowrap;
}
.ep-title-preset-card-label {
font-size: 9px;
color: #64748b;
line-height: 1;
font-family: inherit;
text-align: center;
margin-top: auto;
}
.ep-title-preset-label {
font-size: 10px;
color: var(--text-tertiary, #94a3b8);
line-height: 1;
}
.ep-title-preset-btn.active .ep-title-preset-label {
.ep-title-preset-card.active .ep-title-preset-card-label {
color: var(--primary, #6366f1);
font-weight: 500;
}
@@ -4,13 +4,7 @@
*/
import React, { useState, useCallback, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
// 简易消息工具(UI 库未导出 message)
const msg = {
success: (s: string) => console.log(`${s}`),
error: (s: string) => console.error(`${s}`),
warning: (s: string) => console.warn(`⚠️ ${s}`),
info: (s: string) => console.info(`${s}`),
};
import { message } from "antd";
import type {
EditingTemplate,
TemplateCategory,
@@ -20,26 +14,22 @@ import type {
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type {
EditPlanClip,
EditPlanGeneration,
MediaAsset,
} from "@/api/editPlans";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
MATERIAL_TYPE_LABELS,
aiRecommendClips,
generateCover,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import type { ClipData, ClipType } from "./types";
import MediaPanel from "./components/MediaPanel";
import PreviewPlayer from "./components/PreviewPlayer";
@@ -51,14 +41,6 @@ import type { GenPhase } from "./components/GenerationProgressModal";
import GenerationHistoryModal from "./components/GenerationHistoryModal";
import "./EditingPlanner.css";
/* ──────────── 类型 ──────────── */
interface ClipData extends EditPlanClip {
name: string;
thumbnail?: string;
assetName?: string;
}
/* ──────────── 常量 ──────────── */
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
@@ -106,7 +88,7 @@ const EditingPlanner: React.FC = () => {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
/* ── AI 操作状态 ── */
const [aiRecommendLoading, setAiRecommendLoading] = useState(false);
const [aiCoverLoading, setAiCoverLoading] = useState(false);
/* ── 封面方案 ── */
@@ -119,7 +101,7 @@ const EditingPlanner: React.FC = () => {
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState({
aiAutoSelect: true,
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
@@ -128,6 +110,7 @@ const EditingPlanner: React.FC = () => {
italic: false,
stroke: true,
shadow: true,
color: "#ffffff",
});
const [subtitleSettings, setSubtitleSettings] = useState({
@@ -192,7 +175,7 @@ const EditingPlanner: React.FC = () => {
setCategories(cats);
setMediaAssets(assets);
} catch {
msg.error("加载模板失败");
message.error("加载模板失败");
} finally {
setLoadingTemplates(false);
}
@@ -216,14 +199,13 @@ const EditingPlanner: React.FC = () => {
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
name: `片段${idx + 1} · ${MATERIAL_TYPE_LABELS[seg.material_type || "video"] || "视频"}`,
material_type:
(seg.material_type as ClipData["material_type"]) || "video",
script_text: "",
type: (seg.material_type === "voiceover"
? "voice"
: "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
resetClips(mapped);
@@ -234,6 +216,7 @@ const EditingPlanner: React.FC = () => {
position: tpl.title_config.position,
font: tpl.title_config.font_preset,
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}));
setSubtitleSettings({
enabled: tpl.subtitle_config.enabled,
@@ -249,7 +232,7 @@ const EditingPlanner: React.FC = () => {
setDraftCategory(tpl.category);
setDraftTags(tpl.tags.join(", "));
})
.catch(() => msg.error("加载模板详情失败"));
.catch(() => message.error("加载模板详情失败"));
}, [loadedTemplateId, resetClips]);
/* ──────────── 计算 ──────────── */
@@ -303,91 +286,32 @@ const EditingPlanner: React.FC = () => {
);
};
const handleAssetDropToClip = (clipId: string, asset: MediaAsset) => {
handleClipUpdate(clipId, {
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
});
};
const handleAssetDropToTimeline = (asset: MediaAsset) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
template_segment_id: "",
name: `片段${clips.length + 1} · ${MATERIAL_TYPE_LABELS[asset.type]}`,
material_type:
asset.type === "audio"
? "voiceover"
: asset.type === "image"
? "image"
: "video",
script_text: "",
duration: asset.duration || 5,
order: clips.length,
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
transition: { type: "none", duration: 0 },
};
setClips((prev) => [...prev, newClip]);
};
/**
* AI 推荐片段方案
* 将当前已绑定素材的片段 ID 列表 + 编辑模式 + 目标时长发送给后端
* 返回的推荐结果直接替换当前片段列表(不保留原手动编排)
* 添加片段(不绑定任何素材)
* 片段 = 时间规划 + 类型标记
*/
const handleAiRecommend = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载模板");
return;
}
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
if (assetIds.length === 0) {
msg.warning("请先添加素材到片段");
return;
}
setAiRecommendLoading(true);
try {
const res = await aiRecommendClips(loadedTemplateId, {
asset_ids: assetIds,
editing_mode: currentMode,
target_duration: totalDuration || 30,
});
const mapped: ClipData[] = res.clips.map((c, i) => ({
id: `clip-ai-${i}-${Date.now()}`,
template_segment_id: "",
name: `片段${i + 1} · ${c.clip_type}`,
material_type: (c.clip_type as ClipData["material_type"]) || "video",
script_text: c.text_content || "",
duration: c.duration || 5,
order: c.order ?? i,
media_asset_id: c.asset_id || undefined,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
setClips(mapped);
msg.success(`AI 推荐了 ${mapped.length} 个片段`);
} catch {
msg.error("AI 推荐失败");
} finally {
setAiRecommendLoading(false);
}
};
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
};
setClips((prev) => [...prev, newClip]);
},
[clips.length, setClips],
);
/* AI 封面生成 */
const handleAiGenerateCover = async (
coverType: "ai_frame" | "ai_regenerate",
) => {
if (!loadedTemplateId) return;
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
const assetIds = selectedAssetIds;
if (assetIds.length === 0) {
msg.warning("请先添加素材");
message.warning("请先在素材库中选择素材");
return;
}
setAiCoverLoading(true);
@@ -399,29 +323,24 @@ const EditingPlanner: React.FC = () => {
setCurrentCoverScheme(
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
);
msg.success("AI 封面生成成功");
message.success("AI 封面生成成功");
} catch {
msg.error("AI 封面生成失败");
message.error("AI 封面生成失败");
} finally {
setAiCoverLoading(false);
}
};
/* 保存 */
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
setSaveModalOpen(true);
};
const handleSave = async () => {
if (!draftName.trim()) {
msg.warning("请输入模板名称");
message.warning("请输入模板名称");
return;
}
if (!loadedTemplateId) return;
setSaveLoading(true);
try {
const payload: SaveTemplatePayload = {
@@ -436,7 +355,7 @@ const EditingPlanner: React.FC = () => {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
font_preset: titleSettings.font,
font_color: "#ffffff",
font_color: titleSettings.color,
font_size: titleSettings.size,
position: titleSettings.position,
},
@@ -457,15 +376,19 @@ const EditingPlanner: React.FC = () => {
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.material_type,
material_type: c.type === "voice" ? "voiceover" : "video",
})),
};
await updateEditingTemplate(loadedTemplateId, payload);
msg.success("模板保存成功");
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload);
} else {
await createEditingTemplate(payload);
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功");
setSaveModalOpen(false);
loadTemplates();
} catch {
msg.error("保存失败");
message.error("保存失败");
} finally {
setSaveLoading(false);
}
@@ -477,16 +400,13 @@ const EditingPlanner: React.FC = () => {
* 一键生成页面从 params 解析配置,无需重复请求接口
*/
const handleGoToGenerate = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
const planConfig = {
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
bold: titleSettings.bold,
italic: titleSettings.italic,
@@ -508,16 +428,16 @@ const EditingPlanner: React.FC = () => {
total_duration: totalDuration,
segments: clips.map((c, i) => ({
order: i,
name: c.name,
material_type: c.material_type,
material_type: c.type === "voice" ? "voiceover" : "video",
duration: c.duration,
media_asset_id: c.media_asset_id,
template_segment_id: c.template_segment_id,
script_text: c.script_text,
})),
};
const params = new URLSearchParams();
params.set("edit_plan_id", loadedTemplateId);
if (loadedTemplateId) {
params.set("edit_plan_id", loadedTemplateId);
}
params.set("plan_config", JSON.stringify(planConfig));
navigate(`/app/generate?${params.toString()}`);
};
@@ -537,9 +457,7 @@ const EditingPlanner: React.FC = () => {
});
const res = await createGenerationTask({
template_id: loadedTemplateId,
asset_ids: clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v),
asset_ids: [],
title_ids: [],
voice_ids: [],
});
@@ -547,9 +465,9 @@ const EditingPlanner: React.FC = () => {
const task = await getTask(res.id);
setGenTask(task);
setGenPhase("progress");
msg.info("生成任务已创建");
message.info("生成任务已创建");
} catch {
msg.error("创建生成任务失败");
message.error("创建生成任务失败");
} finally {
setGenSubmitting(false);
}
@@ -588,7 +506,7 @@ const EditingPlanner: React.FC = () => {
setGenTask(t);
setGenPhase("progress");
} catch {
msg.error("重试失败");
message.error("重试失败");
} finally {
setGenSubmitting(false);
}
@@ -604,7 +522,7 @@ const EditingPlanner: React.FC = () => {
/* 查看生成历史 */
const handleViewGenHistory = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
message.warning("请先加载一个模板");
return;
}
setGenHistoryOpen(true);
@@ -613,7 +531,7 @@ const EditingPlanner: React.FC = () => {
const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items);
} catch {
msg.error("加载生成历史失败");
message.error("加载生成历史失败");
} finally {
setGenHistoryLoading(false);
}
@@ -724,9 +642,7 @@ const EditingPlanner: React.FC = () => {
onClipSelect={handleClipSelect}
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAssetDropToTimeline={handleAssetDropToTimeline}
onAssetDropToClip={handleAssetDropToClip}
mediaAssets={mediaAssets}
onAddClip={handleAddClip}
/>
</div>
@@ -739,7 +655,6 @@ const EditingPlanner: React.FC = () => {
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
aiRecommendLoading={aiRecommendLoading}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
@@ -750,7 +665,6 @@ const EditingPlanner: React.FC = () => {
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onAiRecommend={handleAiRecommend}
/>
</div>
@@ -4,19 +4,7 @@
*/
import React from "react";
import type { TemplateMode } from "@/api/editingPlanner";
import { MATERIAL_TYPE_LABELS } from "@/api/editPlans";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: "video" | "image" | "audio" | "voiceover";
thumbnail?: string;
assetName?: string;
media_asset_id?: string;
template_segment_id?: string;
script_text?: string;
}
import type { ClipData, ClipType } from "../types";
interface TitleSettings {
aiAutoSelect: boolean;
@@ -28,6 +16,7 @@ interface TitleSettings {
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
interface SubtitleSettings {
@@ -50,12 +39,10 @@ interface ClipPropertiesPanelProps {
clipsCount: number;
totalDuration: number;
currentMode: TemplateMode;
aiRecommendLoading: boolean;
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
onAiRecommend: () => void;
}
const POSITION_OPTIONS = [
@@ -64,7 +51,15 @@ const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
];
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑"];
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
];
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -82,61 +77,163 @@ const BGM_OPTIONS = [
];
/**
* 标题样式预设 — 一键应用一组样式
* 注意:预设只绑定样式属性(粗细、描边、阴影、字号),不绑定字体家族
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
* 不绑定字体,用户可自由搭配任意字体
* 预览统一用系统字体展示效果
*/
const TITLE_PRESETS = [
{
key: "default",
label: "默认",
key: "classic_white",
label: "经典白字",
style: {
size: 24,
bold: false,
italic: false,
stroke: false,
shadow: false,
},
},
{
key: "classic",
label: "经典",
style: {
size: 24,
size: 28,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
shadow: false,
},
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "bold",
label: "醒目",
key: "black_gold",
label: "黑金质感",
style: {
size: 32,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: {
size: 24,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
fontSize: "18px",
},
},
{
key: "variety_show",
label: "综艺花字",
style: {
size: 36,
color: "#ff4081",
bold: true,
italic: false,
stroke: true,
shadow: true,
},
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "elegant",
label: "柔和",
key: "business",
label: "商务极简",
style: {
size: 20,
size: 24,
color: "#1a1a1a",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#1a1a1a",
fontSize: "17px",
},
},
{
key: "retro_film",
label: "复古胶片",
style: {
size: 28,
color: "#e8d5b7",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: {
size: 32,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow:
"0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: {
size: 28,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
];
/** 判断当前设置匹配哪个预设(不含 font 字段 */
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体 */
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
@@ -148,6 +245,13 @@ function getActivePreset(settings: TitleSettings): string | null {
return null;
}
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" };
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
titleSettings,
@@ -156,12 +260,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
clipsCount,
totalDuration,
currentMode,
aiRecommendLoading,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onAiRecommend,
}) => {
return (
<div className="ep-right-panel">
@@ -241,38 +343,34 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-title-presets">
<div className="ep-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key;
return (
<button
key={p.key}
className={`ep-title-preset-btn${isActive ? " active" : ""}`}
onClick={() => onTitleSettingsChange({ ...p.style })}
className={`ep-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
onTitleSettingsChange({
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
})
}
title={p.label}
>
<span
className="ep-title-preset-t"
style={{
fontWeight: p.style.bold ? 700 : 400,
fontStyle: p.style.italic ? "italic" : "normal",
WebkitTextStroke: p.style.stroke
? "1px currentColor"
: undefined,
textShadow: p.style.shadow
? "2px 2px 4px rgba(0,0,0,0.5)"
: undefined,
fontSize:
p.style.size >= 32
? "22px"
: p.style.size >= 24
? "18px"
: "16px",
}}
className="ep-title-preset-preview-text"
style={p.previewStyle}
>
T
</span>
<span className="ep-title-preset-card-label">
{p.label}
</span>
<span className="ep-title-preset-label">{p.label}</span>
</button>
);
})}
@@ -452,15 +550,36 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-header">
<span className="ep-clip-detail-name">{selectedClip.name}</span>
<span className="ep-clip-detail-type">
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
</span>
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false; // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() =>
!disabled && onClipUpdate(selectedClip.id, { type: t })
}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
);
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
@@ -470,7 +589,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Number(e.target.value),
duration: Math.max(
1,
Math.min(120, Number(e.target.value) || 1),
),
})
}
/>
@@ -478,31 +600,31 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
</div>
{selectedClip.assetName && (
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
<span className="ep-clip-detail-linked">
{selectedClip.assetName}
</span>
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(
0,
Math.min(9999, Number(e.target.value) || 0),
),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
</div>
</div>
<button
className="ep-ai-recommend-btn"
onClick={onAiRecommend}
disabled={aiRecommendLoading}
>
{aiRecommendLoading ? "⏳ AI 分析中..." : "✨ AI 推荐素材"}
</button>
</div>
</div>
)}
@@ -4,15 +4,7 @@
* 封面右侧竖排4个方案按钮
*/
import React from "react";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: string;
thumbnail?: string;
assetName?: string;
}
import type { ClipData, ClipType } from "../types";
interface CoverScheme {
key: string;
@@ -54,11 +46,14 @@ interface PreviewPlayerProps {
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void;
}
const MATERIAL_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
};
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
@@ -97,7 +92,10 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
style={{ width: isPlaying ? "45%" : "0%" }}
/>
</div>
<div className="ep-phone-clip-label">{displayClip.name}</div>
<div className="ep-phone-clip-label">
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}{" "}
{CLIP_TYPE_LABELS[displayClip.type] || "片段"}
</div>
</>
) : (
<span className="ep-phone-empty-hint"></span>
@@ -168,7 +166,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
<div className="ep-cover-image">
{displayClip ? (
<span className="ep-cover-icon">
{MATERIAL_ICONS[displayClip.material_type] || "🎬"}
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}
</span>
) : (
<span></span>
@@ -1,23 +1,16 @@
/**
* 水平轨道时间线 — V8 原型 1:1 还原
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
* 支持「添加片段」按钮直接选择素材添加,不依赖拖拽
* "+" 卡片 → 类型+时长选择器
*/
import React, { useState, useRef } from "react";
import type { MediaAsset } from "@/api/editPlans";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: string;
thumbnail?: string;
assetName?: string;
media_asset_id?: string;
template_segment_id?: string;
script_text?: string;
order?: number;
}
import React, {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
} from "react";
import type { ClipData, ClipType } from "../types";
interface TimelinePanelProps {
clips: ClipData[];
@@ -26,22 +19,19 @@ interface TimelinePanelProps {
onClipSelect: (clipId: string) => void;
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAssetDropToTimeline: (asset: MediaAsset) => void;
onAssetDropToClip: (clipId: string, asset: MediaAsset) => void;
mediaAssets: MediaAsset[];
onAddClip: (type: ClipType, duration: number) => void;
}
const ASSET_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼",
audio: "🎵",
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙",
pip: "🖼️",
};
const MATERIAL_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const TimelinePanel: React.FC<TimelinePanelProps> = ({
@@ -51,18 +41,96 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipSelect,
onClipReorder,
onClipRemove,
onAssetDropToTimeline,
onAssetDropToClip,
mediaAssets,
onAddClip,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const dragRef = useRef<number | null>(null);
const [showAddPicker, setShowAddPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const addCardRef = useRef<HTMLDivElement>(null);
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
top: 0,
right: 0,
});
/* ── 点击外部关闭素材选择器 ── */
React.useEffect(() => {
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>("voice");
const [addDuration, setAddDuration] = useState<number>(5);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] =
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"]; // voice_pip 或默认
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
updatePickerPosition();
}
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
const vh = window.innerHeight;
const vw = window.innerWidth;
/* 默认:面板在"+"按钮上方 */
let top = addRect.top - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH;
if (top < 8) top = 8;
}
}
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
let right = vw - addRect.right;
const pickerRect = pickerEl.getBoundingClientRect();
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, [showAddPicker]);
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false);
@@ -74,9 +142,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddPicker]);
/* ── 选择素材添加片段 ── */
const handlePickAsset = (asset: MediaAsset) => {
onAssetDropToTimeline(asset);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
setShowAddPicker(false);
};
@@ -109,37 +177,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx);
}
return;
}
// 素材拖到片段上
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDropToClip(clips[toIdx].id, asset);
} catch {
/* ignore */
}
}
};
/* ── 空轨道区域拖入 ── */
const handleEmptyDrop = (e: React.DragEvent) => {
e.preventDefault();
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDropToTimeline(asset);
} catch {
/* ignore */
}
}
};
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
/* ── 时间标尺 ── */
@@ -187,135 +230,149 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 一镜到底提示 */}
{currentMode === "one_take" && (
<div className="ep-one-take-hint">
🎥
</div>
<div className="ep-one-take-hint">🎥 </div>
)}
{/* 时间标尺 */}
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
{t}s
</span>
))}
</div>
</div>
{/* 水平片段轨道 */}
<div
className="ep-clip-track"
onDrop={handleEmptyDrop}
onDragOver={handleEmptyDragOver}
>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text">
+
</div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 缩略图区域 */}
<div className="ep-clip-thumbnail">
{MATERIAL_ICONS[clip.material_type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">{clip.name}</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
</button>
</div>
))
)}
{t}s
</span>
))}
</div>
</div>
)}
{/* ── 轨道末尾 "+" 添加卡片(对齐 V21 原型 epAddSegment ── */}
<div className="ep-track-add-card-wrapper" ref={pickerRef}>
<div
className={`ep-track-add-card${currentMode === "one_take" ? " locked" : ""}`}
onClick={() => {
if (currentMode !== "one_take") setShowAddPicker((v) => !v);
}}
title={
currentMode === "one_take" ? "一镜到底模式下不可添加" : "添加片段"
}
>
{currentMode === "one_take" ? "🔒" : "+"}
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
className="ep-track-add-card"
onClick={handleTogglePicker}
title="添加片段"
>
+
</div>
</div>
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{showAddPicker && (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 素材选择面板 */}
{showAddPicker && (
<div className="ep-add-clip-picker">
<div className="ep-add-clip-picker-title"></div>
{mediaAssets.length === 0 ? (
<div className="ep-add-clip-picker-empty">
</div>
) : (
<div className="ep-add-clip-picker-list">
{mediaAssets.map((asset) => (
<button
key={asset.id}
className="ep-add-clip-picker-item"
onClick={() => handlePickAsset(asset)}
>
<span className="ep-add-clip-picker-icon">
{ASSET_TYPE_ICONS[asset.type] || "📁"}
</span>
<span className="ep-add-clip-picker-name">
{asset.name}
</span>
<span className="ep-add-clip-picker-type">
{asset.type === "image"
? "图片"
: asset.type === "audio"
? "音频"
: "视频"}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(
Math.max(1, Math.min(120, Number(e.target.value) || 1)),
)
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button
className="ep-add-clip-confirm-btn"
onClick={handleConfirmAdd}
>
</button>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,17 @@
/**
* 片段(Clip)统一类型定义
* 片段 = 时间规划 + 类型标记,不绑定任何素材
*/
export type ClipType = "voice" | "pip";
export interface ClipData {
id: string;
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
duration: number; // 时长(秒)
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
// 保留兼容字段(后端序列化需要)
template_segment_id?: string;
script_text?: string;
order?: number;
}
+2
View File
@@ -75,6 +75,8 @@ export const useAuthStore = create<AuthState>()(
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
}),
},
),
+35 -33
View File
@@ -6,25 +6,19 @@ ROOT_DIR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/repo"
COMPOSE_DIR="$ROOT_DIR/infra/docker"
ENV_FILE="$HOST_PREFIX/var/lib/xiaoxia-saas-production/.env"
RELEASE_VERSION="${RELEASE_VERSION:-}"
RUNTIME_IMAGE_TAR="${RUNTIME_IMAGE_TAR:-}"
WEB_IMAGE_TAR="${WEB_IMAGE_TAR:-}"
if [ -n "$RELEASE_VERSION" ] && [ -z "$RUNTIME_IMAGE_TAR" ]; then
RUNTIME_IMAGE_TAR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/runtime-images-$RELEASE_VERSION.tar"
fi
if [ -n "$RELEASE_VERSION" ] && [ -z "$WEB_IMAGE_TAR" ]; then
WEB_IMAGE_TAR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/web-$RELEASE_VERSION.tar"
fi
# Registry 配置
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ensure_container_running() {
name="$1"
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Required infrastructure container not found: $name"
echo "Run infra/docker/infra-production.yml first before deploying production applications."
exit 1
fi
state="$(docker inspect -f '{{.State.Status}}' "$name")"
state="$(docker inspect -f "{{.State.Status}}" "$name")"
if [ "$state" != "running" ]; then
echo "Required infrastructure container is not running: $name ($state)"
exit 1
@@ -39,10 +33,10 @@ fi
cd "$ROOT_DIR"
cp "$ENV_FILE" "$ROOT_DIR/.env"
mkdir -p "$ROOT_DIR/apps/web/public" "$HOST_PREFIX/var/lib/xiaoxia-saas-production/generated"
[ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf 'placeholder' > "$ROOT_DIR/apps/web/public/.keep"
[ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf "placeholder" > "$ROOT_DIR/apps/web/public/.keep"
if [ ! -f "$ROOT_DIR/apps/web/dist/index.html" ]; then
echo "Missing prebuilt web artifact: $ROOT_DIR/apps/web/dist/index.html"
echo "Production deploy must not build frontend assets on the server. Run npm run build before packaging the release artifact."
echo "Production deploy must not build frontend assets on the server."
exit 1
fi
@@ -50,23 +44,33 @@ ensure_container_running xiaoxia-postgres-production
ensure_container_running xiaoxia-redis-production
cd "$COMPOSE_DIR"
if [ -n "$RELEASE_VERSION" ]; then
if [ ! -f "$RUNTIME_IMAGE_TAR" ]; then
echo "Missing production runtime image artifact: $RUNTIME_IMAGE_TAR"
echo "Build it on a dedicated build host with scripts/build_release_images.sh $RELEASE_VERSION, then upload it before production deploy."
exit 1
fi
docker load -i "$RUNTIME_IMAGE_TAR"
if [ ! -f "$WEB_IMAGE_TAR" ]; then
echo "Missing production web image artifact: $WEB_IMAGE_TAR"
echo "Build it on a dedicated build host, then upload it before production deploy."
exit 1
fi
docker load -i "$WEB_IMAGE_TAR"
export API_IMAGE="xiaoxia-saas-api:$RELEASE_VERSION"
export WORKER_IMAGE="xiaoxia-saas-worker:$RELEASE_VERSION"
export WEB_IMAGE="${WEB_IMAGE:-xiaoxia-saas-web:$RELEASE_VERSION}"
export WEB_IMAGE="xiaoxia-saas-web:$RELEASE_VERSION"
export APP_VERSION="$RELEASE_VERSION"
# ---- 从 Registry Pull 镜像 ----
echo "Pulling images from registry: $REGISTRY"
if [ -n "$REGISTRY_TOKEN" ]; then
printf "%s" "$REGISTRY_TOKEN" | docker login git.xiaoxiajianji.com -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
fi
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$RELEASE_VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$RELEASE_VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$RELEASE_VERSION"
docker pull "$REGISTRY_API"
docker pull "$REGISTRY_WORKER"
docker pull "$REGISTRY_WEB"
# Re-tag 为本地镜像名,保持 compose 兼容
docker tag "$REGISTRY_API" "$API_IMAGE"
docker tag "$REGISTRY_WORKER" "$WORKER_IMAGE"
docker tag "$REGISTRY_WEB" "$WEB_IMAGE"
echo "All images pulled and tagged."
fi
export DOCKER_BUILDKIT=0
@@ -76,7 +80,6 @@ export ENV=production
export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile
export WEB_NGINX_CONF=infra/docker/nginx-production.conf
# Ensure isolated production network exists
docker network create xiaoxia-net-production 2>/dev/null || true
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"
export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"
@@ -86,24 +89,23 @@ if [ "${ALLOW_PRODUCTION_BUILDS:-false}" = "true" ]; then
docker compose --env-file "$ENV_FILE" build --pull=false worker
docker compose --env-file "$ENV_FILE" build --pull=false web
else
echo "Skipping production API/worker/web image builds. Set ALLOW_PRODUCTION_BUILDS=true only on a dedicated build host."
echo "Skipping production image builds (using prebuilt images from registry)."
docker image inspect "${API_IMAGE:-xiaoxia-saas-api:dev}" >/dev/null
docker image inspect "${WORKER_IMAGE:-xiaoxia-saas-worker:dev}" >/dev/null
docker image inspect "${WEB_IMAGE:-xiaoxia-saas-web:dev}" >/dev/null
fi
docker compose --env-file "$ENV_FILE" run --rm --no-deps api sh -c '
docker compose --env-file "$ENV_FILE" run --rm --no-deps api sh -c "
cd /app &&
python /app/scripts/validate_release_env.py --from-environ --strict-external &&
alembic upgrade head
'
"
docker compose --env-file "$ENV_FILE" up -d api worker
# Recreate web after API so nginx resolves the current API container IP.
# Docker's embedded DNS is resolved by nginx at startup for this static upstream.
docker compose --env-file "$ENV_FILE" up -d --force-recreate web
docker compose --env-file "$ENV_FILE" ps
if [ -n "$RELEASE_VERSION" ] && [ -d "$HOST_PREFIX/etc/cron.d" ]; then
cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" <<EOF
cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" << EOF
*/5 * * * * root cd /var/lib/xiaoxia-saas-production/repo && EXPECTED_VERSION=$RELEASE_VERSION sh scripts/production_resource_check.sh >/var/log/xiaoxia-resource-check.log 2>&1
EOF
fi
@@ -33,6 +33,8 @@ class SQLAlchemyUserRepository(UserRepository):
model.max_projects = user.max_projects
model.max_storage_gb = user.max_storage_gb
model.is_admin = user.is_admin
model.wechat_openid = user.wechat_openid
model.wechat_unionid = user.wechat_unionid
model.created_at = user.created_at
self.session.commit()
@@ -49,6 +51,16 @@ class SQLAlchemyUserRepository(UserRepository):
model = self.session.query(UserModel).filter(UserModel.username == username.strip()).first()
return self._to_entity(model)
def find_by_wechat_openid(self, openid: str) -> User | None:
model = self.session.query(UserModel).filter(UserModel.wechat_openid == openid.strip()).first()
return self._to_entity(model)
def find_by_wechat_unionid(self, unionid: str) -> User | None:
if not unionid or not unionid.strip():
return None
model = self.session.query(UserModel).filter(UserModel.wechat_unionid == unionid.strip()).first()
return self._to_entity(model)
def find_by_verification_token(self, token: str) -> User | None:
model = self.session.query(UserModel).filter(UserModel.email_verification_token == token).first()
return self._to_entity(model)
@@ -87,5 +99,7 @@ class SQLAlchemyUserRepository(UserRepository):
max_projects=model.max_projects or 3,
max_storage_gb=model.max_storage_gb or 10,
is_admin=model.is_admin or False,
wechat_openid=model.wechat_openid,
wechat_unionid=model.wechat_unionid,
created_at=model.created_at,
)
@@ -0,0 +1,206 @@
"""
微信同步登录/注册 Use Case
供 BFF 层调用的系统级接口:
- 根据 openid 查找用户,找到则登录返回 token
- 没找到则创建新用户并返回 token
- 支持 unionid 跨应用关联
"""
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import uuid4
import jwt as pyjwt
from packages.adapters.redis import get_session_store
from packages.application.auth.jwt_service import jwt_service
from packages.domain.entities import User
class WechatSyncRequest:
"""微信同步登录请求"""
def __init__(
self,
openid: str,
unionid: str = "",
nickname: str = "",
avatar_url: str = "",
source: str = "miniapp",
):
self.openid = openid.strip()
self.unionid = unionid.strip() if unionid else ""
self.nickname = nickname or "微信用户"
self.avatar_url = avatar_url or ""
self.source = source
class WechatSyncResponse:
"""微信同步登录响应"""
def __init__(
self,
access_token: str,
refresh_token: str,
user_id: str,
nickname: str,
avatar_url: str,
is_new_user: bool,
expires_in: int,
):
self.access_token = access_token
self.refresh_token = refresh_token
self.user_id = user_id
self.nickname = nickname
self.avatar_url = avatar_url
self.is_new_user = is_new_user
self.expires_in = expires_in
def to_dict(self) -> dict:
return {
"access_token": self.access_token,
"token": self.access_token, # 兼容 BFF 层用 token 字段读取
"refresh_token": self.refresh_token,
"user_id": self.user_id,
"user": {
"id": self.user_id,
"nickname": self.nickname,
"avatar_url": self.avatar_url,
"display_name": self.nickname,
},
"user_info": {
"id": self.user_id,
"nickname": self.nickname,
"avatar_url": self.avatar_url,
"display_name": self.nickname,
},
"is_new_user": self.is_new_user,
"expires_in": self.expires_in,
}
class WechatSyncUseCase:
"""微信同步登录/注册用例
系统级接口,由 BFF 通过 API Key 调用。
职责:根据 openid 查找或创建用户,返回 SaaS token。
"""
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
self.user_repository = user_repository
self.session_store = session_store or get_session_store()
self.jwt_secret_key = jwt_secret_key or jwt_service.config.SECRET_KEY
def execute(self, request: WechatSyncRequest) -> tuple[Optional[WechatSyncResponse], Optional[str]]:
"""
执行微信同步登录/注册
Returns:
(响应对象, 错误信息) - 成功则错误信息为 None
"""
try:
if not request.openid:
return None, "openid is required"
is_new_user = False
# 1. 按 openid 查找用户
user = self.user_repository.find_by_wechat_openid(request.openid)
# 2. 如果 openid 没找到,尝试 unionid
if not user and request.unionid:
user = self.user_repository.find_by_wechat_unionid(request.unionid)
if user:
# 找到用户但 openid 为空,绑定一下当前 openid
user.wechat_openid = request.openid
self.user_repository.save(user)
# 3. 都没找到则创建新用户
if not user:
user = self._create_wechat_user(request)
is_new_user = True
# 4. 创建 session 并生成 token
session_id = secrets.token_urlsafe(16)
refresh_token = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
access_token_payload = {
"sub": user.id,
"sid": session_id,
"type": "user_auth",
"iat": now,
"exp": now + timedelta(minutes=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES),
}
access_token = pyjwt.encode(
access_token_payload,
self.jwt_secret_key,
algorithm=jwt_service.config.ALGORITHM,
)
# 保存 session
self.session_store.save_session(
session_id=session_id,
user_id=user.id,
refresh_token=refresh_token,
device_info=f"wechat_{request.source}",
ip_address="bff_gateway",
expires_in_seconds=30 * 24 * 3600, # 30 天
)
# 更新最后登录信息
user.last_login_at = now
user.last_login_ip = "bff_gateway"
self.user_repository.save(user)
response = WechatSyncResponse(
access_token=access_token,
refresh_token=refresh_token,
user_id=user.id,
nickname=user.display_name,
avatar_url="", # SaaS 用户模型暂存头像,后续可扩展
is_new_user=is_new_user,
expires_in=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
return response, None
except Exception as e:
return None, f"Internal error: {str(e)}"
def _create_wechat_user(self, request: WechatSyncRequest) -> User:
"""创建微信用户"""
user_id = uuid4().hex
# 生成唯一名和邮箱(微信用户无真实邮箱,用 openid 生成占位)
safe_openid = request.openid.replace("-", "_")[:20]
username = f"wx_{safe_openid}"
email = f"{safe_openid}@wechat.local"
# 确保 username 唯一
suffix = 0
while self.user_repository.find_by_username(username):
suffix += 1
username = f"wx_{safe_openid}_{suffix}"
# 随机密码(微信用户不用密码登录)
random_password = secrets.token_urlsafe(32)
from packages.application.auth.password_hasher import password_hasher
password_hash = password_hasher.hash_password(random_password)
user = User(
id=user_id,
email=email,
username=username,
display_name=request.nickname or "微信用户",
password_hash=password_hash,
email_verified=True, # 微信登录视为已验证
wechat_openid=request.openid,
wechat_unionid=request.unionid or None,
)
self.user_repository.save(user)
return user
+5
View File
@@ -54,6 +54,11 @@ class User:
used_storage_gb: float = 0.0
# 管理员标识
is_admin: bool = False
# 微信绑定
wechat_openid: str | None = None
wechat_unionid: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+10
View File
@@ -41,6 +41,16 @@ class UserRepository(ABC):
"""根据密码重置令牌查找用户"""
pass
@abstractmethod
def find_by_wechat_openid(self, openid: str) -> Optional[User]:
"""根据微信 openid 查找用户"""
pass
@abstractmethod
def find_by_wechat_unionid(self, unionid: str) -> Optional[User]:
"""根据微信 unionid 查找用户"""
pass
@abstractmethod
def delete(self, user_id: str) -> bool:
"""删除用户"""
+68 -22
View File
@@ -9,42 +9,44 @@ if [ -z "$VERSION" ]; then
fi
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/dist/release-images}"
mkdir -p "$OUTPUT_DIR"
API_IMAGE="xiaoxia-saas-api:$VERSION"
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
API_LATEST="xiaoxia-saas-api:dev"
WORKER_LATEST="xiaoxia-saas-worker:dev"
TAR_PATH="$OUTPUT_DIR/xiaoxia-runtime-images-$VERSION.tar"
cd "$ROOT_DIR"
if docker ps --format '{{.Names}}' | grep -Eq '^(xiaoxia-(api|web|worker|postgres|redis)-production|gitea)$'; then
if docker ps --format "{{.Names}}" | grep -Eq "^(xiaoxia-(api|web|worker|postgres|redis)-production|gitea)$"; then
if [ "${ALLOW_SHARED_PRODUCTION_BUILD_HOST:-false}" != "true" ]; then
echo "Refusing to build runtime images on a host that is running production services."
echo "Use a dedicated build host/CI runner, or set ALLOW_SHARED_PRODUCTION_BUILD_HOST=true only for an explicitly approved emergency."
exit 1
fi
fi
# ---- BuildKit 缓存优化 ----
# ---- Registry 配置 ----
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
CACHE_REGISTRY="${CACHE_REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
CACHE_TAG="${CACHE_TAG:-release}"
USE_CACHE=0
# 检查 buildx 和 Gitea Registry 认证是否可用
API_IMAGE="xiaoxia-saas-api:$VERSION"
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
WEB_IMAGE="xiaoxia-saas-web:$VERSION"
API_LATEST="xiaoxia-saas-api:dev"
WORKER_LATEST="xiaoxia-saas-worker:dev"
# Registry 上的完整镜像名
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$VERSION"
USE_CACHE=0
USE_PUSH=0
# 检查 buildx 和 Registry 认证
if docker buildx version >/dev/null 2>&1; then
# 尝试登录缓存 Registry(有 token 才启用缓存)
if [ -n "${REGISTRY_TOKEN:-}" ]; then
printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null && USE_CACHE=1
printf "%s" "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null && USE_CACHE=1 && USE_PUSH=1
fi
# 确保使用 docker driver(共享 daemon 凭证)
docker buildx use default 2>/dev/null || true
fi
echo "=== Building API image ==="
if [ "$USE_CACHE" -eq 1 ]; then
echo "Using buildx with distributed cache (${CACHE_REGISTRY})"
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
@@ -52,6 +54,12 @@ if [ "$USE_CACHE" -eq 1 ]; then
-t "$API_IMAGE" -t "$API_LATEST" \
--load \
.
else
docker build --pull=false -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
fi
echo "=== Building Worker image ==="
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
@@ -60,11 +68,49 @@ if [ "$USE_CACHE" -eq 1 ]; then
--load \
.
else
echo "Buildx cache not available, using plain docker build"
docker build --pull=false -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
docker build --pull=false -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
fi
docker save "$API_IMAGE" "$API_LATEST" "$WORKER_IMAGE" "$WORKER_LATEST" -o "$TAR_PATH"
echo "=== Building Web image (with buildx cache) ==="
# 先构建前端产物
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm ci && npm run build"
printf '%s\n' "$TAR_PATH"
test -f apps/web/dist/index.html
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "$WEB_IMAGE" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "$WEB_IMAGE" \
.
fi
# Push 到 Registry
if [ "$USE_PUSH" -eq 1 ]; then
echo "=== Pushing images to Registry ==="
docker tag "$API_IMAGE" "$REGISTRY_API"
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER"
docker tag "$WEB_IMAGE" "$REGISTRY_WEB"
docker push "$REGISTRY_API"
docker push "$REGISTRY_WORKER"
docker push "$REGISTRY_WEB"
echo "All images pushed to $REGISTRY"
else
echo "Registry push skipped (no auth token available)"
fi
echo "=== Build complete ==="
docker images | grep "xiaoxia-saas.*:$VERSION"