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
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:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const useLogout = () => {
|
||||
} finally {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate("/login");
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -75,6 +75,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""删除用户"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user