diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 78e3255..0f885ee 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -27,6 +27,7 @@ jobs: echo "CR_ORG=prod" >> $GITHUB_ENV echo "DEPLOY_ENV=production" >> $GITHUB_ENV echo "DOMAIN_WEB=airshelf.airlabs.art" >> $GITHUB_ENV + echo "DOMAIN_CORE=airshelf-web.airlabs.art" >> $GITHUB_ENV elif [[ "${{ github.ref_name }}" == "dev" ]]; then echo "IMAGE_TAG=dev-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV @@ -35,6 +36,7 @@ jobs: echo "CR_ORG=dev" >> $GITHUB_ENV echo "DEPLOY_ENV=development" >> $GITHUB_ENV echo "DOMAIN_WEB=airshelf.test.airlabs.art" >> $GITHUB_ENV + echo "DOMAIN_CORE=airshelf-web.test.airlabs.art" >> $GITHUB_ENV fi - name: Login to Volcano Engine CR @@ -63,6 +65,50 @@ jobs: done [ $ok -eq 1 ] || { echo "ERROR: web push failed after 3 attempts"; exit 1; } + - name: Build and Push Core API (Django) + id: build_core_api + run: | + set -o pipefail + ok=0 + for attempt in 1 2 3; do + echo "Build core-api attempt $attempt/3..." + DOCKER_BUILDKIT=0 docker build \ + --tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:${{ env.IMAGE_TAG }} \ + --tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:latest \ + "./core/backend" 2>&1 | tee /tmp/build-core-api.log && { ok=1; break; } + echo "Attempt $attempt failed, retrying in 10s..." && sleep 10 + done + [ $ok -eq 1 ] || { echo "ERROR: core-api build failed after 3 attempts"; exit 1; } + ok=0 + for attempt in 1 2 3; do + docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:${{ env.IMAGE_TAG }} && \ + docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-api:latest && { ok=1; break; } + echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10 + done + [ $ok -eq 1 ] || { echo "ERROR: core-api push failed after 3 attempts"; exit 1; } + + - name: Build and Push Core Web (React/Vite) + id: build_core_web + run: | + set -o pipefail + ok=0 + for attempt in 1 2 3; do + echo "Build core-web attempt $attempt/3..." + DOCKER_BUILDKIT=0 docker build \ + --tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:${{ env.IMAGE_TAG }} \ + --tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:latest \ + "./core/frontend" 2>&1 | tee /tmp/build-core-web.log && { ok=1; break; } + echo "Attempt $attempt failed, retrying in 10s..." && sleep 10 + done + [ $ok -eq 1 ] || { echo "ERROR: core-web build failed after 3 attempts"; exit 1; } + ok=0 + for attempt in 1 2 3; do + docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:${{ env.IMAGE_TAG }} && \ + docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-core-web:latest && { ok=1; break; } + echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10 + done + [ $ok -eq 1 ] || { echo "ERROR: core-web push failed after 3 attempts"; exit 1; } + - name: Setup Kubectl run: | if ! command -v kubectl &>/dev/null; then @@ -94,12 +140,32 @@ jobs: echo "Environment: ${{ env.DEPLOY_ENV }}" CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}" - # Replace image placeholders + # Replace image placeholders (PRD design site) sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-web:latest|${CR_IMAGE}/airshelf-web:${{ env.IMAGE_TAG }}|g" k8s/web-deployment.yaml # Replace domain placeholder in ingress sed -i "s|airshelf.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/ingress.yaml + # ===== Core (real app) image + domain substitution ===== + sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-api:latest|${CR_IMAGE}/airshelf-core-api:${{ env.IMAGE_TAG }}|g" k8s/core/api-deployment.yaml k8s/core/worker-deployment.yaml + sed -i "s|\${CI_REGISTRY_IMAGE}/airshelf-core-web:latest|${CR_IMAGE}/airshelf-core-web:${{ env.IMAGE_TAG }}|g" k8s/core/web-deployment.yaml + sed -i "s|airshelf-web.airlabs.art|${{ env.DOMAIN_CORE }}|g" k8s/core/ingress.yaml + + # ===== Build core env file: core/backend/.env + production overrides ===== + # Source of truth is core/backend/.env (committed; real MySQL + managed Redis + TOS + ARK). + # Override only the env-specific bits; DB_BIND_ADDRESS is dropped (dev LAN IP + # has no NIC in-cluster), settings -> production, hosts/CSRF/CORS -> the domain. + grep -vE '^\s*(#|$)' core/backend/.env \ + | grep -vE '^(DJANGO_SETTINGS_MODULE|DJANGO_DEBUG|DB_BIND_ADDRESS|DJANGO_ALLOWED_HOSTS|DJANGO_CSRF_TRUSTED_ORIGINS|CORS_ALLOWED_ORIGINS)=' \ + > /tmp/core.env + { + echo "DJANGO_SETTINGS_MODULE=airshelf.settings.production" + echo "DJANGO_DEBUG=false" + echo "DJANGO_ALLOWED_HOSTS=airshelf-web.airlabs.art,${{ env.DOMAIN_CORE }},localhost,127.0.0.1" + echo "DJANGO_CSRF_TRUSTED_ORIGINS=https://airshelf-web.airlabs.art,https://${{ env.DOMAIN_CORE }}" + echo "CORS_ALLOWED_ORIGINS=https://airshelf-web.airlabs.art,https://${{ env.DOMAIN_CORE }}" + } >> /tmp/core.env + # All kubectl operations with retry (K3s 内网连接可能抖动) export KUBECTL_TIMEOUT="--request-timeout=4s" @@ -114,16 +180,32 @@ jobs: --docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \ --dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f - - # Apply manifests + # Core backend env secret (real MySQL / managed Redis / TOS / ARK) + kubectl $KUBECTL_TIMEOUT create secret generic airshelf-core-env \ + --from-env-file=/tmp/core.env \ + --dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f - + + # Apply manifests — shared infra kubectl $KUBECTL_TIMEOUT apply -f k8s/cert-manager-issuer.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/redirect-https-middleware.yaml + + # PRD design site kubectl $KUBECTL_TIMEOUT apply -f k8s/web-deployment.yaml kubectl $KUBECTL_TIMEOUT apply -f k8s/ingress.yaml + # Core real app (api + celery worker + web + ingress) + kubectl $KUBECTL_TIMEOUT apply -f k8s/core/api-deployment.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/core/worker-deployment.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/core/web-deployment.yaml + kubectl $KUBECTL_TIMEOUT apply -f k8s/core/ingress.yaml + # Preserve real client IP kubectl $KUBECTL_TIMEOUT patch svc traefik -n kube-system -p '{"spec":{"externalTrafficPolicy":"Local"}}' 2>/dev/null || true kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-web + kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-api + kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-worker + kubectl $KUBECTL_TIMEOUT rollout restart deployment/airshelf-core-web } 2>&1 | tee /tmp/deploy.log && { ok=1; break; } echo "Attempt $attempt failed, retrying in 30s..." sleep 30 @@ -143,6 +225,16 @@ jobs: if [ -f /tmp/build.log ]; then BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') fi + elif [[ "${{ steps.build_core_api.outcome }}" == "failure" ]]; then + FAILED_STEP="build" + if [ -f /tmp/build-core-api.log ]; then + BUILD_LOG=$(tail -50 /tmp/build-core-api.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + fi + elif [[ "${{ steps.build_core_web.outcome }}" == "failure" ]]; then + FAILED_STEP="build" + if [ -f /tmp/build-core-web.log ]; then + BUILD_LOG=$(tail -50 /tmp/build-core-web.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') + fi elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then FAILED_STEP="deploy" if [ -f /tmp/deploy.log ]; then diff --git a/.gitignore b/.gitignore index 6590034..64e1176 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ out dist .env*.local .env +# core 后端环境变量需要进 CI 构建,放行它(其余 .env 仍忽略) +!core/backend/.env .venv __pycache__/ *.pyc diff --git a/core/backend/.dockerignore b/core/backend/.dockerignore new file mode 100644 index 0000000..6de7c6f --- /dev/null +++ b/core/backend/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +**/__pycache__/ +*.pyc +db.sqlite3 +.env +.env.* +!.env.example +tests/ +*.md +staticfiles/ diff --git a/core/backend/.env b/core/backend/.env new file mode 100644 index 0000000..12aacd9 --- /dev/null +++ b/core/backend/.env @@ -0,0 +1,24 @@ +DJANGO_SETTINGS_MODULE=airshelf.settings.development +DJANGO_SECRET_KEY=S2GYXa8YC21lmnFfVwC+6cyFNhCCSoclhpylOmSAm16vKflUgQi398VQQSM+Rbit +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=airshelf-web.airlabs.art,airshelf-web.test.airlabs.art,localhost,127.0.0.1,192.168.124.86 +DJANGO_CSRF_TRUSTED_ORIGINS=https://airshelf-web.airlabs.art,https://airshelf-web.test.airlabs.art,https://airshelf.airlabs.art,https://api.airshelf.airlabs.art,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173,http://192.168.124.86:5173 +CORS_ALLOWED_ORIGINS=https://airshelf-web.airlabs.art,https://airshelf-web.test.airlabs.art,https://airshelf.airlabs.art,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173,http://192.168.124.86:5173 +DB_ENGINE=mysql +DB_NAME=airshelf_test +DB_USER=airshelf_app +DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85 +DB_HOST=14.103.27.192 +DB_PORT=3306 +DB_BIND_ADDRESS=192.168.124.86 +REDIS_CACHE_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/0 +CELERY_BROKER_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/1 +CELERY_RESULT_BACKEND=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/2 +REDIS_LOCK_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/3 +TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com +TOS_BUCKET=airshelf +TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY +TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ== +VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951 +VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +DEFAULT_TRIAL_CREDITS=1000.0000 diff --git a/core/backend/Dockerfile b/core/backend/Dockerfile new file mode 100644 index 0000000..27d17db --- /dev/null +++ b/core/backend/Dockerfile @@ -0,0 +1,26 @@ +# ---- AirShelf core backend: Django + DRF + gunicorn / celery ---- +FROM docker.m.daocloud.io/python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ + +WORKDIR /app + +# PyMySQL is pure-python (install_as_MySQLdb), boto3/gunicorn need no C deps, +# so the slim image is enough — no build-essential required. +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +COPY . . + +# Collected admin/static lands here; served by WhiteNoise (see settings/production.py) +RUN mkdir -p /app/staticfiles + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 8000 +ENTRYPOINT ["docker-entrypoint.sh"] +CMD ["gunicorn", "airshelf.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"] diff --git a/core/backend/airshelf/settings/production.py b/core/backend/airshelf/settings/production.py index 2277a85..15482bd 100644 --- a/core/backend/airshelf/settings/production.py +++ b/core/backend/airshelf/settings/production.py @@ -1,4 +1,7 @@ +from pathlib import Path + from .base import * # noqa: F403 +from .base import BASE_DIR, MIDDLEWARE DEBUG = False @@ -6,3 +9,16 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True +# Serve admin/static behind gunicorn with DEBUG=False (no nginx static mount needed). +# WhiteNoise sits right after SecurityMiddleware. +MIDDLEWARE = ( + MIDDLEWARE[:1] + + ["whitenoise.middleware.WhiteNoiseMiddleware"] + + MIDDLEWARE[1:] +) + +STATIC_ROOT = Path(BASE_DIR) / "staticfiles" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, +} diff --git a/core/backend/docker-entrypoint.sh b/core/backend/docker-entrypoint.sh new file mode 100644 index 0000000..6f05cc9 --- /dev/null +++ b/core/backend/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# Only the web (gunicorn) container should run migrations / collectstatic. +# The celery worker shares this image but skips DB schema mutation to avoid races. +case "$1" in + gunicorn) + echo "[entrypoint] running migrations..." + python manage.py migrate --noinput + echo "[entrypoint] collecting static..." + python manage.py collectstatic --noinput + ;; +esac + +exec "$@" diff --git a/core/backend/requirements.txt b/core/backend/requirements.txt index 378ff1f..4f1994a 100644 --- a/core/backend/requirements.txt +++ b/core/backend/requirements.txt @@ -8,4 +8,5 @@ python-dotenv>=1.0,<2.0 boto3>=1.34,<2.0 requests>=2.31,<3.0 gunicorn>=21.2,<23.0 +whitenoise>=6.6,<7.0 diff --git a/core/frontend/.dockerignore b/core/frontend/.dockerignore new file mode 100644 index 0000000..7945624 --- /dev/null +++ b/core/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.tsbuildinfo +.env +.env.* +*.md diff --git a/core/frontend/Dockerfile b/core/frontend/Dockerfile new file mode 100644 index 0000000..11f1065 --- /dev/null +++ b/core/frontend/Dockerfile @@ -0,0 +1,27 @@ +# ---- Stage 1: build the React/Vite SPA ---- +FROM docker.m.daocloud.io/node:20-alpine AS build + +WORKDIR /app + +# npm registry mirror for CN runners +RUN npm config set registry https://registry.npmmirror.com + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +# VITE_API_BASE_URL is empty => the SPA calls /api on the same origin, +# which nginx (below) proxies to the backend service. Zero CORS. +ENV VITE_API_BASE_URL="" +RUN npm run build + +# ---- Stage 2: serve static + reverse-proxy /api to backend ---- +FROM docker.m.daocloud.io/nginx:alpine + +RUN sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/core/frontend/nginx.conf b/core/frontend/nginx.conf new file mode 100644 index 0000000..259698b --- /dev/null +++ b/core/frontend/nginx.conf @@ -0,0 +1,67 @@ +server_tokens off; +charset utf-8; + +# Backend (Django gunicorn) service inside the cluster. +upstream airshelf_api { + server airshelf-core-api:8000; +} + +# Preserve the original scheme: traefik terminates TLS and forwards +# X-Forwarded-Proto=https; fall back to our own scheme if it's absent. +map $http_x_forwarded_proto $fwd_proto { + default $http_x_forwarded_proto; + "" $scheme; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 50m; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ---- Backend pass-through (same origin, no CORS) ---- + location /api/ { + proxy_pass http://airshelf_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $fwd_proto; + proxy_read_timeout 300s; + } + + location /admin/ { + proxy_pass http://airshelf_api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $fwd_proto; + } + + # Django/WhiteNoise admin static assets + location /static/ { + proxy_pass http://airshelf_api; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $fwd_proto; + } + + # ---- Frontend SPA: hashed assets cache long, index.html never ---- + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-cache, must-revalidate" always; + expires off; + } + + # SPA fallback: every unknown path serves index.html (client-side routing) + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/core/frontend/public/assets/pay-alipay.png b/core/frontend/public/assets/pay-alipay.png new file mode 100644 index 0000000..98869d4 Binary files /dev/null and b/core/frontend/public/assets/pay-alipay.png differ diff --git a/core/frontend/public/assets/pay-wechat.png b/core/frontend/public/assets/pay-wechat.png new file mode 100644 index 0000000..50f2d3c Binary files /dev/null and b/core/frontend/public/assets/pay-wechat.png differ diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index b69b1ff..da61c6e 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Bell, CircleDollarSign } from "lucide-react"; import { api, getToken, setToken } from "./api"; +import { IconKitSvg } from "./components/IconKitSvg"; import type { AITask, Asset, @@ -13,7 +13,7 @@ import type { TeamMember, User } from "./types"; -import { Decorations, Sidebar, ToastLike } from "./components/app-shell"; +import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell"; import { AccountPage, AssetFactoryPage, @@ -36,6 +36,28 @@ import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./r import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config"; import { money } from "./routes/stage-config"; +const crumbLabels: Partial> = { + dashboard: "工作台", + products: "商品库", + productDetail: "商品详情", + productCreateUpload: "新建商品", + projects: "视频项目", + projectWizard: "新建视频项目", + pipeline: "生产管线", + library: "资产库", + account: "消费", + team: "团队", + messages: "消息中心", + assetFactory: "图片生成", + imageOptimize: "图片创作", + modelPhoto: "模特上身图", + modelPhotoDemoA: "模特图方案 A", + modelPhotoDemoB: "模特图方案 B", + platformCover: "平台套图", + settings: "设置", + settingsNotify: "设置" +}; + export function App() { const [route, setRoute] = useState(() => resolveRoute()); const page = route.page; @@ -275,10 +297,6 @@ export function App() { projects={projects.filter((project) => project.product === activeProduct.id)} navigate={navigate} onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")} - onDelete={async () => { - const ok = await action(() => api.deleteProduct(activeProduct.id), "商品已删除"); - if (ok !== null) navigate("products"); - }} /> ); case "projects": @@ -303,68 +321,29 @@ export function App() { }} /> ); - case "pipeline": { - const project = projectDetail || activeProject; - if (!project) { - return ( -
-
-

暂无项目

-
- // 先创建一个视频项目 -
-
-
- + case "pipeline": + // 有项目时由下方 full-screen 特例渲染;这里只兜底「暂无项目」 + return ( +
+
+

暂无项目

+
+ // 先创建一个视频项目
- ); - } - return ( - action(() => api.generateScript(project.id, { prompt }), "脚本已生成")} - onAdoptScript={(scriptId) => action(() => api.adoptScript(project.id, scriptId), "脚本已采用")} - onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(project.id, { kind, prompt }), "基础资产已生成")} - onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(project.id, { prompt }), "故事板已生成")} - onSkipStoryboard={() => action(() => api.skipStoryboard(project.id), "已跳过故事板")} - onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(project.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")} - onPollVideo={(segmentId) => action(() => api.pollVideo(project.id, segmentId), "片段状态已刷新")} - onSubmitAllVideos={(prompt) => - action(async () => { - const targets = project.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status)); - for (const segment of targets) { - await api.submitVideo(project.id, { - video_segment_id: segment.id, - prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒` - }); - } - return targets.length; - }, "60s 多段视频任务已提交") - } - onPollAllVideos={() => - action(async () => { - const targets = project.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); - for (const segment of targets) { - await api.pollVideo(project.id, segment.id).catch(() => undefined); - } - return targets.length; - }, "视频片段状态已刷新") - } - onSubmitExport={() => action(() => api.submitExport(project.id), "导出任务已提交")} - /> +
+ +
+
); - } case "library": return action(() => api.uploadAsset(formData), "资产已上传")} />; case "account": return ; case "team": - return ; + return ; case "messages": return ; case "assetFactory": @@ -388,31 +367,92 @@ export function App() { } } - const avatarChar = (user.username || "U").slice(0, 1).toUpperCase(); + const avatarChar = (currentTeam.name || currentUser.username || "A").slice(0, 1).toUpperCase(); + const here = crumbLabels[page] || routeLabels[page] || "工作台"; + + // 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell + const pipelineProject = projectDetail || activeProject; + if (page === "pipeline" && pipelineProject) { + return ( + action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")} + onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")} + onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")} + onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已生成")} + onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")} + onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")} + onPollVideo={(segmentId) => action(() => api.pollVideo(pipelineProject.id, segmentId), "片段状态已刷新")} + onSubmitAllVideos={(prompt) => + action(async () => { + const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status)); + for (const segment of targets) { + await api.submitVideo(pipelineProject.id, { + video_segment_id: segment.id, + prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒` + }); + } + return targets.length; + }, "60s 多段视频任务已提交") + } + onPollAllVideos={() => + action(async () => { + const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); + for (const segment of targets) { + await api.pollVideo(pipelineProject.id, segment.id).catch(() => undefined); + } + return targets.length; + }, "视频片段状态已刷新") + } + onSubmitExport={() => action(() => api.submitExport(pipelineProject.id), "导出任务已提交")} + /> + ); + } return (
- +
- {routeLabels[page]} + {page === "dashboard" ? ( + 工作台 + ) : ( + <> + { event.preventDefault(); navigate("dashboard"); }}>工作台 + / + {here} + + )}
- - - +
+ {notice && } {renderPage()}
diff --git a/core/frontend/src/account-page.css b/core/frontend/src/account-page.css new file mode 100644 index 0000000..426e198 --- /dev/null +++ b/core/frontend/src/account-page.css @@ -0,0 +1,161 @@ +/* 账户页 · 从 public/exact/account.html 内联