feat(k8s): 新增 core 真应用(前端+Django API+Celery worker)构建与部署
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s

- core/frontend: Vite 多阶段镜像 + nginx 同源反代 /api,/admin,/static(零 CORS)
- core/backend: Django gunicorn 镜像 + entrypoint(自动 migrate/collectstatic)+ WhiteNoise
- k8s/core: api/worker/web Deployment+Service + ingress(airshelf-web.airlabs.art)
- workflow: 追加 core 前后端 build/push,从 core/backend/.env 套生产覆盖生成 env Secret 后部署
- .gitignore 放行 core/backend/.env;.env 白名单加入 airshelf-web 域名
- 含前端 WIP 还原改动

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc 2026-06-05 10:21:29 +08:00
parent cfdcd84a30
commit d41e487f08
37 changed files with 4108 additions and 456 deletions

View File

@ -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

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ out
dist
.env*.local
.env
# core 后端环境变量需要进 CI 构建,放行它(其余 .env 仍忽略)
!core/backend/.env
.venv
__pycache__/
*.pyc

View File

@ -0,0 +1,10 @@
.venv/
**/__pycache__/
*.pyc
db.sqlite3
.env
.env.*
!.env.example
tests/
*.md
staticfiles/

24
core/backend/.env Normal file
View File

@ -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

26
core/backend/Dockerfile Normal file
View File

@ -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"]

View File

@ -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"},
}

View File

@ -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 "$@"

View File

@ -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

View File

@ -0,0 +1,6 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
*.md

27
core/frontend/Dockerfile Normal file
View File

@ -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;"]

67
core/frontend/nginx.conf Normal file
View File

@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -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<Record<Page, string>> = {
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<ResolvedRoute>(() => 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 (
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 先创建一个视频项目</span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}>
</button>
case "pipeline":
// 有项目时由下方 full-screen 特例渲染;这里只兜底「暂无项目」
return (
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 先创建一个视频项目</span>
</div>
</div>
);
}
return (
<PipelinePage
project={project}
loading={loading}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => 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), "导出任务已提交")}
/>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}>
</button>
</div>
</div>
);
}
case "library":
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
case "account":
return <AccountPage billing={billing} ledgers={ledgers} projects={projects} teamMembers={teamMembers} />;
case "team":
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} navigate={navigate} />;
return <TeamPage team={currentTeam} user={currentUser} members={teamMembers} billing={billing} navigate={navigate} />;
case "messages":
return <MessagesPage navigate={navigate} />;
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 (
<PipelinePage
project={pipelineProject}
loading={loading}
navigate={navigate}
user={currentUser}
team={currentTeam}
products={products}
projects={projects}
billing={billing}
notice={notice}
avatarChar={avatarChar}
logout={logout}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => 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 (
<div className="app">
<Sidebar page={page} navigate={navigate} user={user} products={products} />
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} />
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
<span className="here">{routeLabels[page]}</span>
{page === "dashboard" ? (
<span className="here"></span>
) : (
<>
<a href="/dashboard" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}></a>
<span className="sep">/</span>
<span className="here">{here}</span>
</>
)}
</div>
<div className="right">
<button className="balance-chip" type="button" onClick={() => navigate("account")}>
<CircleDollarSign size={13} />
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
<span className="count-noti">12</span>
</button>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} aria-label="消息">
<Bell size={15} />
</button>
<button className="topbar-avatar" type="button" onDoubleClick={logout} title="双击退出登录">
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
<span>{avatarChar}</span>
</button>
</div>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{renderPage()}
</div>

View File

@ -0,0 +1,161 @@
/* 账户页 · 从 public/exact/account.html 内联 <style> 忠实移植,整段 scope 进 .account-page 防冲突 */
.account-page {
.top-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(480px, .85fr); gap: 16px; margin-bottom: 22px; align-items: stretch; }
@media (max-width: 1120px) { .top-grid { grid-template-columns: 1fr; } }
.balance-banner {
background: var(--accent-black);
color: var(--accent-white);
padding: 26px 28px;
position: relative;
border: 1px solid var(--accent-black);
border-radius: var(--r-md);
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
min-height: 246px;
}
.balance-banner::before, .balance-banner::after,
.balance-banner > .corner-tr, .balance-banner > .corner-bl {
content: ''; position: absolute; width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain; pointer-events: none;
}
.balance-banner::before { top: -7px; left: -7px; }
.balance-banner::after { bottom: -7px; right: -7px; }
.balance-banner > .corner-tr { top: -7px; right: -7px; }
.balance-banner > .corner-bl { bottom: -7px; left: -7px; }
.balance-hero { display: flex; flex-direction: column; gap: 4px; }
.balance-hero .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.balance-hero .v { font-size: 38px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
.balance-hero .meta { font-size: 11.5px; color: rgba(255,255,255,.5); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }
.balance-sub { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.1); }
.balance-sub .col { min-width: 0; }
.balance-sub .lbl { font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); letter-spacing: .06em; text-transform: uppercase; }
.balance-sub .v { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.balance-sub .meta { font-size: 10.5px; color: rgba(255,255,255,.42); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.balance-foot { margin-top: auto; padding-top: 2px; }
.balance-meter { height: 5px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); overflow: hidden; }
.balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--heat); border-radius: inherit; }
.balance-foot-meta { display: flex; justify-content: space-between; gap: 14px; margin-top: 8px; color: rgba(255,255,255,.46); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.pane .desc { font-size: 11.5px; color: var(--black-alpha-48); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; }
.topup-pane { display: flex; flex-direction: column; padding: 20px 22px; margin-bottom: 0; min-height: 246px; }
.topup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.topup-head h3 { margin-bottom: 5px; }
.topup-head .desc { margin-bottom: 0; }
.topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); white-space: nowrap; padding-top: 2px; }
.recharge-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.recharge-card { min-height: 76px; border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 8px; text-align: center; cursor: pointer; background: var(--surface); position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), transform var(--t-fast); }
.recharge-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }
.recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--heat-40); }
.recharge-card.selected { border-color: var(--heat); background: var(--heat-12); box-shadow: inset 0 0 0 1px var(--heat); }
.recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--heat); color: var(--accent-white); font-size: 10px; line-height: 1; }
.recharge-card .amt { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.15; }
.recharge-card .gift { font-size: 10px; color: var(--black-alpha-48); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; }
.recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; }
.recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--heat); color: var(--accent-white); letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); }
.pay-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding-top: 0; border-top: 0; }
.pay-title { font-size: 12px; font-weight: 600; color: var(--accent-black); line-height: 1.2; }
.pay-row .input { width: 100%; box-sizing: border-box; height: 38px; }
.pay-btn-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.pay-method-btn { height: 38px; border-radius: var(--r-pill); display: inline-flex; justify-content: center; align-items: center; gap: 8px; background: var(--surface); color: var(--accent-black); border-color: var(--border-faint); font-weight: 500; }
.pay-method-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }
.pay-method-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--black-alpha-16); }
.pay-logo { width: 18px; height: 18px; border-radius: 6px; display: inline-block; flex: 0 0 18px; overflow: hidden; }
.pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; }
@media (max-width: 720px) { .recharge-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.billing-tabs { display: flex; align-items: flex-end; gap: 4px; border-bottom: 1px solid var(--border-faint); margin: 24px 0 18px; padding: 0 2px; overflow-x: auto; scrollbar-width: none; }
.billing-tabs::-webkit-scrollbar { display: none; }
.billing-tabs .tab { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 6px; background: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: var(--r-md) var(--r-md) 0 0; margin-bottom: -1px; padding: 10px 14px; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); font-family: inherit; cursor: pointer; letter-spacing: 0; user-select: none; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.billing-tabs .tab:hover { color: var(--accent-black); background: var(--black-alpha-4); }
.billing-tabs .tab:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--heat-40); }
.billing-tabs .tab.active { color: var(--accent-black); border-bottom-color: var(--heat); font-weight: 600; }
.billing-tabs .tab .count { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding: 1px 7px; background: var(--black-alpha-4); border-radius: var(--r-sm); letter-spacing: .04em; }
.billing-tabs .tab.active .count { background: var(--heat-12); color: var(--heat); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; }
.trend-pane { padding: 18px 20px 14px; display: flex; flex-direction: column; }
.trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }
.trend-head h3 { margin-bottom: 0; }
.trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.trend-head .spacer { flex: 1; }
.trend-head .chip { font-family: var(--font-mono); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-pill); color: var(--black-alpha-56); cursor: pointer; }
.trend-head .chip.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; }
.trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; }
.trend-chart .bar > span { display: block; width: 100%; background: var(--heat); border-radius: 2px 2px 0 0; }
.trend-chart .bar:hover > span { background: var(--accent-black); }
.trend-chart .bar.peak > span { background: var(--accent-black); }
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }
.trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-faint); font-size: 12px; }
.trend-foot .item { display: flex; align-items: baseline; gap: 6px; }
.trend-foot .item .k { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
.trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); }
.trend-foot .item .v.warn { color: #B45309; }
.stage-pane .usage-line { display: flex; justify-content: space-between; padding: 4px 0 4px; font-size: 12.5px; }
.stage-pane .usage-line .k { color: var(--accent-black); }
.stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }
.stage-pane .usage-bar { height: 4px; background: var(--background-lighter); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; }
.stage-pane .usage-bar > span { display: block; height: 100%; transition: width .3s ease; }
.stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--border-faint); font-size: 13px; font-weight: 600; }
.stage-pane .total .v { font-variant-numeric: tabular-nums; }
.rule-pane .rule-list { font-size: 12.5px; color: var(--black-alpha-56); line-height: 1.7; }
.rule-pane .rule-list strong { color: var(--accent-black); font-weight: 600; }
.rule-pane .mono-acc { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); }
.quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-faint); }
.quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; }
.quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 12.5px; color: var(--accent-black); }
.quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; }
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
.billing-table th, .billing-table td { padding: 11px 14px; text-align: left; font-size: 12.5px; border-bottom: 0; }
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.billing-table tbody tr:hover { background: var(--background-lighter); }
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
.billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }
.billing-table .muted { color: var(--black-alpha-56); font-size: 11.5px; }
.billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }
.billing-table .who { display: inline-flex; align-items: center; gap: 8px; }
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }
.billing-table .role-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: var(--r-pill); font-size: 10.5px; font-weight: 500; }
.billing-table .role-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
.billing-table .role-super { background: var(--heat-12); color: var(--heat); }
.billing-table .role-super .dot { background: var(--heat); }
.billing-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
.billing-table .role-admin .dot { background: #1E40AF; }
.billing-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
.billing-table .role-member .dot { background: var(--black-alpha-56); }
.billing-table .status-tag { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: var(--r-sm); letter-spacing: .04em; }
.billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }
.billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
.billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 12.5px; font-family: inherit; color: var(--accent-black); }
.filter-bar select { padding-right: 24px; }
.filter-bar .spacer { flex: 1; }
.filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
}

View File

@ -1,63 +1,120 @@
import { Check, Search } from "lucide-react";
import type { Product, User } from "../types";
import type { NavItem, Notice, Page } from "../routes/route-config";
import { mainNav, parentPage, supplementNav } from "../routes/route-config";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
type Navigate = (page: Page) => void;
export function Sidebar({ page, navigate, user, products }: { page: Page; navigate: Navigate; user: User; products: Product[] }) {
const activePage = parentPage(page);
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
const NAV: NavDef[] = [
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
{ id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "library", page: "library", label: "资产库", icon: "library" },
{ id: "team", page: "team", label: "团队", icon: "users" },
{ id: "account", page: "account", label: "消费", icon: "creditCard" },
{ id: "settings", page: "settings", label: "设置", icon: "settings" }
];
const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
dashboard: "dashboard",
products: "products",
productDetail: "products",
productCreateUpload: "products",
projects: "projects",
projectWizard: "projects",
pipeline: "projects",
assetFactory: "assetFactory",
imageOptimize: "assetFactory",
modelPhoto: "assetFactory",
modelPhotoDemoA: "assetFactory",
modelPhotoDemoB: "assetFactory",
platformCover: "assetFactory",
library: "library",
team: "team",
account: "account",
settings: "settings",
settingsNotify: "settings"
};
export function Sidebar({ page, navigate, user, team, products, projects }: {
page: Page;
navigate: Navigate;
user: User;
team: Team | null;
products: Product[];
projects: Project[];
}) {
const activeNav = PAGE_TO_NAV[page];
const badges: Partial<Record<string, number>> = { products: products.length, projects: projects.length };
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
return (
<aside className="sidebar">
<div className="brand">
<div className="flame">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2c1 3 4 5 4 9a4 4 0 0 1-4 4 4 4 0 0 1-4-4 5 5 0 0 1 1.5-3.5C10.5 6 11.5 4 12 2zm-1 13c0 2 1 3 1 5 1-1 3-2 3-5 0-1.5-1-2-2-3-1 1-2 2-2 3z" />
</svg>
</div>
<div><div className="name">·Studio</div></div>
<div className="sidebar-head">
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
</a>
</div>
<div className="search-box">
<Search size={14} />
<input id="global-search" placeholder="搜索" />
<span className="kbd">K</span>
<button className="sidebar-toggle" type="button" aria-label="收窄导航" title="收窄导航">
<span className="sidebar-toggle-icon sidebar-toggle-icon--collapse"><IconKitSvg name="chevronLeft" size={18} strokeWidth={1.8} /></span>
<span className="sidebar-toggle-icon sidebar-toggle-icon--expand"><IconKitSvg name="chevronRight" size={18} strokeWidth={1.8} /></span>
</button>
<div className="search-box" title="搜索">
<IconKitSvg name="search" />
<input id="global-search" placeholder="搜索" readOnly aria-label="打开全局搜索" />
<span className="kbd">Ctrl K</span>
</div>
<div className="nav-section"></div>
<nav>
{mainNav.map((item) => (
<NavButton key={item.page} item={{ ...item, badge: item.page === "products" ? String(products.length) : item.badge }} active={activePage === item.page} navigate={navigate} />
{NAV.map((item) => (
<a
key={item.id}
href={`/${item.id}`}
className={activeNav === item.page ? "active" : ""}
title={item.label}
aria-label={item.label}
onClick={(event) => { event.preventDefault(); navigate(item.page); }}
>
<IconKitSvg name={item.icon} />
<span>{item.label}</span>
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
</a>
))}
</nav>
<div className="nav-section"></div>
<nav>
{supplementNav.map((item) => <NavButton key={item.page} item={item} active={activePage === item.page} navigate={navigate} />)}
</nav>
<div className="aside-foot">
<button className="user" type="button">
<div className="av">{user.username.slice(0, 1).toUpperCase()}</div>
<div className="em">{user.username}</div>
</button>
<div className="user">
<div className="av">{avatar}</div>
<div className="em">{team?.name || user.username}</div>
</div>
</div>
</aside>
);
}
export function NavButton({ item, active, navigate }: { item: NavItem; active: boolean; navigate: Navigate }) {
const Icon = item.icon;
export function Decorations() {
// 与设计稿 shell.js 一致:grid-bg 底纹 + 4 个 sq-mark(写死坐标,不压内容)
return (
<button className={active ? "active" : ""} type="button" onClick={() => navigate(item.page)}>
<Icon size={14} />
<span>{item.label}</span>
{item.badge && <span className="pill-mini">{item.badge}</span>}
</button>
<>
<div className="grid-bg" />
<span className="sq-mark" style={{ top: 238, left: 478 }} />
<span className="sq-mark" style={{ top: 478, left: 1198 }} />
<span className="sq-mark" style={{ bottom: 300, left: 238 }} />
<span className="sq-mark" style={{ top: 718, right: 240 }} />
</>
);
}
export function Decorations() {
// Exact 设计稿只用 grid-bg 做底纹;旧版散点/角标是绝对定位写死坐标,会压在
// topbar 和 page-head 上(重叠 bug),且不在设计稿里。品牌 mono 签名由各页面 inline
// 的 // 注释、[ ALL ] 等承载,这里只保留底纹。
return <div className="grid-bg" />;
export function CornerMarks() {
return (
<>
<CornerMark pos="tl" />
<CornerMark pos="tr" />
<CornerMark pos="bl" />
<CornerMark pos="br" />
</>
);
}
export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {

View File

@ -0,0 +1,13 @@
/* 资产库页 · public/exact/library.html 内联 <style> 移植资产网格部分,scope .library-page
tabs/chip/toolbar/search-inline/result-meta/empty-filter 走全局 design-restraint */
.library-page {
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.asset-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; position: relative; }
.asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
.asset-body { padding: 12px 14px; }
.asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
}

View File

@ -3,5 +3,12 @@ import { App } from "./App";
import "./styles.css";
import "./design-restraint.css";
import "./exact-pages.css";
import "./account-page.css";
import "./product-detail-page.css";
import "./team-page.css";
import "./pipeline-page.css";
import "./projects-page.css";
import "./products-page.css";
import "./library-page.css";
createRoot(document.getElementById("root")!).render(<App />);

View File

@ -0,0 +1,398 @@
/* 生产管线页 · public/exact/pipeline.html 内联 <style> 忠实移植
Phase 1:chrome(自带顶栏+5阶段步进器)+ 视口锁定 + Stage 1(脚本)
Stage 2-5 / modal 待后续补整段 scope .pipeline-page(= .app.pipeline-page) */
.pipeline-page {
/* ─── 视口锁定 · 只让主内容区滚动(sidebar + topbar 固定)─── */
height: 100vh; max-height: 100vh; overflow: hidden;
& > .sidebar { height: 100vh; overflow-y: auto; }
& > main { height: 100vh; max-height: 100vh; overflow: hidden; display: flex; flex-direction: column; min-width: 0; }
& > main > .topbar { flex-shrink: 0; }
& > main > .content { flex: 1 1 0; min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; }
/* ─── 顶栏:返回 + 标题 + 中部 stage-pill ─── */
.topbar { position: relative; }
.pipeline-topbar-left {
display: inline-flex; align-items: center; gap: 12px;
min-width: 0; max-width: min(36vw, 520px);
}
.pipeline-back { height: 34px; padding: 0 13px 0 11px; border-radius: var(--r-pill); flex: 0 0 auto; }
.pipeline-back svg { width: 14px; height: 14px; }
.pipeline-topbar-title {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13.5px; font-weight: 500; color: var(--accent-black);
}
.pipeline-topbar-title .mono { margin-left: 8px; font-size: 10.5px; font-weight: 400; letter-spacing: .04em; color: var(--black-alpha-48); }
@media (max-width: 1500px) { .pipeline-topbar-title { display: none; } }
.stage-pill {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
display: inline-flex; align-items: center; gap: 0;
padding: 6px 16px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
z-index: 3;
}
.stage-pill .sp-dot {
position: relative;
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 8px;
text-decoration: none; cursor: pointer;
border-radius: var(--r-sm);
transition: background var(--t-base);
}
.stage-pill .sp-dot:hover { background: var(--background-lighter); }
.stage-pill .sp-dot .d {
width: 10px; height: 10px; border-radius: 50%;
background: var(--black-alpha-8);
border: 1.5px solid transparent;
transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base);
}
.stage-pill .sp-dot .l {
font-size: 12px; color: var(--black-alpha-56);
font-weight: 500; letter-spacing: .01em;
white-space: nowrap; transition: color var(--t-base);
}
.stage-pill .sp-dot:hover .l { color: var(--accent-black); }
.stage-pill .sp-dot.done .d { background: var(--accent-forest); border-color: var(--accent-forest); }
.stage-pill .sp-dot.done .l { color: var(--accent-black); }
.stage-pill .sp-dot.active .d {
background: var(--heat); border-color: var(--heat);
box-shadow: 0 0 0 3px var(--heat-12);
animation: prog-pulse 1.4s ease-in-out infinite;
}
.stage-pill .sp-dot.active .l { color: var(--heat); font-weight: 600; }
.stage-pill .sp-dot.fail .d { background: var(--accent-crimson); border-color: var(--accent-crimson); }
.stage-pill .sp-dot.fail .l { color: var(--accent-crimson); }
.stage-pill .sp-line { width: 14px; height: 1.5px; background: var(--black-alpha-8); transition: background var(--t-base); }
.stage-pill .sp-line.done { background: var(--accent-forest); }
/* ─── Stage panes ─── */
.stage { display: none; }
.stage.active { display: block; }
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.pane-h { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-bottom: 1px solid var(--border-faint); }
.pane-h strong { font-size: 14px; font-weight: 600; }
/* Stage foot */
.stage-foot { display: flex; justify-content: space-between; align-items: center; padding: 18px 0 0; margin-top: 18px; border-top: 1px solid var(--border-faint); }
.stage-foot .info { font-size: 12.5px; color: var(--black-alpha-56); }
.stage-foot .info .mono { font-family: var(--font-mono); color: var(--black-alpha-48); font-size: 11.5px; letter-spacing: .02em; }
.stage-foot .hstack { gap: 10px; align-items: center; }
.stage-foot .btn { height: 40px; min-height: 40px; padding: 0 18px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; font-size: 13.5px; line-height: 1; white-space: nowrap; }
.stage-foot .btn-primary { padding: 0 20px; font-weight: 600; }
.stage-foot .btn svg { width: 14px; height: 14px; flex: 0 0 14px; }
/* ─── 全高度布局 ─── */
.content.content--fh { display: flex; flex-direction: column; overflow: hidden; padding: 24px 28px 20px; }
.content.content--fh-flat { padding: 0; }
.content--fh-flat .stage.active > .stage-script .pane { background: var(--surface); border: 0; border-radius: 0; }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.shot-list > .pane-h,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.shot-list > .shots-body { padding-left: 28px; }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .pane-h,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .chat-body,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .chat-input { padding-left: 28px; padding-right: 28px; }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.shot-list > .pane-h,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .pane-h { border-bottom: 0; }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .pane.chat-pane > .chat-input { border-top: 0; }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .stage-script-gutter::after { background: transparent; }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .stage-script-gutter:hover::after,
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script > .stage-script-gutter.dragging::after { background: var(--heat); }
.content--fh-flat .stage[data-stage-pane="1"].active > .stage-script { gap: 0; grid-template-columns: minmax(0, 1fr) 6px var(--chat-w, 520px); }
.stage-script-gutter { position: relative; background: transparent; cursor: col-resize; transition: background var(--t-base); }
.stage-script-gutter::after { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; width: 1px; transform: translateX(-50%); background: var(--border-faint); transition: background var(--t-base), width var(--t-base); }
.stage-script-gutter:hover::after, .stage-script-gutter.dragging::after { background: var(--heat); width: 2px; }
.stage-script-gutter.dragging { background: var(--heat-12); }
.content--fh-flat .stage.active > .stage-foot { margin-top: 0; padding: 14px 28px; background: var(--surface); border-top: 1px solid var(--border-faint); }
.content--fh .stage.active { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.content--fh .stage[data-stage-pane="1"].active > .stage-script { flex: 1 1 0; min-height: 0; overflow: hidden; }
.content--fh .stage.active > .stage-foot { flex: 0 0 auto; }
.content--fh .stage[data-stage-pane="1"].active > .stage-script > .shot-list,
.content--fh .stage[data-stage-pane="1"].active > .stage-script > .chat-pane { min-height: 0; min-width: 0; }
.content--fh .stage[data-stage-pane="1"].active .shots-body,
.content--fh .stage[data-stage-pane="1"].active .chat-body { max-height: none; flex: 1 1 0; min-height: 0; overflow-y: auto; }
/* === STAGE 1 · 脚本 === */
.stage-script { display: grid; grid-template-columns: 7fr 3fr; gap: 16px; min-height: 560px; }
.chat-pane { display: flex; flex-direction: column; }
.chat-body { padding: 16px 18px; flex: 1; overflow-y: auto; max-height: 460px; display: flex; flex-direction: column; gap: 14px; }
.msg .bubble { max-width: 90%; padding: 10px 14px; font-size: 13px; line-height: 1.6; border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.msg.ai .bubble { background: var(--surface); }
.msg.user { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .bubble { background: var(--heat-12); color: var(--accent-black); border-color: var(--heat-20); }
.msg .time { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 4px; letter-spacing: .02em; }
.msg .actions { display: flex; gap: 6px; margin-top: 6px; }
.ai-avatar { width: 26px; height: 26px; flex-shrink: 0; background: var(--heat); color: var(--accent-white); display: grid; place-items: center; font-size: 11px; font-weight: 700; border: 1px solid var(--heat); border-radius: 50%; }
.del { text-decoration: line-through; color: var(--black-alpha-48); }
.ins { background: var(--forest-bg); color: var(--accent-forest); padding: 0 3px; }
.chat-input { padding: 14px 18px 18px; border-top: 1px solid var(--border-faint); }
.chat-input-card { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px 10px; transition: border-color var(--t-base), box-shadow var(--t-base); }
.chat-input-card:focus-within { border-color: var(--accent-black); box-shadow: 0 0 0 3px rgba(0,0,0,.04); }
.chat-input-area { width: 100%; border: none; outline: none; background: transparent; font-family: var(--font-sans); font-size: 13px; color: var(--accent-black); line-height: 1.55; resize: none; padding: 0; min-height: 42px; }
.chat-input-area::placeholder { color: var(--black-alpha-40); }
.chat-input-foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.chat-input-foot .hint { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-40); letter-spacing: .02em; }
.chat-input-foot .spacer { flex: 1; }
.chat-icon-btn { width: 28px; height: 28px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border-faint); border-radius: 50%; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.chat-icon-btn:hover { border-color: var(--accent-black); color: var(--accent-black); }
.chat-send-btn { width: 32px; height: 32px; display: grid; place-items: center; background: var(--accent-black); border: 1px solid var(--accent-black); border-radius: 50%; color: var(--accent-white); cursor: pointer; transition: background var(--t-base), border-color var(--t-base), transform var(--t-base); }
.chat-send-btn:hover { background: var(--heat); border-color: var(--heat); }
.chat-send-btn:active { transform: scale(.95); }
.chat-send-btn:disabled { background: var(--black-alpha-12); border-color: var(--black-alpha-12); color: var(--black-alpha-40); cursor: not-allowed; transform: none; }
.chat-attach-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.shot-list { display: flex; flex-direction: column; }
.shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 0; }
.shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; }
.shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.script-brief-summary { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.script-brief-pill { gap: 4px; padding: 3px 8px; font-size: 11px; }
.script-brief-pill .k { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); letter-spacing: .04em; }
.script-brief-pill .v { color: var(--accent-black); max-width: 116px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.script-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-left: 6px; }
.script-tags .tag-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.script-tags .tg-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; flex-shrink: 0; }
.script-tags .tag-add { width: 20px; height: 20px; display: grid; place-items: center; background: transparent; border: 1px dashed var(--black-alpha-24); border-radius: 50%; color: var(--black-alpha-48); cursor: pointer; font-size: 13px; line-height: 1; padding: 0; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.script-tags .tag-add:hover { border-style: solid; border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
/* 镜头脚本空缺省态 */
.shots-empty { padding: 36px 24px; margin: auto; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; color: var(--black-alpha-48); }
.shots-empty .empty-ico { width: 56px; height: 56px; border: 1px dashed var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-32); }
.shots-empty .empty-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }
/* 对话空态三胶囊 */
.chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.chat-empty .ce-title { font-size: 13.5px; color: var(--accent-black); font-weight: 500; }
.chat-empty .ce-hint { font-size: 11.5px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; }
.chat-modes { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.chat-mode { height: 30px; padding: 0 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 999px; font-size: 12.5px; color: var(--accent-black); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.chat-mode:hover { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }
.chat-mode.primary { background: var(--heat-12); border-color: var(--heat); color: var(--heat); }
.chat-mode svg { width: 13px; height: 13px; }
/* ============= STAGE 2 · 基础资产 ============= */
.content--fh .stage[data-stage-pane="2"].active > .stage-assets { flex: 1 1 0; min-height: 0; overflow: hidden; }
.content--fh-flat .stage[data-stage-pane="2"].active > .stage-assets { gap: 0; height: 100%; }
.content--fh-flat .stage[data-stage-pane="2"].active > .stage-assets > .asset-side { position: static; align-self: stretch; padding: 18px 16px; background: var(--background-base); overflow-y: auto; }
.content--fh-flat .stage[data-stage-pane="2"].active > .stage-assets > .asset-main { padding: 18px 28px; overflow-y: auto; background: var(--background-base); }
.stage-assets { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 24px; }
.stage-assets > div { min-width: 0; }
.asset-side { position: sticky; top: 16px; align-self: start; }
.asset-sec { min-width: 0; scroll-margin-top: 16px; }
.asset-side .ttab { padding: 10px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: var(--r-md); }
.asset-side .ttab:hover { background: var(--background-lighter); }
.asset-side .ttab.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.asset-side .ttab .num { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); margin-left: auto; }
.asset-side .ttab.active .num { color: var(--heat); }
.asset-side .info { font-size: 12px; color: var(--black-alpha-48); padding: 14px 12px; line-height: 1.6; margin-top: 14px; border-top: 1px solid var(--border-faint); }
.asset-side .info strong { color: var(--black-alpha-56); display: block; }
.asset-side .info .mono { font-family: var(--font-mono); }
.asset-sec + .asset-sec { margin-top: 32px; }
.asset-sec .sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.asset-sec .sec-h h3 { font-size: 15px; font-weight: 600; }
.asset-grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.prod-row { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
.prod-row > .asset-card-2 { flex: 0 0 auto; width: auto; max-width: none; min-width: 0; height: 360px; aspect-ratio: 3 / 5; }
.prod-preview { flex: 0 0 360px; height: 360px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: none; flex-direction: column; gap: 10px; }
.prod-preview.show { display: flex; }
.prod-preview-h { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; }
.prod-preview-img { aspect-ratio: 16/9; }
.prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }
.prod-preview-history { display: none; flex-direction: column; gap: 6px; }
.asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; }
.asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); }
.asset-card-2 .thumb-2 { aspect-ratio: 1; }
.asset-card-2 .body-2 { padding: 12px 14px; }
.asset-card-2 .body-2 .btn:disabled, .asset-card-2 .body-2 .btn.disabled { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; cursor: not-allowed; opacity: .72; transform: none; }
.asset-card-2 .body-2 .btn:disabled:hover, .asset-card-2 .body-2 .btn.disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; transform: none; }
.asset-card-2.prod-lib-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.asset-card-2.prod-lib-card .prod-thumb { flex: 1 1 0; min-height: 0; position: relative; aspect-ratio: auto; }
.asset-card-2.prod-lib-card .prod-body { padding: 14px 14px 12px; flex: 0 0 auto; }
.asset-card-2.prod-lib-card .prod-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.asset-card-2.prod-lib-card .prod-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 11.5px; }
.asset-card-2.prod-lib-card .prod-date { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
.asset-card-2.prod-lib-card .prod-action { padding: 10px 12px; border-top: 1px solid var(--border-faint); background: var(--surface); }
.asset-card-2.prod-lib-card .prod-action[hidden] { display: none; }
.asset-card-2.prod-lib-card .prod-action .btn-aigen {
width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; padding: 0 14px; background: var(--heat); color: var(--accent-white);
border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
box-shadow: inset 0 -2px 4px rgba(250,93,25,.20), 0 1px 1px rgba(250,93,25,.12), 0 2px 4px rgba(250,93,25,.10);
transition: background var(--t-base), box-shadow var(--t-base), transform var(--t-base);
}
.asset-card-2.prod-lib-card .prod-action .btn-aigen:hover { background: #FB6E2E; box-shadow: inset 0 -2px 4px rgba(250,93,25,.24), 0 2px 4px rgba(250,93,25,.20), 0 4px 12px rgba(250,93,25,.18); transform: translateY(-1px); }
.asset-card-2.prod-lib-card .prod-action .btn-aigen .ai-spark { width: 14px; height: 14px; flex-shrink: 0; }
.prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); }
.prompt-box[contenteditable="true"] { cursor: text; outline: none; }
.prompt-box[contenteditable="true"]:hover { border-color: var(--heat-20); }
.prompt-box[contenteditable="true"]:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); }
.fail-icon { width: 28px; height: 28px; background: var(--accent-crimson); color: var(--accent-white); display: grid; place-items: center; font-weight: 700; font-size: 16px; border-radius: 50%; }
/* ============= STAGE 3 · 故事板 ============= */
.content--fh .stage[data-stage-pane="3"].active > .stage-storyboard { flex: 1 1 0; min-height: 0; overflow-y: auto; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard { gap: 0; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas { border: 0; border-radius: 0; background: var(--surface); padding: 18px 14px 18px 28px; align-items: center; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img { width: 100%; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side { display: flex; flex-direction: column; min-height: 0; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side > .pane { flex: 1 1 0; min-height: 0; overflow-y: auto; border: 0; border-radius: 0; background: var(--surface); padding: 18px 28px; }
.content--fh-flat .stage[data-stage-pane="3"].active .sb-scenes-col { max-height: none; }
.stage-storyboard { display: grid; grid-template-columns: minmax(0, 1fr) 380px; gap: 16px; align-items: stretch; }
.sb-canvas { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: grid; grid-template-columns: 108px minmax(0, 1fr); gap: 14px; }
.sb-scenes-col { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; overflow-x: hidden; max-height: 560px; padding-right: 6px; scrollbar-width: thin; }
.sb-scenes-col::-webkit-scrollbar { width: 6px; }
.sb-scenes-col::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 4px; }
.sb-scene-thumb { flex: 0 0 auto; cursor: pointer; display: flex; flex-direction: column; gap: 6px; padding: 6px; border: 1px solid var(--border-faint); border-radius: var(--r-md); background: var(--surface); transition: border-color var(--t-base), background var(--t-base); }
.sb-scene-thumb:hover { background: var(--background-lighter); }
.sb-scene-thumb.selected { border-color: var(--heat); background: var(--heat-12); }
.sb-scene-thumb .placeholder { aspect-ratio: 1; }
.sb-scene-thumb .nm { font-size: 11.5px; font-weight: 500; color: var(--accent-black); }
.sb-scene-thumb .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }
.sb-main-img { aspect-ratio: 16/9; min-height: 0; }
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
.sb-rerun-note .warn-ic svg { width: 14px; height: 14px; }
.sb-rerun-note .note-copy { min-width: 0; font-size: 11.5px; }
.sb-rerun-note strong { color: #B45309; }
.sb-rerun-note a { color: #B45309; text-decoration: underline; text-underline-offset: 2px; }
.sb-stage-actions { display: flex; gap: 8px; margin-top: 14px; margin-bottom: 12px; }
.sb-history { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-faint); }
.sb-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
.sb-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.sb-history-thumb { flex: 0 0 80px; min-width: 80px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); }
.sb-history-thumb:hover { border-color: var(--heat); }
.sb-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
.sb-history-thumb .placeholder { aspect-ratio: 1; }
.sb-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }
.sb-history-thumb.current .ts { color: var(--heat); font-weight: 600; }
.pill-cta { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 14px; border-radius: 999px; font-size: 12.5px; cursor: pointer; font-family: inherit; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.pill-cta.heat { background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); }
.pill-cta.heat:hover { box-shadow: var(--shadow-cta-hover); }
.pill-cta.ghost { background: var(--surface); color: var(--accent-black); border: 1px solid var(--border-faint); }
.pill-cta.ghost:hover { background: var(--background-lighter); border-color: var(--heat-20); color: var(--heat); }
.pill-cta svg { width: 13px; height: 13px; }
.sb-side .pane { padding: 18px; }
.prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 200px; outline: none; letter-spacing: .01em; cursor: text; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
.prompt-edit:hover { border-color: var(--heat-20); }
.prompt-edit:focus { border-color: var(--heat); background: var(--surface); box-shadow: 0 0 0 3px var(--heat-12); }
.asset-tag { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); font-size: 11.5px; }
.asset-tag .dotc { width: 14px; height: 14px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: 50%; }
/* ============= STAGE 4 · 视频 ============= */
.content--fh .stage[data-stage-pane="4"].active > .video-grid { flex: 1 1 0; min-height: 0; overflow-y: auto; }
.content--fh-flat .stage[data-stage-pane="4"].active > .queue-bar { border: 0; border-radius: 0; border-bottom: 1px solid var(--border-faint); margin: 0; padding: 14px 28px; }
.content--fh-flat .stage[data-stage-pane="4"].active > .video-grid { padding: 18px 28px; background: var(--background-base); }
.queue-bar { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); margin-bottom: 18px; }
.queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--background-lighter); overflow: hidden; }
.queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--heat); }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(196px, 216px)); gap: 14px; align-content: start; align-items: start; justify-content: start; }
.video-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), background var(--t-base); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.video-card:hover { border-color: var(--heat-40); background: var(--background-lighter); }
.video-thumb { width: 100%; aspect-ratio: 9/16; position: relative; border-radius: var(--r-md) var(--r-md) 0 0; overflow: hidden; }
.video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }
.video-thumb:hover .play { opacity: 1; }
.video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: var(--accent-white); border-radius: 50%; display: grid; place-items: center; }
.video-card .body { padding: 12px 12px 14px; flex: 1 1 auto; min-height: 118px; display: flex; flex-direction: column; }
.video-card-head { display: flex; align-items: flex-start; gap: 8px; }
.video-card-title { min-width: 0; flex: 1 1 auto; font-size: 13px; line-height: 1.4; font-weight: 600; color: var(--accent-black); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.video-card-head .pill { flex: 0 0 auto; }
.video-meta { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 5px; }
.video-actions { margin-top: auto; padding-top: 12px; display: flex; align-items: center; gap: 10px; }
/* ============= STAGE 5 · 拼接编辑器 ============= */
.content--fh .stage[data-stage-pane="5"].active > .editor { flex: 1 1 0; min-height: 0; overflow-y: auto; height: auto; }
.content--fh-flat .stage[data-stage-pane="5"].active > .editor { border: 0; border-radius: 0; }
.content--fh-flat .stage[data-stage-pane="5"].active > .editor > .editor-preview { padding-left: 28px; }
.content--fh-flat .stage[data-stage-pane="5"].active > .editor > .editor-props { padding-right: 28px; }
.content--fh-flat .stage[data-stage-pane="5"].active > .editor > .timeline { padding-left: 28px; padding-right: 28px; }
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.editor-preview { padding: 16px; border-right: 1px solid var(--border-faint); border-bottom: 1px solid var(--border-faint); display: flex; flex-direction: column; gap: 12px; }
.editor-preview .canvas { flex: 1 1 0; min-height: 0; aspect-ratio: 9/16; margin: 0 auto; background: repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px), var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; }
.editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }
.ctl-btn { width: 36px; height: 36px; border: 1px solid var(--border-faint); background: var(--surface); color: var(--black-alpha-56); border-radius: var(--r-md); display: grid; place-items: center; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.ctl-btn:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }
.editor-props { padding: 16px; border-bottom: 1px solid var(--border-faint); overflow-y: auto; }
.props-tabs { display: flex; gap: 0; margin-bottom: 14px; border-bottom: 1px solid var(--border-faint); }
.props-tabs > div { padding: 8px 12px; font-size: 12.5px; color: var(--black-alpha-56); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.props-tabs > div.active { color: var(--heat); border-bottom-color: var(--heat); font-weight: 600; }
.style-swatch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.swatch-card { padding: 10px; border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; }
.swatch-card:hover { background: var(--background-lighter); }
.swatch-card.selected { border-color: var(--heat); background: var(--heat-12); }
.swatch-card .demo { font-size: 12px; padding: 6px 8px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); margin-bottom: 4px; text-align: center; }
.swatch-card .demo.b { background: var(--accent-black); color: var(--accent-white); font-family: serif; }
.swatch-card .demo.c { color: var(--heat); -webkit-text-stroke: 0.5px var(--accent-black); }
.swatch-card .demo.d { background: var(--accent-honey, #f5c451); color: var(--accent-black); font-weight: 700; }
.swatch-card .nm { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }
.props-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-faint); font-size: 12.5px; }
.props-row:last-child { border-bottom: 0; }
.props-row .k { color: var(--black-alpha-48); flex: 1; font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em; }
.input-mini { width: 90px; padding: 0 10px; height: 28px; font-size: 12px; border-radius: var(--r-md); background: var(--surface); border: 1px solid var(--black-alpha-12); }
.timeline { position: relative; grid-column: 1 / -1; padding: 14px 16px; background: var(--background-base); }
.tl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-faint); }
.tl-toolbar .tl-action { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 10px; background: transparent; border: 1px solid transparent; border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.tl-toolbar .tl-action:hover { background: var(--surface); border-color: var(--border-faint); color: var(--accent-black); }
.tl-toolbar .tl-action.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
.tl-toolbar .tl-action svg { width: 13px; height: 13px; }
.tl-toolbar .tl-sep { width: 1px; height: 16px; background: var(--border-faint); margin: 0 4px; }
.tl-toolbar .tl-zoom { display: inline-flex; align-items: center; gap: 8px; }
.tl-toolbar .tl-zoom .lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; }
.tl-toolbar .tl-zoom input[type="range"] { width: 120px; accent-color: var(--heat); }
.tl-ruler { display: grid; grid-template-columns: 80px 1fr; align-items: end; padding: 0; margin-bottom: 4px; }
.tl-ruler .l { font-family: var(--font-mono); color: var(--black-alpha-48); padding: 0 4px 4px; font-size: 10.5px; letter-spacing: .04em; align-self: end; }
.tl-ruler .rule-track { position: relative; height: 22px; border-bottom: 1px solid var(--border-faint); cursor: pointer; }
.tl-ruler .rule-track .tick { position: absolute; bottom: 0; width: 1px; background: var(--black-alpha-24); }
.tl-ruler .rule-track .tick.major { height: 8px; background: var(--black-alpha-48); }
.tl-ruler .rule-track .tick.minor { height: 4px; }
.tl-ruler .rule-track .t { position: absolute; bottom: 10px; transform: translateX(-50%); font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; white-space: nowrap; }
.tl-track { display: grid; grid-template-columns: 80px 1fr; align-items: center; padding: 3px 0; }
.tl-track .label { display: flex; align-items: center; gap: 6px; padding-left: 4px; font-size: 11.5px; color: var(--black-alpha-72); font-weight: 500; }
.tl-track .label .ico { width: 18px; height: 18px; display: grid; place-items: center; border-radius: var(--r-sm); flex-shrink: 0; }
.tl-track .label .ico svg { width: 12px; height: 12px; }
.tl-track .label.video .ico { background: var(--heat-12); color: var(--heat); }
.tl-track .label.subtitle .ico { background: var(--forest-bg); color: var(--accent-forest); }
.tl-track .label.bgm .ico { background: rgba(144, 97, 255, .10); color: var(--accent-amethyst, #9061ff); }
.tl-track .lane { position: relative; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); cursor: pointer; }
.tl-track.video-track .lane { height: 46px; }
.tl-track.subtitle-track .lane { height: 28px; }
.tl-track.bgm-track .lane { height: 34px; }
.tl-track .lane::before { content: ""; position: absolute; inset: 0; background-image: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 1px, transparent 1px, transparent calc(100% / 15)); pointer-events: none; opacity: .55; border-radius: inherit; }
.clip { position: absolute; top: 3px; bottom: 3px; display: flex; align-items: center; gap: 6px; padding: 0 8px; font-size: 11px; border: 1px solid transparent; border-radius: 4px; cursor: grab; overflow: hidden; white-space: nowrap; user-select: none; box-sizing: border-box; }
.clip:hover { filter: brightness(1.04); }
.clip .num { font-family: var(--font-mono); font-weight: 700; opacity: .85; flex-shrink: 0; }
.clip .lbl { overflow: hidden; text-overflow: ellipsis; }
.clip.video { background: var(--heat-12); border-color: var(--heat-40); color: var(--heat); }
.clip.video .frames { position: absolute; top: 0; bottom: 0; left: 0; width: var(--src-width, 100%); transform: translateX(var(--src-offset, 0%)); display: flex; gap: 0; pointer-events: none; z-index: 0; border-radius: inherit; overflow: hidden; }
.clip.video .frames .fr { flex: 1; min-width: 0; background: repeating-linear-gradient(45deg, transparent 0, transparent 4px, rgba(38,38,38,.06) 4px, rgba(38,38,38,.06) 5px), rgba(250,93,25,.10); }
.clip.video .frames .fr + .fr { border-left: 1px solid rgba(255,255,255,.55); }
.clip.video .num, .clip.video .lbl { position: relative; z-index: 1; }
.clip.subtitle { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); font-size: 11px; }
.clip.subtitle .lbl::before { content: "\201C"; font-family: serif; font-size: 14px; opacity: .55; margin-right: 2px; }
.clip.subtitle:hover { background: rgba(31, 138, 81, .14); }
.clip.bgm { background: rgba(144,97,255,.10); border-color: rgba(144,97,255,.30); color: var(--accent-amethyst, #9061ff); }
.clip.bgm .wave { position: absolute; inset: 6px 10px; pointer-events: none; opacity: .5; display: block; z-index: 0; }
.clip.bgm .wave svg { width: 100%; height: 100%; display: block; }
.clip.bgm .lbl, .clip.bgm .num { position: relative; z-index: 1; }
.playhead { position: absolute; top: -90px; bottom: -44px; width: 18px; transform: translateX(-50%); background: transparent; z-index: 10; pointer-events: auto; cursor: ew-resize; touch-action: none; }
.playhead::after { content: ''; position: absolute; top: 0; bottom: 0; left: 50%; transform: translateX(-50%); width: 1.5px; background: var(--heat); pointer-events: none; }
.playhead::before { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%) rotate(45deg); width: 10px; height: 10px; background: var(--heat); box-shadow: 0 0 0 1.5px var(--surface); border-radius: 1px; pointer-events: none; }
.playhead .ph-grab { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 24px; height: 24px; cursor: ew-resize; pointer-events: auto; border-radius: 50%; }
}

View File

@ -0,0 +1,911 @@
/* 商品详情页 · 从 public/exact/product-detail.html 内联 <style> 忠实移植,整段 scope 进 .product-detail-page 防冲突 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .3; }
}
.product-detail-page {
/* ─── 顶部 标题 + 状态 ─── */
.pd-title {
display: flex; align-items: center; gap: 12px;
margin-bottom: 22px;
}
.pd-title h1 {
font-size: 24px; font-weight: 600;
letter-spacing: -.015em;
color: var(--accent-black);
line-height: 1.25;
}
.pd-title .status {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px;
background: var(--accent-emerald-bg, #e6f4ec);
color: var(--accent-emerald, #1f8a51);
border: 1px solid var(--accent-emerald-bd, #c4e3d1);
border-radius: var(--r-sm);
font-size: 11.5px;
font-weight: 500;
}
/* ─── 商品信息(含图片) + 快速操作(辅助) · 3 : 2 两栏 · 高度对齐 ─── */
.pd-overview {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
margin-bottom: 24px;
align-items: stretch;
}
.pd-overview .ov-card { height: 100%; box-sizing: border-box; }
.pd-overview .ov-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 20px 22px;
min-width: 0;
position: relative;
}
/* 编辑按钮 · 放在 .ov-h 标题行右侧 (flex item, 不再 absolute) */
.pd-overview .ov-h { align-items: center; }
.ov-edit {
display: inline-flex; align-items: center; gap: 5px;
height: 28px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 12px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.ov-edit-single { margin-left: auto; }
.ov-edit:hover {
border-color: var(--heat-40);
color: var(--heat);
background: var(--heat-12);
}
.ov-edit svg { width: 12px; height: 12px; }
.ov-edit.primary {
background: var(--heat);
color: var(--accent-white);
border-color: var(--heat);
white-space: nowrap;
}
.ov-edit.primary:hover { filter: brightness(1.05); background: var(--heat); color: var(--accent-white); }
.ov-edit:disabled { cursor: not-allowed; color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); opacity: 1; }
.ov-edit:disabled:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }
.ov-edit:disabled svg { color: var(--heat); }
/* 编辑模式按钮组 (重置 + 取消 + 保存) */
.ov-edit-group {
display: none;
align-items: center;
gap: 6px;
margin-left: auto;
}
.ov-card.editing .ov-edit-single { display: none; }
.ov-card.editing .ov-edit-group { display: inline-flex; }
/* AI 生成三视图 · 按钮 + 弹出 panel(布局复刻 pipeline.html stage 2 三视图预览) */
.ov-tri-wrap { position: relative; margin-left: auto; }
/* 当 AI 入口存在时,编辑信息按钮不再独占 ml-auto,与 AI 按钮紧贴 */
.ov-tri-wrap + .ov-edit-single { margin-left: 0; }
.ov-tri-trigger { white-space: nowrap; }
.ov-tri-trigger.is-open { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.ov-card.editing .ov-tri-wrap { display: none; }
.ov-tri-pop {
position: absolute;
top: calc(100% + 6px); right: 0;
width: 360px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: 0 8px 24px rgba(0,0,0,.10), 0 2px 6px rgba(0,0,0,.06);
padding: 14px 14px 12px;
display: none;
flex-direction: column;
gap: 10px;
z-index: 40;
}
.ov-tri-pop.show { display: flex; }
.ov-tri-pop::before {
content: ''; position: absolute;
top: -5px; right: 36px;
width: 9px; height: 9px;
background: var(--surface);
border-left: 1px solid var(--border-faint);
border-top: 1px solid var(--border-faint);
transform: rotate(45deg);
}
.ov-tri-close {
position: absolute;
top: 8px; right: 8px;
width: 22px; height: 22px;
display: grid; place-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
color: var(--black-alpha-56);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
z-index: 2;
}
.ov-tri-close:hover { background: var(--black-alpha-08); color: var(--accent-black); border-color: var(--border-faint); }
.ov-tri-close svg { width: 12px; height: 12px; }
/* 复刻 pipeline.html .prod-preview-* 内部样式 */
.ov-tri-pop .prod-preview-h { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-56); letter-spacing: .04em; text-transform: uppercase; padding-right: 28px; }
.ov-tri-pop .prod-preview-img { aspect-ratio: 16/9; }
.ov-tri-pop .prod-preview-foot { display: flex; align-items: center; gap: 8px; min-height: 30px; }
.ov-tri-pop .prod-preview-history { display: none; flex-direction: column; gap: 6px; }
.ov-tri-pop .prod-preview-history.show { display: flex; }
.ov-tri-pop .prod-preview-history .h-lbl { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.ov-tri-pop .prod-preview-history .h-lbl .ct { color: var(--accent-black); font-weight: 600; }
.ov-tri-pop .prod-preview-history .h-row { display: flex; gap: 6px; overflow-x: auto; padding: 2px; scrollbar-width: thin; }
.ov-tri-pop .prod-preview-history .h-row::-webkit-scrollbar { height: 4px; }
.ov-tri-pop .prod-preview-history .h-row::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }
.ov-tri-pop .prod-preview-history .h-thumb {
flex: 0 0 auto;
width: 72px; aspect-ratio: 16/9;
background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm);
position: relative; cursor: pointer; transition: border-color var(--t-base);
display: grid; place-items: center; overflow: hidden;
}
.ov-tri-pop .prod-preview-history .h-thumb:hover { border-color: var(--heat-40); }
/* 已采用版本:主橙描边 + 「已采用」徽标 */
.ov-tri-pop .prod-preview-history .h-thumb.adopted { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
/* 仅预览(未采用):黑色描边,无徽标 */
.ov-tri-pop .prod-preview-history .h-thumb.previewing { border-color: var(--accent-black); border-width: 2px; }
.ov-tri-pop .prod-preview-history .h-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.ov-tri-pop .prod-preview-history .h-thumb.adopted .v { color: var(--heat); font-weight: 600; }
.ov-tri-pop .prod-preview-history .h-thumb.previewing .v { color: var(--accent-black); font-weight: 600; }
.ov-tri-pop .prod-preview-history .h-thumb .badge { position: absolute; top: 2px; left: 2px; font-family: var(--font-mono); font-size: 8.5px; padding: 0 4px; line-height: 12px; background: var(--heat); color: var(--accent-white); border-radius: 2px; letter-spacing: .02em; display: none; }
.ov-tri-pop .prod-preview-history .h-thumb.adopted .badge { display: block; }
/* 主图可点击放大 */
.ov-tri-pop .prod-preview-img.is-zoomable { cursor: zoom-in; transition: border-color var(--t-base); position: relative; }
.ov-tri-pop .prod-preview-img.is-zoomable:hover { border-color: var(--heat-40); }
.ov-tri-pop .prod-preview-img.is-zoomable::after {
content: '';
position: absolute; top: 8px; right: 8px;
width: 22px; height: 22px;
background: rgba(21,20,15,.72) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='M21 21l-4.3-4.3M8 11h6M11 8v6'/></svg>") center/14px no-repeat;
border-radius: var(--r-sm);
opacity: 0; transition: opacity var(--t-base);
pointer-events: none;
}
.ov-tri-pop .prod-preview-img.is-zoomable:hover::after { opacity: 1; }
/* 三视图放大查看 lightbox */
#ov-tri-lightbox-bg { z-index: 80; }
#ov-tri-lightbox-bg .tri-lightbox {
position: relative;
width: min(1100px, 92vw);
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 18px 20px 20px;
display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 24px 64px rgba(0,0,0,.24);
}
.tri-lightbox-head {
display: flex; align-items: center; gap: 8px;
font-family: var(--font-mono);
font-size: 11px; letter-spacing: .04em; text-transform: uppercase;
color: var(--black-alpha-56);
padding-right: 32px;
}
.tri-lightbox-head .lb-ver { color: var(--heat); font-weight: 600; }
.tri-lightbox-head .lb-tag {
margin-left: 6px;
padding: 2px 6px;
background: var(--heat-12); color: var(--heat);
border-radius: 3px;
font-size: 10px;
}
.tri-lightbox-close {
position: absolute;
top: 12px; right: 12px;
width: 28px; height: 28px;
display: grid; place-items: center;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-56);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
z-index: 2;
}
.tri-lightbox-close:hover { background: var(--black-alpha-08); color: var(--accent-black); border-color: var(--black-alpha-12); }
.tri-lightbox-close svg { width: 14px; height: 14px; }
.tri-lightbox-img { aspect-ratio: 16/9; width: 100%; }
.tri-lightbox-foot { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); }
.tri-lightbox-foot .spc { flex: 1; }
.tri-lightbox-foot kbd {
display: inline-block;
padding: 1px 5px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-bottom-width: 2px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--black-alpha-72);
}
/* 字段 view ↔ edit 状态切换 */
.v-edit { display: none; }
.ov-card.editing .v-static { display: none; }
.ov-card.editing .v-edit { display: block; }
/* 输入控件 · 对齐新建表单 V2.1 规范 */
.v-input,
.v-select {
width: 100%;
max-width: 100%;
height: 38px;
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
padding: 0 14px;
font-size: 13.5px;
color: var(--accent-black);
background: var(--background-lighter);
font-family: inherit;
outline: none;
transition: border-color var(--t-base), box-shadow var(--t-base);
}
.v-input:focus,
.v-select:focus {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
/* 编辑模式下 · 核心卖点 bullet-list (与新建表单完全一致) */
.v-bullet-list {
list-style: none;
padding: 0; margin: 0;
}
.v-bullet-list .bl-item,
.v-bullet-list .bl-add {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 13.5px;
}
.v-bullet-list .bl-add { background: transparent; border-style: dashed; }
.v-bullet-list .bl-add:focus-within { border-color: var(--heat-40); background: var(--surface); }
.v-bullet-list .num {
width: 22px; height: 22px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 11px;
color: var(--heat);
font-weight: 700;
display: grid; place-items: center;
flex-shrink: 0;
}
.v-bullet-list .bl-add .num {
background: transparent;
color: var(--heat);
border-color: var(--heat-40);
}
.v-bullet-list .bl-text { flex: 1; color: var(--accent-black); }
.v-bullet-list .bl-input {
flex: 1;
background: transparent; border: 0; outline: none;
font-size: 13.5px;
color: var(--accent-black);
font-family: inherit;
}
.v-bullet-list .bl-input::placeholder { color: var(--black-alpha-48); }
.v-bullet-list .bl-x {
width: 22px; height: 22px;
color: var(--black-alpha-48);
cursor: pointer;
display: grid; place-items: center;
border-radius: var(--r-sm);
transition: color var(--t-base), background var(--t-base);
}
.v-bullet-list .bl-x:hover { color: var(--accent-crimson, #c43d3d); background: var(--crimson-bg, #fdebea); }
.v-bullet-list .bl-x svg { width: 11px; height: 11px; }
/* 编辑模式下,商品图片显示一个 [+ 上传] 占位 */
.img-upload {
display: none;
aspect-ratio: 1;
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-sm);
cursor: pointer;
place-items: center;
color: var(--black-alpha-48);
background: var(--background-lighter);
transition: border-color var(--t-base), color var(--t-base);
}
.img-upload:hover { border-color: var(--heat); color: var(--heat); }
.img-upload svg { width: 18px; height: 18px; }
.ov-card.editing .img-upload { display: grid; }
.ov-card.editing .ov-images-sub .thumb { cursor: pointer; }
.ov-card.editing .ov-images-sub .thumb::after {
content: '×';
position: absolute;
top: 4px; right: 4px;
width: 18px; height: 18px;
background: rgba(0,0,0,.7);
color: var(--accent-white);
border-radius: 50%;
display: grid; place-items: center;
font-size: 13px;
line-height: 1;
}
.ov-images-sub .thumb { position: relative; }
.pd-overview .ov-h {
display: flex; align-items: baseline; gap: 8px;
margin-bottom: 14px;
}
.pd-overview .ov-h .ti {
font-size: 14px; font-weight: 600;
color: var(--accent-black);
}
.pd-overview .ov-h .ct {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
.pd-overview .ov-h .more {
margin-left: auto;
font-size: 12px;
color: var(--heat);
cursor: pointer;
}
.pd-overview .ov-h .more:hover { text-decoration: underline; }
/* 商品信息卡片内 · 上信息 / 下图片 (堆叠, 图片铺满卡片) */
.ov-main-grid {
display: flex;
flex-direction: column;
gap: 18px;
}
.ov-main-grid > .ov-images-sub {
padding-top: 18px;
border-top: 1px solid var(--border-faint);
}
.ov-info .row {
display: flex; gap: 12px;
margin-bottom: 10px;
font-size: 13px;
}
.ov-info .row:last-child { margin-bottom: 0; }
.ov-info .k {
width: 64px;
flex-shrink: 0;
color: var(--black-alpha-48);
font-size: 12.5px;
}
.ov-info .v {
flex: 1; min-width: 0;
color: var(--accent-black);
line-height: 1.6;
}
.ov-info .v .bullet { display: block; }
.ov-info .v .bullet::before {
content: '·';
color: var(--heat);
margin-right: 6px;
font-weight: 700;
}
/* 商品图片 · 卡片内子 section */
.ov-images-sub .sub-h {
display: flex; align-items: baseline; gap: 6px;
margin-bottom: 10px;
}
.ov-images-sub .sub-h .ti {
font-size: 12.5px; font-weight: 500;
color: var(--black-alpha-72);
}
.ov-images-sub .sub-h .ct {
font-family: var(--font-mono);
font-size: 11px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
.ov-images-sub .sub-h .more {
margin-left: auto;
font-size: 11.5px;
color: var(--heat);
cursor: pointer;
}
.ov-images-sub .sub-h .more:hover { text-decoration: underline; }
.ov-images-sub .grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
}
.ov-images-sub .thumb {
aspect-ratio: 1;
border-radius: var(--r-sm);
overflow: hidden;
cursor: pointer;
}
.ov-images-sub .thumb img { width: 100%; height: 100%; object-fit: cover; }
/* 快速操作 · 2 段:图片生成(3 等比 CTA)+ 视频生成(1 CTA) · 两段等高填充容器 */
.ov-actions { display: flex; flex-direction: column; }
.ov-actions .qa-section {
margin-bottom: 14px;
display: flex; flex-direction: column;
flex: 1 1 0; min-height: 0;
}
.ov-actions .qa-section:last-child { margin-bottom: 0; }
.ov-actions .qa-section-h {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .06em;
text-transform: uppercase;
margin-bottom: 8px;
}
.ov-actions .qa-row-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
flex: 1 1 0; min-height: 0;
}
.ov-actions .qa-row-1 {
display: flex;
flex: 1 1 0; min-height: 0;
}
.ov-actions .qa-row-1 .qa-item { width: 100%; }
.qa-item {
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 8px;
padding: 14px 10px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
cursor: pointer;
font-size: 12.5px;
color: var(--accent-black);
text-align: center;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.qa-item:hover { border-color: var(--heat); background: var(--heat-12); color: var(--heat); }
.qa-item .ic {
width: 32px; height: 32px;
display: grid; place-items: center;
color: var(--heat);
flex-shrink: 0;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
}
.qa-item:hover .ic { border-color: var(--heat-20); background: var(--surface); }
.qa-item .ic svg { width: 16px; height: 16px; }
.qa-item.primary {
background: var(--heat);
color: var(--accent-white);
border-color: var(--heat);
}
.qa-item.primary .ic { background: rgba(255,255,255,.16); color: var(--accent-white); border-color: rgba(255,255,255,.24); }
.qa-item.primary:hover { color: var(--accent-white); box-shadow: var(--shadow-cta-hover); }
/* 状态 pill 三态(通过/不通过/归档) */
.asset-card .meta .pill.pass {
background: var(--accent-emerald-bg, #e6f4ec);
color: var(--accent-emerald, #1f8a51);
border: 1px solid var(--accent-emerald-bd, #c4e3d1);
cursor: pointer;
}
.asset-card .meta .pill.fail {
background: var(--crimson-bg, #fdebea);
color: var(--accent-crimson, #c43d3d);
border: 1px solid var(--crimson-bd, #f5c2bf);
cursor: pointer;
}
.asset-card .meta .pill.archive {
background: var(--background-lighter);
color: var(--black-alpha-56);
border: 1px solid var(--border-faint);
cursor: pointer;
}
/* ─── Tabs ─── */
.pd-tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--border-faint);
margin-bottom: 18px;
}
.pd-tabs .tab {
padding: 10px 14px;
font-size: 13.5px;
color: var(--black-alpha-56);
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: color var(--t-base), border-color var(--t-base);
}
.pd-tabs .tab:hover { color: var(--accent-black); }
.pd-tabs .tab.active {
color: var(--accent-black);
border-bottom-color: var(--heat);
font-weight: 600;
}
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* ─── AI 素材 工具栏 ─── */
.pd-toolbar {
display: flex; align-items: center; gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.pd-toolbar .total {
font-size: 14px; font-weight: 600;
color: var(--accent-black);
}
.pd-toolbar .total .ct {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
margin-left: 4px;
font-weight: 500;
}
.pd-toolbar .filter {
display: inline-flex; align-items: center; gap: 4px;
height: 30px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
cursor: pointer;
font-size: 12.5px;
color: var(--black-alpha-72);
font-family: inherit;
transition: border-color var(--t-base), color var(--t-base);
}
.pd-toolbar .filter:hover { border-color: var(--black-alpha-24); }
.pd-toolbar .filter.open, .pd-toolbar .filter.filtered { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.pd-toolbar .filter.open svg, .pd-toolbar .filter.filtered svg { opacity: 1; }
.pd-toolbar .filter svg { width: 10px; height: 10px; opacity: .6; transition: transform var(--t-base); }
.pd-toolbar .filter.open svg { transform: rotate(180deg); }
/* 筛选下拉 · 挂在 body 上避免被祖先 overflow 裁切 */
.filter-pop {
position: fixed;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 4px;
box-shadow: 0 6px 20px var(--black-alpha-12);
z-index: 1500;
min-width: 130px;
display: none;
flex-direction: column;
}
.filter-pop.show { display: flex; }
.filter-pop button {
background: transparent; border: 0;
padding: 8px 12px;
text-align: left;
font-size: 12.5px;
color: var(--accent-black);
cursor: pointer;
border-radius: var(--r-sm);
font-family: inherit;
white-space: nowrap;
transition: background var(--t-base);
}
.filter-pop button:hover { background: var(--background-lighter); }
.filter-pop button.selected { background: var(--heat-12); color: var(--heat); font-weight: 600; }
.filter-pop button.selected::before { content: '✓ '; }
.pd-toolbar .right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
.pd-toolbar .view-tog {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
padding: 2px;
}
.pd-toolbar .view-tog button {
width: 28px; height: 26px;
display: grid; place-items: center;
border: 0;
background: transparent;
color: var(--black-alpha-48);
cursor: pointer;
border-radius: 4px;
}
.pd-toolbar .view-tog button.active {
background: var(--accent-black);
color: var(--accent-white);
}
.pd-toolbar .view-tog button svg { width: 13px; height: 13px; }
/* ─── AI 素材 网格 ─── */
.asset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
/* 列表视图:卡片横排,缩略图缩到 88px */
.asset-grid.list-view {
display: flex;
flex-direction: column;
gap: 6px;
}
.asset-grid.list-view .asset-card {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 0;
align-items: center;
}
.asset-grid.list-view .asset-card .thumb {
aspect-ratio: 1;
width: 88px;
border-right: 1px solid var(--border-faint);
}
.asset-grid.list-view .asset-card .thumb .type-pill {
font-size: 9.5px;
padding: 2px 6px;
top: 4px;
left: 4px;
}
.asset-grid.list-view .asset-card .thumb .ph-frame { font-size: 10px; }
.asset-grid.list-view .asset-card .meta {
padding: 10px 14px;
}
/* 空筛选结果 */
.empty-filter {
padding: 56px 24px;
text-align: center;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 12.5px;
letter-spacing: .02em;
border: 1px dashed var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
}
.empty-filter .reset {
display: inline-block; margin-top: 12px;
color: var(--heat); cursor: pointer; text-decoration: underline;
}
.asset-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--t-base), transform var(--t-fast);
}
.asset-card:hover { border-color: var(--black-alpha-24); transform: translateY(-1px); }
.asset-card .thumb {
aspect-ratio: 3/4;
position: relative;
overflow: hidden;
}
.asset-card .thumb .type-pill {
position: absolute; top: 8px; left: 8px;
padding: 3px 8px;
background: rgba(0,0,0,.65);
color: var(--accent-white);
border-radius: var(--r-sm);
font-size: 11px;
font-weight: 500;
backdrop-filter: blur(4px);
}
.asset-card .meta {
padding: 10px 12px;
display: flex; align-items: center; gap: 8px;
}
.asset-card .meta .pill {
padding: 2px 8px;
border-radius: var(--r-sm);
font-size: 10.5px;
font-weight: 500;
}
.asset-card .meta .date {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
.pd-more {
text-align: center;
padding: 18px 0 32px;
}
.pd-more button {
height: 32px;
padding: 0 18px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
color: var(--black-alpha-72);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
}
.pd-more button:hover { border-color: var(--heat-40); color: var(--heat); }
/* ─── 任务记录 · 表格 ─── */
.task-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.task-stat {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 14px 18px;
}
.task-stat .lbl {
font-family: var(--font-mono);
font-size: 11px;
color: var(--black-alpha-48);
letter-spacing: .04em;
margin-bottom: 6px;
}
.task-stat .v {
font-size: 22px; font-weight: 600;
color: var(--accent-black);
letter-spacing: -.01em;
}
.task-stat .v small {
font-size: 13px;
color: var(--black-alpha-48);
font-weight: 400;
margin-left: 4px;
}
.task-stat.ok .v { color: var(--accent-emerald, #1f8a51); }
.task-stat.gen .v { color: var(--heat); }
.task-stat.err .v { color: var(--accent-crimson, #c43d3d); }
.task-table {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
}
.task-row {
display: grid;
grid-template-columns: 36px 1.8fr 0.7fr 1fr 1.1fr 1.1fr 0.7fr 100px;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-bottom: 1px solid var(--border-faint);
font-size: 13px;
}
.task-row:last-child { border-bottom: 0; }
.task-row.head {
background: var(--background-lighter);
font-family: var(--font-mono);
font-size: 11px;
color: var(--black-alpha-48);
letter-spacing: .04em;
font-weight: 500;
padding: 10px 18px;
}
.task-row .ph {
width: 36px; height: 36px;
border-radius: var(--r-sm);
flex-shrink: 0;
}
.task-row .nm {
color: var(--accent-black);
font-weight: 500;
display: flex; align-items: center; gap: 8px;
}
.task-row .nm .id-mono {
font-family: var(--font-mono);
font-size: 11px;
color: var(--black-alpha-48);
font-weight: 400;
}
.task-row .qty { color: var(--black-alpha-72); font-family: var(--font-mono); }
.task-row .time {
color: var(--black-alpha-72);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: .01em;
}
.task-row .dur {
color: var(--black-alpha-56);
font-family: var(--font-mono);
font-size: 12px;
}
.task-row .pill {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 9px;
border-radius: var(--r-sm);
font-size: 11.5px;
font-weight: 500;
width: fit-content;
}
.task-row .pill .dot {
width: 6px; height: 6px;
border-radius: 50%;
}
.task-row .pill.ok {
background: var(--accent-emerald-bg, #e6f4ec);
color: var(--accent-emerald, #1f8a51);
border: 1px solid var(--accent-emerald-bd, #c4e3d1);
}
.task-row .pill.ok .dot { background: var(--accent-emerald, #1f8a51); }
.task-row .pill.gen {
background: var(--heat-12);
color: var(--heat);
border: 1px solid var(--heat-20);
}
.task-row .pill.gen .dot { background: var(--heat); animation: pulse 1.6s ease-in-out infinite; }
.task-row .pill.err {
background: var(--crimson-bg, #fdebea);
color: var(--accent-crimson, #c43d3d);
border: 1px solid var(--crimson-bd, #f5c2bf);
}
.task-row .pill.err .dot { background: var(--accent-crimson, #c43d3d); }
.task-row .pill.wait {
background: var(--background-lighter);
color: var(--black-alpha-56);
border: 1px solid var(--border-faint);
}
.task-row .pill.wait .dot { background: var(--black-alpha-32); }
.task-row .status-cell { display: flex; flex-direction: column; gap: 4px; }
.task-row .progress {
width: 100%; height: 3px;
background: var(--black-alpha-12);
border-radius: 2px;
overflow: hidden;
}
.task-row .progress > span {
display: block;
height: 100%;
background: var(--heat);
}
.task-row .ops {
display: inline-flex; gap: 4px;
justify-self: end;
}
.task-row .ops button {
padding: 4px 10px;
height: 26px;
background: transparent;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
color: var(--black-alpha-72);
font-size: 11.5px;
font-family: inherit;
cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.task-row .ops button:hover { border-color: var(--heat-40); color: var(--heat); }
.task-row .ops button.danger:hover { border-color: var(--crimson-bd, #f5c2bf); color: var(--accent-crimson, #c43d3d); }
@media (max-width: 1100px) {
.pd-overview { grid-template-columns: 1fr; }
.ov-actions .qa-grid {
display: grid !important;
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.ov-actions .qa-grid { grid-template-columns: 1fr 1fr; }
.task-stats { grid-template-columns: repeat(2, 1fr); }
}
}

View File

@ -0,0 +1,33 @@
/* 商品库页 · public/exact/products.html 内联 <style> 忠实移植,整段 scope .products-page
page-head/toolbar/chip/search-inline 走全局 design-restraint;此处覆盖 products 专属版式 */
.products-page {
.page-head { position: sticky; top: 0; z-index: 5; background: var(--background-base); padding-top: 4px; margin-top: -4px; }
.products-main { display: flex; flex-direction: column; }
.products-main .result-meta { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.chip-wrap { position: relative; }
.product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; }
.product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.product-thumb { aspect-ratio: 1.4 / 1; }
.product-body { padding: 14px 14px 12px; flex: 1; }
.product-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 11.5px; }
.product-date { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
.product-footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-faint); font-size: 11.5px; color: #6f6f6f; background: var(--background-base); }
.product-footer .stat { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 3px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); letter-spacing: .02em; white-space: nowrap; border: 1px solid transparent; justify-self: center; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.product-footer .stat svg { width: 14px; height: 14px; color: currentColor; flex-shrink: 0; stroke-width: 1.25; transition: color var(--t-base); }
.product-footer .stat b { color: var(--accent-black); font-weight: 600; transition: color var(--t-base); }
.product-footer .sep { color: #b8b8b8; font-family: var(--font-mono); flex-shrink: 0; }
.view-tog { display: inline-flex; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 2px; flex-shrink: 0; }
.view-tog button { width: 30px; height: 28px; display: grid; place-items: center; border: 0; background: transparent; color: var(--black-alpha-48); cursor: pointer; border-radius: 4px; transition: background var(--t-base), color var(--t-base); }
.view-tog button:hover { color: var(--accent-black); }
.view-tog button.active { background: var(--accent-black); color: var(--accent-white); }
.view-tog button svg { width: 13px; height: 13px; }
/* 编辑模式 checkbox(默认隐藏) */
.product-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; }
}

View File

@ -0,0 +1,44 @@
/* 视频项目页 · public/exact/projects.html 内联 <style> 忠实移植,整段 scope .projects-page
tabs/chip/toolbar/search-inline/result-meta 走全局 design-restraint(restraint );此处只覆盖 proj-* / view-toggle 等页面专属 */
.projects-page {
/* ─── List view ─── */
.proj-name-cell { display: flex; align-items: center; gap: 12px; }
.proj-thumb { width: 40px; height: 52px; flex-shrink: 0; border-radius: var(--r-md); }
.proj-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
.proj-sub { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.row-action { display: flex; gap: 4px; visibility: hidden; }
table.t tbody tr:hover .row-action { visibility: visible; }
.row-action a { width: 28px; height: 28px; display: grid; place-items: center; color: var(--black-alpha-56); border-radius: var(--r-md); }
.row-action a:hover { background: var(--surface); color: var(--heat); border: 1px solid var(--border-faint); }
/* ─── View toggle ─── */
.view-toggle { display: inline-flex; border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
.view-toggle button { padding: 0 14px; background: var(--surface); color: var(--black-alpha-56); font-size: 13px; border-right: 1px solid var(--border-faint); border-radius: 0; height: 36px; cursor: pointer; font-family: inherit; display: flex; align-items: center; gap: 6px; transition: background var(--t-base), color var(--t-base); }
.view-toggle button:last-child { border-right: 0; }
.view-toggle button:hover { background: var(--background-lighter); color: var(--accent-black); }
.view-toggle button.active { background: var(--heat-12); color: var(--heat); font-weight: 600; }
.view-toggle button svg { width: 13px; height: 13px; }
/* ─── Grid view ─── */
.proj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.proj-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; display: flex; flex-direction: column; position: relative; }
.proj-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.proj-card .card-thumb { aspect-ratio: 9/16; max-height: 280px; border-radius: var(--r-md) var(--r-md) 0 0; }
.row-more { position: relative; display: inline-flex; cursor: pointer; align-items: center; color: var(--black-alpha-56); padding: 4px; }
.row-more:hover { color: var(--accent-black); }
.row-more-tip { position: absolute; top: calc(100% + 6px); right: 0; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: 0 4px 16px rgba(0,0,0,.08); padding: 4px; min-width: 110px; opacity: 0; pointer-events: none; transform: translateY(-2px); transition: opacity .15s, transform .15s; z-index: 12; }
.row-more-tip::before { content: ''; position: absolute; top: -8px; left: 0; right: 0; height: 8px; }
.row-more:hover .row-more-tip, .row-more-tip:hover { opacity: 1; pointer-events: auto; transform: translateY(0); }
.row-more-tip .mi { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; background: transparent; border: 0; border-radius: var(--r-sm); cursor: pointer; font-size: 12.5px; color: var(--accent-black); font-family: inherit; text-align: left; transition: background var(--t-base), color var(--t-base); }
.row-more-tip .mi:hover { background: var(--crimson-bg, #fdebea); color: var(--accent-crimson, #c43d3d); }
.row-more-tip .mi svg { width: 13px; height: 13px; }
.btn.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.proj-card .card-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.proj-card .card-name { font-size: 13.5px; font-weight: 600; color: var(--accent-black); line-height: 1.4; }
.proj-card .card-sub { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; }
.proj-card .card-foot { display: flex; align-items: center; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--border-faint); margin-top: auto; }
.proj-card .card-time { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* chip 下拉容器(默认收起)· chip 本体/caret 走全局 restraint .chip */
.chip-wrap { position: relative; }
}

View File

@ -1,7 +1,22 @@
import { useState } from "react";
import { CreditCard } from "lucide-react";
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
import { money, stageMeta, statusPill } from "./stage-config";
import { money } from "./stage-config";
type Tab = "overview" | "by-project" | "by-member" | "bills";
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; ribbon?: string }> = [
{ amt: 100, gift: "无赠送", bonus: false },
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, ribbon: "推荐" },
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true },
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true }
];
const STAGES: Array<{ k: string; color: string }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)" },
{ k: "故事板(image-2)", color: "var(--accent-forest)" },
{ k: "基础资产", color: "var(--black-alpha-56)" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)" }
];
export function AccountPage({ billing, ledgers, projects, teamMembers }: {
billing: BillingSummary | null;
@ -9,21 +24,195 @@ export function AccountPage({ billing, ledgers, projects, teamMembers }: {
projects: Project[];
teamMembers: TeamMember[];
}) {
const [tab, setTab] = useState<"overview" | "projects" | "members" | "bills">("overview");
const [amount, setAmount] = useState(500);
const [tab, setTab] = useState<Tab>("overview");
const [recharge, setRecharge] = useState(500);
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
const memberLimit = teamMembers.reduce((sum, m) => sum + Math.max(0, Number(m.monthly_credit_limit || 0)), 0);
const limit = memberLimit || balance;
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div></div></div>
<div className="top-grid"><div className="balance-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="balance-hero"><div className="lbl"></div><div className="v">{money(billing?.account.balance)}</div><div className="meta">// 冻结 {money(billing?.account.reserved_balance)} · 已消费 {money(billing?.charged_total)}</div></div></div><div className="pane topup-pane"><div className="topup-head"><div><h3>快速充值</h3><div className="desc">// 充值后立刻到账,可开发票</div></div><div className="topup-selected">已选 ¥{amount}</div></div><div className="recharge-row">{[100, 500, 1000, 3000].map((item) => <button className={`recharge-card ${amount === item ? "selected" : ""}`} type="button" key={item} onClick={() => setAmount(item)}><div className="amt">¥{item}</div><div className="gift">+ 赠送</div></button>)}</div><button className="btn pay-method-btn" type="button"><CreditCard size={13} />微信支付</button></div></div>
<div className="billing-tabs">{[["overview", "总览"], ["projects", "项目"], ["members", "成员"], ["bills", "账单流水"]].map(([key, label]) => <button className={`tab ${tab === key ? "active" : ""}`} type="button" key={key} onClick={() => setTab(key as typeof tab)}>{label}</button>)}</div>
{tab === "overview" && <div className="overview-grid"><div className="pane trend-pane"><h3></h3><div className="bars">{Array.from({ length: 14 }).map((_, index) => <span key={index} style={{ height: `${18 + (index % 6) * 10}%` }} />)}</div></div><div className="pane stage-pane"><h3></h3><UsageLine label="视频片段" value="¥98.40" width="60%" color="var(--orange)" /><UsageLine label="故事板" value="¥36.00" width="22%" color="var(--green)" /></div></div>}
{tab === "projects" && <table className="billing-table"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{projects.map((project) => <tr key={project.id}><td>{project.name}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td>¥0.00</td></tr>)}</tbody></table>}
{tab === "members" && <table className="billing-table"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{teamMembers.map((member) => <tr key={member.id}><td>{member.user.username}</td><td>{member.role}</td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td></tr>)}</tbody></table>}
{tab === "bills" && <table className="billing-table bills"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{ledgers.map((item) => <tr key={item.id}><td>{new Date(item.created_at).toLocaleString("zh-CN")}</td><td>{item.ledger_type}</td><td>{item.reason}</td><td>{item.amount}</td></tr>)}</tbody></table>}
</>
<section className="account-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
</div>
</div>
<div className="top-grid">
<div className="balance-banner">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
<div className="balance-hero">
<div className="lbl"></div>
<div className="v">{money(balance)}</div>
<div className="meta">// 充值累加 · 不重置</div>
</div>
<div className="balance-sub">
<div className="col">
<div className="lbl"></div>
<div className="v">{money(limit)}</div>
<div className="meta">// 按自然月重置</div>
</div>
<div className="col">
<div className="lbl"></div>
<div className="v">{money(used)}</div>
<div className="meta">// 占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
</div>
</div>
<div className="balance-foot">
<div className="balance-meter" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}><span style={{ width: `${pct}%` }} /></div>
<div className="balance-foot-meta">
<span> {money(left)}</span>
<span>使 {pct.toFixed(1)}%</span>
</div>
</div>
</div>
<div className="pane topup-pane">
<div className="topup-head">
<div>
<h3></h3>
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
</div>
<div className="topup-selected"> ¥{recharge}</div>
</div>
<div className="recharge-row">
{RECHARGE.map((item) => (
<div
key={item.amt}
className={`recharge-card ${recharge === item.amt ? "selected" : ""}`}
role="button"
tabIndex={0}
aria-pressed={recharge === item.amt}
onClick={() => setRecharge(item.amt)}
>
{item.ribbon && <span className="ribbon">{item.ribbon}</span>}
<div className="amt">¥{item.amt}</div>
<div className={`gift ${item.bonus ? "bonus" : ""}`}>{item.gift}</div>
</div>
))}
</div>
<div className="pay-row">
<div className="pay-title"></div>
<input className="input" placeholder="最低 ¥50,可输入任意金额" />
<div className="pay-btn-row">
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付">
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
</button>
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝">
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
</button>
</div>
</div>
</div>
</div>
<div className="billing-tabs" role="tablist">
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}></button>
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}> <span className="count">{projects.length}</span></button>
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}> <span className="count">{teamMembers.length}</span></button>
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}> <span className="count">{ledgers.length}</span></button>
</div>
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
<div className="overview-grid">
<div className="pane trend-pane">
<div className="trend-head">
<h3></h3>
<span className="sub">// 近 14 天 · 单位 ¥</span>
<span className="spacer"></span>
<button className="chip active" type="button"></button>
<button className="chip" type="button"></button>
<button className="chip" type="button"></button>
</div>
<div className="trend-chart">
<div className="bars"></div>
<div className="x-axis"></div>
</div>
<div className="trend-foot">
<div className="item"><span className="k">14 </span><span className="v">{money(used)}</span></div>
<div className="item"><span className="k"></span><span className="v">{money(used / 14)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(used)}</span></div>
</div>
</div>
<div className="pane stage-pane">
<h3></h3>
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
{STAGES.map((s) => (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(0)}</span></div>
<div className="usage-bar"><span style={{ width: "0%", background: s.color }} /></div>
</div>
))}
<div className="total"><span></span><span className="v">{money(used)}</span></div>
</div>
</div>
<div className="pane rule-pane" style={{ marginTop: 16 }}>
<h3> + </h3>
<div className="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
<div className="rule-list">
<strong> </strong>: / / <br />
<strong> </strong>:,<br />
<strong> <span className="mono-acc">[ ]</span> </strong><br />
<strong> </strong>, token
</div>
<div className="quota-rules">
<div className="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
<div className="step"><span className="num">1</span><span><strong></strong> × <span className="formula">1.2</span></span></div>
<div className="step"><span className="num">2</span><span><strong></strong> </span></div>
<div className="step"><span className="num">3</span><span><strong></strong> </span></div>
<div className="step"><span className="num">4</span><span><strong></strong> </span></div>
</div>
</div>
</div>
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<table className="billing-table">
<thead><tr><th></th><th> / </th><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{ledgers.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
<td>{l.ledger_type}</td>
<td className="muted">{l.reason}</td>
<td></td>
<td><span className="status-tag ok">OK</span></td>
<td className="neg">{l.amount}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<table className="billing-table">
<thead><tr><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className="zero">{money(0)}</td></tr>
))}
</tbody>
</table>
</div>
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<table className="billing-table">
<thead><tr><th></th><th></th><th> / </th><th></th></tr></thead>
<tbody>
{teamMembers.map((m) => (
<tr key={m.id}><td className="who"><span className="av">{m.user.username.slice(0, 1).toUpperCase()}</span>{m.user.username}</td><td>{m.role}</td><td className="zero">{money(m.monthly_credit_limit)}</td><td>{m.status}</td></tr>
))}
</tbody>
</table>
</div>
</section>
);
}
export function UsageLine({ label, value, width, color }: { label: string; value: string; width: string; color: string }) {
return <><div className="usage-line"><span>{label}</span><span className="v">{value}</span></div><div className="usage-bar"><span style={{ width, background: color }} /></div></>;
}

View File

@ -4,6 +4,22 @@ import { api } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
const CHECK_SVG = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
);
const MAIL_SVG = (
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
);
const LOCK_SVG = (
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
);
const EYE_SVG = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
);
const ARROW_SVG = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
);
export function AuthScreen({
initialMode,
onModeChange,
@ -20,8 +36,11 @@ export function AuthScreen({
const [email, setEmail] = useState("");
const [registerPassword, setRegisterPassword] = useState("");
const [registerPassword2, setRegisterPassword2] = useState("");
const [invite, setInvite] = useState("");
const [agreed, setAgreed] = useState(true);
const [showPwd, setShowPwd] = useState(false);
const [showRegPwd, setShowRegPwd] = useState(false);
const [showRegPwd2, setShowRegPwd2] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
@ -82,71 +101,117 @@ export function AuthScreen({
<div className="hero">
{mode === "login" ? <><h1>AI <br /><span className="h"></span></h1><p> AI Seedance 9:16 60s </p></> : <><h1>,<br /> <span className="h">AI </span> </h1><p> / 1-2 ,,</p></>}
</div>
<div className="ascii">
<div className="ln"><span className="k">// step·1</span> &nbsp; 脚本生成 &nbsp; &nbsp; <span className="v">●●●●●</span></div>
<div className="ln"><span className="k">// step·2</span> &nbsp; 基础资产 &nbsp; &nbsp; <span className="v">●●●●○</span></div>
<div className="ln"><span className="k">// step·3</span> &nbsp; 故事板 &nbsp; &nbsp; &nbsp; <span className="v">●●●○○</span></div>
<div className="ln"><span className="k">// step·4</span> &nbsp; 视频片段 &nbsp; &nbsp; <span className="v">●●○○○</span></div>
<div className="ln"><span className="k">// step·5</span> &nbsp; 拼接导出 &nbsp; &nbsp; <span className="v">●○○○○</span></div>
</div>
{mode === "login" ? (
<div className="ascii">
<div className="ln"><span className="k">// step·1</span> &nbsp; 脚本生成 &nbsp; &nbsp; <span className="v">●●●●●</span></div>
<div className="ln"><span className="k">// step·2</span> &nbsp; 基础资产 &nbsp; &nbsp; <span className="v">●●●●○</span></div>
<div className="ln"><span className="k">// step·3</span> &nbsp; 故事板 &nbsp; &nbsp; &nbsp; <span className="v">●●●○○</span></div>
<div className="ln"><span className="k">// step·4</span> &nbsp; 视频片段 &nbsp; &nbsp; <span className="v">●●○○○</span></div>
<div className="ln"><span className="k">// step·5</span> &nbsp; 拼接导出 &nbsp; &nbsp; <span className="v">●○○○○</span></div>
</div>
) : (
<div className="val-list">
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b>5 线</b> · </span></div>
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b></b> · , / / </span></div>
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b> + + </b> · / / , / + / </span></div>
<div className="val-item"><span className="ic-v">{CHECK_SVG}</span><span className="txt-v"><b></b> · / / ,</span></div>
</div>
)}
<div className="foot"><a href="#"></a><a href="#"></a><a href="#"></a><a href="#"></a></div>
</aside>
<main className="auth-form">
<div className="h-row"><h2>{mode === "login" ? "登录" : "注册团队"}</h2><span className="sub">{mode === "login" ? "// /auth/login" : "// /auth/register"}</span></div>
<p className="lead">{mode === "login" ? "使用团队邀请邮箱登录,接受邀请后自动加入对应团队。" : "填写团队信息开通账户,默认成为团队超管。"}</p>
<form id={mode === "login" ? "login-form" : "register-form"} autoComplete="off" onSubmit={submit}>
{mode === "login" ? (
<>
<div className="field">
<label className="field-label" htmlFor="auth-email"> <span className="req">*</span></label>
<div className="field-input-wrap">
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" /><path d="m22 6-10 7L2 6" /></svg>
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
</div>
{mode === "login" ? (
<form id="login-form" autoComplete="off" onSubmit={submit}>
<div className="field">
<label className="field-label" htmlFor="auth-email"> <span className="req">*</span></label>
<div className="field-input-wrap">
{MAIL_SVG}
<input type="email" id="auth-email" placeholder="name@company.com" value={username} onChange={(event) => setUsername(event.target.value)} required />
</div>
<div className="field">
<label className="field-label" htmlFor="auth-pwd"> <span className="req">*</span></label>
<div className="field-input-wrap">
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>
<svg id="pwd-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></svg>
</button>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="auth-pwd"> <span className="req">*</span></label>
<div className="field-input-wrap">
{LOCK_SVG}
<input type={showPwd ? "text" : "password"} id="auth-pwd" placeholder="••••••••" value={password} onChange={(event) => setPassword(event.target.value)} required />
<button type="button" className="toggle-pwd" aria-label="切换密码可见" onClick={() => setShowPwd((value) => !value)}>{EYE_SVG}</button>
</div>
<div className="row-between">
<label><input type="checkbox" defaultChecked /> 7 </label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>?</a>
</div>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></>}
</div>
<div className="row-between">
<label><input type="checkbox" defaultChecked /> 7 </label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>?</a>
</div>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
{busy ? <span className="busy-copy">// 验证中...</span> : <>登录{ARROW_SVG}</>}
</button>
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
<div className="sso-row">
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
</button>
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
<div className="sso-row">
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "请在 60s 内用微信扫一扫完成授权 · 内测中,以邮箱登录为准")}>
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 4C5 4 2 6.5 2 9.5c0 1.7 1 3.2 2.5 4.2L4 16l2.2-1.1c.7.2 1.5.3 2.3.3-.3-.5-.5-1.1-.5-1.7 0-2.8 2.9-5 6.5-5h.5C14.6 6 12 4 9 4zm-2 3.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zM15 9c-3.3 0-6 2.2-6 5s2.7 5 6 5c.7 0 1.4-.1 2-.3L19 20l-.4-1.7c1.4-.9 2.4-2.3 2.4-3.8 0-2.8-2.7-5-6-5zm-2 2.5c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1zm4 0c.6 0 1 .4 1 1s-.4 1-1 1-1-.4-1-1 .4-1 1-1z" /></svg>
</button>
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
SSO
</button>
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "即将打开企业飞书登录授权页 · 内测中,以邮箱登录为准")}>
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="18" height="18" rx="3" /></svg>
SSO
</button>
</div>
<div className="switch-row">? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a></div>
</form>
) : (
<form id="register-form" autoComplete="off" onSubmit={submit}>
<div className="field">
<label className="field-label" htmlFor="reg-team"> <span className="req">*</span></label>
<div className="field-input-wrap">
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><path d="M9 22V12h6v10" /></svg>
<input type="text" id="reg-team" placeholder="例: 小李的店 / XX 文化传媒" value={teamName} onChange={(event) => setTeamName(event.target.value)} required />
</div>
<div className="switch-row">? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a></div>
</>
) : (
<>
<div className="field"><label className="field-label" htmlFor="reg-team"> <span className="req">*</span></label><div className="field-input-wrap"><input id="reg-team" value={teamName} onChange={(event) => setTeamName(event.target.value)} required /></div></div>
<div className="field"><label className="field-label" htmlFor="reg-email"> <span className="req">*</span></label><div className="field-input-wrap"><input type="email" id="reg-email" value={email} onChange={(event) => setEmail(event.target.value)} required /></div></div>
<div className="field-row"><div className="field"><label className="field-label" htmlFor="reg-pwd"> <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required /></div></div><div className="field"><label className="field-label" htmlFor="reg-pwd2"> <span className="req">*</span></label><div className="field-input-wrap"><input type="password" id="reg-pwd2" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required /></div></div></div>
<label className="agree"><input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} /><span> </span></label>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>{busy ? <span className="busy-copy">// 验证中...</span> : "创建团队 · 开始使用"}</button>
<div className="switch-row">? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}> </a></div>
</>
)}
</form>
</div>
<div className="field">
<label className="field-label" htmlFor="reg-email"> <span className="req">*</span><span className="hint"> + </span></label>
<div className="field-input-wrap">
{MAIL_SVG}
<input type="email" id="reg-email" placeholder="name@company.com" value={email} onChange={(event) => setEmail(event.target.value)} required />
</div>
</div>
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="reg-pwd"> <span className="req">*</span></label>
<div className="field-input-wrap">
{LOCK_SVG}
<input type={showRegPwd ? "text" : "password"} id="reg-pwd" placeholder="至少 8 位" value={registerPassword} onChange={(event) => setRegisterPassword(event.target.value)} required />
<button type="button" className="toggle-pwd" onClick={() => setShowRegPwd((value) => !value)}>{EYE_SVG}</button>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="reg-pwd2"> <span className="req">*</span></label>
<div className="field-input-wrap">
{LOCK_SVG}
<input type={showRegPwd2 ? "text" : "password"} id="reg-pwd2" placeholder="再输一次" value={registerPassword2} onChange={(event) => setRegisterPassword2(event.target.value)} required />
<button type="button" className="toggle-pwd" onClick={() => setShowRegPwd2((value) => !value)}>{EYE_SVG}</button>
</div>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="reg-invite"> <span className="hint"> · </span></label>
<div className="field-input-wrap">
<svg className="ic-l" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></svg>
<input type="text" id="reg-invite" placeholder="例: TEAM-XXXX-XXXX" value={invite} onChange={(event) => setInvite(event.target.value)} />
</div>
</div>
<label className="agree">
<input type="checkbox" id="reg-agree" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
<span> <a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}></a> <a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}></a>, · </span>
</label>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" id="reg-submit" disabled={busy}>
{busy ? <span className="busy-copy">// 创建团队中...</span> : <>创建团队 · 开始使用{ARROW_SVG}</>}
</button>
<div className="switch-row">? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}> </a></div>
</form>
)}
</main>
</div>
{toast && <div className="login-toast"><div>{toast.title}</div><div style={{ fontSize: "11.5px", color: "rgba(0,0,0,.56)", fontWeight: 400, letterSpacing: ".02em" }}>// {toast.sub}</div></div>}

View File

@ -1,8 +1,9 @@
import { Film, Home, Library, Package, Plus, Wallet } from "lucide-react";
import { Plus } from "lucide-react";
import type { Asset, BillingSummary, Product, Project } from "../types";
import type { Page } from "./route-config";
import { money, stageMeta, statusPill } from "./stage-config";
import { Progress } from "../components/pipeline-stage";
import { IconKitSvg } from "../components/IconKitSvg";
export function Dashboard({ products, projects, assets, billing, userName, navigate }: {
products: Product[];
@ -17,9 +18,9 @@ export function Dashboard({ products, projects, assets, billing, userName, navig
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
return (
<>
<div className="page-head"><div><h1>{userName ? `${userName}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
<div className="page-head"><div><h1>{userName ? `${userName.split("@")[0]}` : ""}</h1><div className="sub"><span className="mono">// {new Date().toLocaleDateString("zh-CN")}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><Plus size={13} />新建商品</button><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建项目</button></div></div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projects.length} delta={`↑ 本月 +${Math.max(projects.length, 0)}`} /><KpiStat label="进行中" badge="WIP" value={running} delta="待处理" /><KpiStat label="成片" badge="DONE" value={completed} delta="导出完成" /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: "38%" }} /></div><div className="sub"> {money(billing?.account.reserved_balance)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] </button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div className="dash-side"><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut icon={Package} title="" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut icon={Library} title="" desc={`${assets.length} assets`} onClick={() => navigate("library")} /><Shortcut icon={Wallet} title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut icon={Film} title="" desc={`${projects.length} `} onClick={() => navigate("projects")} /></div></div></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projects.length} ] </button></div><div className="card-hard">{projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{productTitle(project.product)} / AI / 4 </div></div><Progress status={project.current_stage} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${products.length} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assets.length} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projects.length} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
</>
);
}
@ -28,6 +29,6 @@ export function KpiStat({ label, badge, value, delta }: { label: string; badge:
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div><div className="delta up">{delta}</div></div>;
}
export function Shortcut({ icon: Icon, title, desc, onClick }: { icon: typeof Home; title: string; desc: string; onClick: () => void }) {
return <button className="shortcut" type="button" onClick={onClick}><div className="ic"><Icon size={15} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></button>;
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
return <a className="shortcut" onClick={onClick}><div className="ic"><IconKitSvg name={name} size={16} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></a>;
}

View File

@ -1,14 +1,50 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Search, Upload } from "lucide-react";
import type { Asset } from "../types";
import { Drawer } from "../components/overlays";
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
{ key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
];
// 对齐 api-bridge:工具栏 chip 按 tab 显隐
const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "gender", label: "性别", tabs: ["people"] },
{ key: "age", label: "年龄段", tabs: ["people"] },
{ key: "role", label: "角色标签", tabs: ["people"] },
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
{ key: "product", label: "关联商品", tabs: ["products"] },
{ key: "project", label: "关联项目", tabs: ["finals"] },
{ key: "duration", label: "时长", tabs: ["finals"] },
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] }
];
function assetTab(asset: Asset): LibTab {
switch (asset.category) {
case "person": return "people";
case "scene": return "scenes";
case "product_image": return "products";
case "final_video": return "finals";
case "upload": return "uploads";
default: return asset.asset_type === "video" ? "finals" : "unclassified";
}
}
export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people");
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const counts = LIB_TABS.reduce((acc, t) => { acc[t.key] = assets.filter((a) => assetTab(a) === t.key).length; return acc; }, {} as Record<LibTab, number>);
const inTab = assets.filter((a) => assetTab(a) === tab);
const filtered = inTab.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()));
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
@ -22,12 +58,62 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
}
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// 跨项目复用 · {assets.length} assets</span></div></div><div className="actions"><button className="btn" type="button" onClick={() => setDrawer(true)}><Upload size={13} />上传资产</button></div></div>
<div className="tabs"><button className="tab active"> <span className="count">{assets.length}</span></button><button className="tab"></button><button className="tab"></button><button className="tab"></button><button className="tab"></button></div>
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索资产" /></div><button className="chip active"></button><button className="chip">使</button></div>
<div className="asset-grid">{assets.map((asset) => <article className={`asset-card ${asset.asset_type}`} key={asset.id}><div className="placeholder asset-thumb"><span className="ph-frame">{asset.asset_type}</span></div><div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div></article>)}</div>
<section className="library-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
</div>
<div className="actions">
<button className="btn" type="button" id="lib-manage-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="lib-manage-label"></span>
</button>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</button>
</div>
</div>
<div className="tabs" id="asset-tabs">
{LIB_TABS.map((t) => (
<div className={`tab${tab === t.key ? " active" : ""}`} key={t.key} data-tab={t.key} onClick={() => setTab(t.key)}>{t.label} <span className="count">{counts[t.key]}</span></div>
))}
</div>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => (
<div className="chip-wrap" data-key={chip.key} key={chip.key}>
<button className="chip" type="button"><span className="chip-label">{chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
</div>
))}
<span className="spacer"></span>
<div className="chip-wrap" data-key="sort">
<button className="chip" type="button"><span className="chip-label">使</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {inTab.length} 个资产</div>
{filtered.length ? (
<div className="asset-grid" id="asset-grid">
{filtered.map((asset) => (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
<div className="placeholder asset-thumb"><span className="ph-frame">{asset.asset_type}</span></div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
</article>
))}
</div>
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label"></label><input className="input file-input" type="file" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label"></label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit" disabled={!file}></button></div></form></Drawer>
</>
</section>
);
}

View File

@ -1,26 +1,71 @@
import { useState } from "react";
import { Copy, Play } from "lucide-react";
import type { Project } from "../types";
import { FragmentStageStep } from "../components/pipeline-stage";
import { stageOrder, statusPill } from "./stage-config";
import { Fragment, useState } from "react";
import type { CSSProperties } from "react";
import { Play } from "lucide-react";
import type { BillingSummary, Product, Project, Team, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
import { IconKitSvg } from "../components/IconKitSvg";
export function PipelinePage({
project,
loading,
onRefresh,
onGenerateScript,
onAdoptScript,
onGenerateBaseAsset,
onGenerateStoryboard,
onSkipStoryboard,
onSubmitVideo,
onPollVideo,
onSubmitAllVideos,
onPollAllVideos,
onSubmitExport
}: {
// 镜像 shell.js→mock-media.js 给 .placeholder 注入 mock 图;React 手动复刻对应映射
const mock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
// Stage 3 故事板 · 镜像 JS 注入的 3 场(全 mock)
const SB_SCENES = [
{ sid: "sc1", nm: "场 1", sub: "0-15s", frame: "深夜办公桌", img: "scene-office.png" },
{ sid: "sc2", nm: "场 2", sub: "15-30s", frame: "面膜包装/特写", img: "product-mask.png" },
{ sid: "sc3", nm: "场 3", sub: "30-45s", frame: "化妆台/产品定格", img: "cover-mask-final.png" }
];
const SB_PROMPT = "中景 / 固定机位\n光线:台灯暖光 + 屏幕冷光\n演员:林夕(疲倦状态)\n关键道具:面膜盒(从抽屉露半角)\n氛围:午夜、安静、些许焦虑";
// Stage 4 视频 · 镜像 3 场(全 mock,video-thumb 经 mock-media 映射封面图)
const VIDEO_CARDS = [
{ vid: "v1", frame: "场 1 · 0-15s", title: "场 1 · 深夜办公桌", meta: "15s · 1080×1920 · ¥0.45", img: "cover-mask-v3.png" },
{ vid: "v2", frame: "场 2 · 15-27s", title: "场 2 · 面膜包装/特写", meta: "12s · 1080×1920 · ¥0.45", img: "product-mask.png" },
{ vid: "v3", frame: "场 3 · 27-40s", title: "场 3 · 化妆台/产品定格", meta: "13s · 1080×1920 · ¥0.45", img: "cover-mask-final.png" }
];
// Stage 5 拼接编辑器 · 全 mock(镜像时间轴引擎按 data-dur 累计定位,React 直接算 left/width)
const ED_VIDEO_CLIPS = [
{ n: 1, lbl: "深夜办公桌", dur: 2 }, { n: 2, lbl: "面膜包装", dur: 3 }, { n: 3, lbl: "精华液微距", dur: 3 },
{ n: 4, lbl: "敷面膜平躺", dur: 3 }, { n: 5, lbl: "化妆台", dur: 2 }, { n: 6, lbl: "产品定格", dur: 2 }
];
const ED_SUB_CLIPS = [
{ lbl: "加班三天 脸已经不能看了…", dur: 2 }, { lbl: "还好我有这个 透真玻尿酸面膜", dur: 3 }, { lbl: "30g 精华 一片顶三片", dur: 3 },
{ lbl: "敷完起来脸是软的", dur: 3 }, { lbl: "化妆都能看出来", dur: 2 }, { lbl: "5 片 ¥39.9 囤起来", dur: 2 }
];
const ED_RULER: Array<{ left: string; major: boolean; t?: string }> = [
{ left: "0%", major: true, t: "0s" }, { left: "6.67%", major: false }, { left: "13.33%", major: true, t: "2s" }, { left: "20%", major: false },
{ left: "26.67%", major: true, t: "4s" }, { left: "33.33%", major: false }, { left: "40%", major: true, t: "6s" }, { left: "46.67%", major: false },
{ left: "53.33%", major: true, t: "8s" }, { left: "60%", major: false }, { left: "66.67%", major: true, t: "10s" }, { left: "73.33%", major: false },
{ left: "80%", major: true, t: "12s" }, { left: "86.67%", major: false }, { left: "93.33%", major: true, t: "14s" }, { left: "100%", major: true, t: "15s" }
];
const ED_WAVE: Array<[number, number]> = [[8,4],[6,8],[3,14],[7,6],[4,12],[2,16],[6,8],[8,4],[5,10],[3,14],[7,6],[4,12],[6,8],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[2,16],[7,6],[3,14],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8],[2,16],[7,6],[4,12],[5,10],[3,14],[6,8],[8,4],[4,12],[2,16],[5,10],[7,6],[3,14],[6,8],[8,4],[4,12],[2,16],[6,8],[5,10],[3,14],[7,6],[4,12],[8,4],[6,8],[2,16],[5,10],[3,14],[7,6],[6,8],[4,12],[8,4],[5,10],[3,14],[6,8]];
function edLayout<T extends { dur: number }>(clips: T[]): Array<T & { leftPct: number; widthPct: number }> {
let acc = 0;
return clips.map((c) => { const left = acc; acc += c.dur; return { ...c, leftPct: (left / 15) * 100, widthPct: (c.dur / 15) * 100 }; });
}
const STAGE_STEPS: Array<{ n: number; label: string }> = [
{ n: 1, label: "脚本" },
{ n: 2, label: "基础资产" },
{ n: 3, label: "故事板" },
{ n: 4, label: "视频" },
{ n: 5, label: "拼接导出" }
];
export function PipelinePage(props: {
project: Project;
loading: boolean;
navigate: (page: Page, options?: { projectId?: string; productId?: string }) => void;
user: User;
team: Team;
products: Product[];
projects: Project[];
billing: BillingSummary | null;
notice: Notice | null;
avatarChar: string;
logout: () => void;
onRefresh: () => void;
onGenerateScript: (prompt: string) => void;
onAdoptScript: (scriptId: string) => void;
@ -33,21 +78,510 @@ export function PipelinePage({
onPollAllVideos: () => void;
onSubmitExport: () => void;
}) {
const [activeStage, setActiveStage] = useState<string>(project.current_stage || "script");
const [prompt, setPrompt] = useState("突出商品卖点,节奏紧凑,适合短视频投放");
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
const {
project, loading, navigate, user, team, products, projects, billing, notice, avatarChar, logout,
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onPollVideo, onSubmitAllVideos, onPollAllVideos, onSubmitExport
} = props;
// 步进器:对齐镜像 activateStage 逻辑。默认(无 hash)pane=脚本(1) 但步进器 active=项目真实阶段;
// 一旦导航(hash 或点击),active 跟随所看阶段,completed=max(项目阶段-1, 所看阶段-1)。
const projectStage = project.status === "completed" ? 5 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1);
const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null;
const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1);
const [navigated, setNavigated] = useState(Boolean(initHash));
const activeDot = navigated ? viewStage : projectStage;
const completed = Math.max(projectStage - 1, activeDot - 1);
const [chatText, setChatText] = useState("");
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
// 真实商品名(api-bridge 仅 hydrate 商品名,其余人物/场景沿用设计稿 mock)
const productName = products.find((item) => item.id === project.product)?.title || "透真补水面膜";
function goStage(n: number) {
setViewStage(n);
setNavigated(true);
if (typeof location !== "undefined") location.hash = `stage-${n}`;
}
function dotCls(n: number) {
if (n === activeDot) return "sp-dot active";
if (n <= completed) return "sp-dot done";
return "sp-dot";
}
return (
<>
<div className="proj-head"><div className="hstack" style={{ gap: 14 }}><div className="placeholder" style={{ width: 42, height: 54 }}><span className="ph-frame">9:16</span></div><div><div className="hstack"><h1>{project.name}</h1><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></div><div className="muted-2 mono" style={{ fontSize: 11.5, marginTop: 4 }}>// AI 全生 · 4 段 · 60s · 9:16</div></div></div><div className="hstack"><button className="btn btn-sm" type="button" onClick={onRefresh}>刷新</button><button className="btn btn-ghost btn-sm" type="button"><Copy size={12} />复制项目</button></div></div>
<div className="stepper"><span className="corner-tr">+</span><span className="corner-bl">+</span>{stageOrder.map((stage, index) => { const projectIndex = stageOrder.indexOf(project.current_stage as never); return <FragmentStageStep key={stage} stage={stage} done={index < projectIndex} active={activeStage === stage} lineDone={index < projectIndex} isLast={index === stageOrder.length - 1} onClick={() => setActiveStage(stage)} />; })}</div>
{activeStage === "script" && <section className="stage active"><div className="stage-script"><div className="pane chat-pane"><div className="pane-h"><div className="ai-avatar">AI</div><strong></strong><span className="muted-2 mono">· Doubao</span></div><div className="chat-input"><textarea className="textarea" value={prompt} onChange={(event) => setPrompt(event.target.value)} /><div className="hstack" style={{ marginTop: 8 }}><span className="spacer" /><button className="btn btn-primary" type="button" disabled={loading} onClick={() => onGenerateScript(prompt)}> </button></div></div></div><div className="pane shot-list"><div className="pane-h"><strong> / </strong><span className="spacer" /><span className="muted-2 mono">{project.script_versions.length} versions</span></div>{project.script_versions.map((script) => <article className={`shot-card ${script.is_adopted ? "highlight" : ""}`} key={script.id}><div className="shot-row"><div className="shot-k">TXT</div><div className="shot-v">{script.content}</div></div><button className="btn btn-sm" type="button" onClick={() => onAdoptScript(script.id)}></button></article>)}</div></div></section>}
{activeStage === "base_assets" && <section className="stage active"><div className="asset-grid-2">{(["product", "person", "scene"] as const).map((kind) => <article className="asset-card-2" key={kind}><div className="placeholder thumb-2"><span className="ph-frame">{kind}</span></div><div className="body-2"><strong>{kind}</strong><div className="prompt-box">{project.name} {kind} 16:9 </div><button className="btn btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, `${project.name} ${kind} 16:9 基础资产`)}> {kind}</button></div></article>)}</div></section>}
{activeStage === "storyboard" && <section className="stage active"><div className="stage-storyboard"><div className="sb-canvas">{[0, 1, 2, 3].map((index) => <div className="sb-row" key={index}><div className="sb-num">{String(index + 1).padStart(2, "0")}<span className="t">{index * 15}-{(index + 1) * 15}s</span></div><div className="sb-img"><div className="placeholder"><span className="ph-frame">16:9</span></div></div><div className="sb-text"><div className="meta">PROMPT</div><div className="dialog"></div></div></div>)}</div><div className="sb-side"><div className="pane"><textarea className="textarea" value={storyboardPrompt} onChange={(event) => setStoryboardPrompt(event.target.value)} /><div className="hstack"><button className="btn btn-primary" type="button" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}></button><button className="btn" type="button" disabled={loading} onClick={onSkipStoryboard}></button></div></div></div></div></section>}
{activeStage === "video" && <section className="stage active"><div className="queue-bar"><span className="mono">[ VIDEO QUEUE ]</span><div className="bar-wrap"><span style={{ width: `${(project.video_segments.filter((s) => s.status === "succeeded").length / 4) * 100}%` }} /></div><span className="mono">{project.video_segments.filter((s) => s.status === "succeeded").length}/4</span></div><div className="field"><textarea className="textarea" value={videoPrompt} onChange={(event) => setVideoPrompt(event.target.value)} /></div><div className="hstack" style={{ marginBottom: 14 }}><button className="btn btn-primary" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}> 60s</button><button className="btn" type="button" disabled={loading} onClick={onPollAllVideos}></button></div><div className="video-grid">{project.video_segments.map((segment) => <article className="video-card" key={segment.id}><div className="placeholder video-thumb"><span className="ph-frame">SEG {segment.sort_order + 1}</span><div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div></div><div className="body"><div className="hstack"><strong>{segment.target_duration_seconds}s</strong><span className="spacer" /><span className={`pill ${statusPill(segment.status)}`}><span className="dot" />{segment.status}</span></div><div className="hstack" style={{ marginTop: 8 }}><button className="btn btn-sm" type="button" disabled={loading || segment.status === "running"} onClick={() => onSubmitVideo(segment.id, `${videoPrompt}${segment.sort_order + 1}`)}></button><button className="btn btn-sm" type="button" onClick={() => onPollVideo(segment.id)}></button></div></div></article>)}</div></section>}
{activeStage === "export" && <section className="stage active"><div className="editor"><div className="editor-preview"><div className="canvas">9:16 / 1080×1920</div></div><div className="editor-props"><button className="btn btn-primary" type="button" disabled={loading || !canExport} onClick={onSubmitExport}></button></div></div></section>}
</>
<div className="app pipeline-page">
<Sidebar page="pipeline" navigate={navigate} user={user} team={team} products={products} projects={projects} />
<main>
<Decorations />
<header className="topbar">
<div className="pipeline-topbar-left">
<a className="btn btn-ghost pipeline-back" href="/projects" onClick={(event) => { event.preventDefault(); navigate("projects"); }} aria-label="返回视频项目">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M19 12H5" /><path d="M12 19l-7-7 7-7" /></svg>
</a>
<div className="pipeline-topbar-title" title={project.name}>
{project.name}<span className="mono">// PIPELINE</span>
</div>
</div>
<div className="right">
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
<span className="count-noti">12</span>
</button>
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
<span>{avatarChar}</span>
</div>
</div>
<div className="stage-pill" id="stage-pill">
{STAGE_STEPS.map((step, index) => (
<Fragment key={step.n}>
<a className={dotCls(step.n)} data-stage={step.n} href={`#stage-${step.n}`} onClick={(event) => { event.preventDefault(); goStage(step.n); }}>
<span className="d"></span><span className="l">{step.label}</span>
</a>
{index < STAGE_STEPS.length - 1 && <span className={`sp-line${step.n <= completed ? " done" : ""}`}></span>}
</Fragment>
))}
</div>
</header>
<div className="content content--fh content--fh-flat" id="page-content">
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{/* ============= STAGE 1 · 脚本 ============= */}
<section className={`stage${viewStage === 1 ? " active" : ""}`} data-stage-pane="1">
<div className="stage-script">
<div className="pane shot-list">
<div className="pane-h">
<div className="shot-headline">
<strong></strong>
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>· · </span>
</div>
<div className="script-brief-summary" aria-label="当前创作方向">
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source"></span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-style"></span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona"></span></span>
</div>
<div className="script-tags" id="script-tags">
<div className="tag-group" data-kind="char">
<span className="tg-lbl">// 人物</span>
<button className="tag-add" type="button" aria-label="添加人物">+</button>
</div>
<div className="tag-group" data-kind="scene">
<span className="tg-lbl">// 场景</span>
<button className="tag-add" type="button" aria-label="添加场景">+</button>
</div>
</div>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn"> </button>
</div>
<div className="shots-body" id="shots-body">
<div className="shots-empty">
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
<div className="empty-title"></div>
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
</div>
</div>
</div>
<div className="stage-script-gutter" id="stage-script-gutter" role="separator" aria-orientation="vertical" aria-label="拖动调整脚本助手宽度"></div>
<div className="pane chat-pane">
<div className="pane-h">
<div className="ai-avatar">AI</div>
<strong></strong>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>· GPT-4o</span>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn"></button>
</div>
<div className="chat-body" id="chat-body">
<div className="chat-empty">
<div className="ce-title"></div>
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
<div className="chat-modes">
<button className="chat-mode primary" type="button" data-mode="ai" disabled={loading} onClick={() => onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI </button>
<button className="chat-mode" type="button" data-mode="theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg></button>
<button className="chat-mode" type="button" data-mode="manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg></button>
</div>
</div>
</div>
<div className="chat-input">
<div className="chat-input-card">
<div className="chat-attach-row" id="chat-attach-row" hidden></div>
<textarea className="chat-input-area" id="chat-textarea" placeholder="直接说怎么改,如:更像小红书种草 / 换成熬夜党" rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
<div className="chat-input-foot">
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); setChatText(""); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ LLM ~2.4k tokens · ¥0.04 · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(2)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
{/* Stage 2-5 · 暂沿用既有功能性结构(默认隐藏),后续逐阶段做像素还原 */}
{viewStage === 2 && (
<section className="stage active" data-stage-pane="2">
<div className="stage-assets">
<div className="asset-side">
<div className="ttab active" data-jump="asset-sec-products"><span></span><span className="num">3 </span></div>
<div className="ttab" data-jump="asset-sec-characters"><span></span><span className="num">2/2</span></div>
<div className="ttab" data-jump="asset-sec-scenes"><span></span><span className="num">3/3</span></div>
<div className="info">
,
<br /><br />
<strong className="mono">// 人物 +¥0.20/张</strong>
<strong className="mono">// 场景 +¥0.15/张</strong>
<span style={{ color: "var(--black-alpha-48)" }}>()</span>
</div>
</div>
<div className="asset-main">
<section className="asset-sec" id="asset-sec-products">
<div className="sec-h"><h3> · <span id="asset-prod-name">{productName}</span></h3><span className="spacer"></span></div>
<div className="prod-row">
<div className="asset-card-2 prod-lib-card" data-asset-kind="product" data-asset-id="prod-main" id="asset-prod-card">
<div className="placeholder prod-thumb has-mock-media" style={mock("product-mask.png")}>
<span className="tri-missing-badge" id="asset-prod-tri-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明">
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono"></span>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body"> <b> / / </b> ,,姿</span>
<span className="pop-tip">建议:点右下 <b>AI </b> ,</span>
</span>
</span>
<span className="ph-frame" id="asset-prod-thumb-label">{productName} · </span>
</div>
<div className="prod-body">
<div className="prod-name" id="asset-prod-card-name">{productName}</div>
<div className="prod-cat"></div>
<div className="prod-date">2026-05-15 </div>
</div>
<div className="prod-action" id="asset-prod-action">
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
AI
</button>
</div>
</div>
<div className="prod-preview" id="asset-prod-preview">
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">生成中</span></div>
<div className="placeholder prod-preview-img" id="prod-preview-img"></div>
<div className="prod-preview-foot" id="prod-preview-foot"></div>
</div>
</div>
</section>
<section className="asset-sec" id="asset-sec-characters">
<div className="sec-h"><h3> · 2 </h3><span className="spacer"></span></div>
<div className="asset-grid-2">
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-linxi">
<div className="placeholder thumb-2 has-mock-media" style={mock("person-linxi.png")}><span className="ph-frame"> · </span></div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}> · </strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 ,,穿,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
<div className="asset-card-2" data-asset-kind="character" data-asset-id="ch-anan">
<div className="placeholder thumb-2">
<div style={{ display: "flex", flexDirection: "column", gap: "8px", alignItems: "center" }}>
<div className="spinner"></div>
<span className="ph-frame"> · 8s</span>
</div>
</div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}>/ · </strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>25-30 ,,穿,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop disabled></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop disabled></button></div>
</div>
</div>
</div>
</section>
<section className="asset-sec" id="asset-sec-scenes">
<div className="sec-h"><h3> · 3 </h3><span className="spacer"></span></div>
<div className="asset-grid-2">
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-desk">
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-office.png")}><span className="ph-frame"></span></div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}></strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>,,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-bed">
<div className="placeholder thumb-2 has-mock-media" style={mock("scene-bedroom.png")}><span className="ph-frame"></span></div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}></strong><span className="spacer"></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
<div className="asset-card-2" data-asset-kind="scene" data-asset-id="sc-subway">
<div className="placeholder thumb-2">
<div style={{ display: "flex", flexDirection: "column", gap: "6px", alignItems: "center" }}>
<div className="fail-icon">!</div>
<span className="ph-frame"></span>
</div>
</div>
<div className="body-2">
<div className="hstack"><strong style={{ fontSize: "13.5px" }}></strong><span className="spacer"></span><span className="pill err"><span className="dot"></span></span></div>
<div className="prompt-box" contentEditable suppressContentEditableWarning spellCheck={false} data-stop>,线,,</div>
<div className="hstack" style={{ marginTop: "10px" }}><button className="btn btn-ghost btn-sm" data-stop></button><span className="spacer"></span><button className="btn btn-ghost btn-sm" data-stop></button></div>
</div>
</div>
</div>
</section>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ ¥0.85 · ¥0.20 · ¥0() ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(1)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
)}
{viewStage === 3 && (
<section className="stage active" data-stage-pane="3">
<div className="stage-storyboard">
<div className="sb-canvas">
<div className="sb-scenes-col" id="sb-scenes-row">
{SB_SCENES.map((scene) => (
<div className={`sb-scene-thumb${scene.sid === "sc1" ? " selected" : ""}`} key={scene.sid} data-sid={scene.sid}>
<div className="placeholder has-mock-media" style={mock(scene.img)}><span className="ph-frame">{scene.frame}</span></div>
<div className="nm">{scene.nm}</div>
<div className="sub">{scene.sub}</div>
</div>
))}
</div>
<div className="placeholder sb-main-img has-mock-media" id="sb-main-img" style={mock("cover-mask-v3.png")}><span className="ph-frame"> 1 · · v1</span></div>
</div>
<div className="sb-side">
<div className="pane" style={{ padding: "18px" }}>
<div className="hstack" style={{ marginBottom: "10px" }}>
<strong style={{ fontSize: "14px" }}> · <span id="sb-side-scene"> 1</span></strong>
<span className="spacer"></span>
<span className="pill ok"><span className="dot"></span></span>
</div>
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}> image-2 , + </div>
<div className="sb-rerun-note">
<span className="warn-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /><path d="M12 9v4M12 17h.01" /></svg>
</span>
<div className="note-copy"><strong></strong> · , <a href="#stage-1" onClick={(event) => { event.preventDefault(); goStage(1); }}>Stage 1 </a> ,</div>
</div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>// 本场提示词</div>
<div className="prompt-edit" contentEditable suppressContentEditableWarning id="sb-prompt-edit">{SB_PROMPT}</div>
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
</button>
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/</span>
</div>
<div className="sb-history">
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">1</span>)</div>
<div className="sb-history-row" id="sb-history-row">
<div className="sb-history-thumb current" data-vi="0">
<div className="placeholder has-mock-media" style={mock("cover-mask-final.png")}><span className="ph-frame">v1</span></div>
<div className="ts">14:02</div>
</div>
</div>
</div>
<div className="divider" style={{ marginTop: "16px" }}></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 绑定的资产</div>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }} id="sb-bound-assets">
<span className="asset-tag"><span className="dotc"></span>()</span>
<span className="asset-tag"><span className="dotc"></span>()</span>
</div>
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ image-2 ¥0.45 · ¥1.35 · , ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(2)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(4)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
)}
{viewStage === 4 && (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
<div>
<div style={{ fontSize: "14px", fontWeight: 600 }}> · 3 / 3 </div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 <span id="seedance-avg">15</span> 秒 · 已完成所有场次</div>
</div>
<div className="bar-wrap"><span style={{ width: "100%" }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>100%</span>
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => onSubmitAllVideos(videoPrompt)}> </button>
<button className="btn btn-sm" type="button">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
</button>
</div>
<div className="video-grid" id="video-grid">
{VIDEO_CARDS.map((card) => (
<div className="video-card" key={card.vid} data-video-id={card.vid}>
<div className="placeholder video-thumb has-mock-media" style={mock(card.img)}>
<span className="ph-frame">{card.frame}</span>
<div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>
</div>
<div className="body">
<div className="video-card-head"><strong className="video-card-title">{card.title}</strong><span className="pill ok"><span className="dot"></span></span></div>
<div className="video-meta">{card.meta}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop></button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-vstop></button>
</div>
</div>
</div>
))}
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ 3 · ¥1.35 · <span id="seedance-total">40</span>s · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(3)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(5)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
)}
{viewStage === 5 && (
<section className="stage active" data-stage-pane="5">
<div className="editor">
<div className="editor-preview">
<div className="canvas has-mock-media" id="ed-canvas" style={{ backgroundImage: "url(/exact/assets/mock/cover-mask-final.png)" }}><span id="ed-canvas-label">9:16 · 1080×1920</span></div>
<div className="controls">
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">00:00.00</span> / <span id="ed-total-time">00:15.00</span></span>
</div>
</div>
<div className="editor-props">
<div className="props-tabs"><div className="active"></div><div></div><div>BGM</div></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式</div>
<div className="style-swatch">
<div className="swatch-card selected"><div className="demo"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo b"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo c"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo d"></div><div className="nm"></div></div>
</div>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 当前选中(<span id="ed-inspect-name">未选</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-start" defaultValue="—" /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-dur" defaultValue="—" /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue="100" /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue="1.0x" /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}></span></div>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}> · 0:42</span><button className="btn btn-ghost btn-sm" type="button"></button></div>
</div>
<div className="timeline" id="ed-timeline">
<div className="tl-toolbar">
<button className="tl-action" type="button" title="撤销"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
<button className="tl-action" type="button" title="重做"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
<span className="tl-sep"></span>
<button className="tl-action" type="button" title="在播放头处分割选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg></button>
<button className="tl-action" type="button" title="复制选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg></button>
<button className="tl-action danger" type="button" title="删除选中片段 (Delete)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg></button>
<span className="spacer"></span>
<div className="tl-zoom"><span className="lbl">// zoom</span><input type="range" min={50} max={200} defaultValue={100} /></div>
</div>
<div className="tl-ruler">
<div className="l">// time</div>
<div className="rule-track" id="ed-ruler">
{ED_RULER.map((tick, i) => (
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: tick.left }}>{tick.t && <span className="t">{tick.t}</span>}</span>
))}
</div>
</div>
<div className="tl-track video-track">
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span></div>
<div className="lane" id="ed-lane-video" data-track="video">
{edLayout(ED_VIDEO_CLIPS).map((clip) => (
<div className="clip video" key={clip.n} data-track="video" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}>
<span className="frames">{Array.from({ length: clip.dur + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
<span className="num">{clip.n}</span><span className="lbl">{clip.lbl}</span>
</div>
))}
</div>
</div>
<div className="tl-track subtitle-track">
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span></div>
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
{edLayout(ED_SUB_CLIPS).map((clip, i) => (
<div className="clip subtitle" key={i} data-track="subtitle" data-label={clip.lbl} style={{ left: `${clip.leftPct}%`, width: `${clip.widthPct}%` }}><span className="lbl">{clip.lbl}</span></div>
))}
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
</div>
</div>
<div className="tl-track bgm-track">
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
<div className="lane">
<div className="clip bgm" data-track="bgm" data-label="温柔治愈钢琴" style={{ left: "0%", width: "100%" }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl"> · 0:42( 1 ,)</span>
</div>
</div>
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ ~30s · / 0 token · ¥1.39 ]</span></div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn" type="button">稿</button>
<button className="btn btn-primary btn-lg" type="button" onClick={onSubmitExport}> MP4 · 1080P 9:16 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg></button>
</div>
</div>
</section>
)}
</div>
</main>
</div>
);
}

View File

@ -1,10 +1,9 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { ArrowLeft, Film, ImageIcon, Plus, Search, Settings, Trash2, Upload, Users, WandSparkles } from "lucide-react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowLeft, Upload } from "lucide-react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import { ConfirmModal, Drawer } from "../components/overlays";
import { Drawer } from "../components/overlays";
type ProductPayload = {
title?: string;
@ -16,17 +15,35 @@ type ProductPayload = {
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
};
// 复刻 mock-media productFor:按商品名关键词映射商品图
function productCover(name: string): string {
const t = name.replace(/\s+/g, "");
if (/蓝牙|耳机|南卡/.test(t)) return "product-earbuds.png";
if (/速食|牛肉面|泡面|面条/.test(t)) return "product-noodle.png";
if (/防晒/.test(t)) return "product-sunscreen.png";
if (/咖啡|冻干/.test(t)) return "product-coffee.png";
if (/空气炸锅|小熊/.test(t)) return "product-air-fryer.png";
if (/瑜伽裤|露露/.test(t)) return "product-yoga-pants.png";
if (/收纳|北欧/.test(t)) return "product-storage.png";
if (/面膜|补水|玻尿酸|透真/.test(t)) return "product-mask.png";
return "";
}
const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
export function ProductsPage({ products, navigate, openProduct, onCreate }: {
products: Product[];
navigate: (page: Page) => void;
openProduct: (productId: string) => void;
onCreate: (payload: ProductPayload) => Promise<unknown> | void;
}) {
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
const [title, setTitle] = useState("");
const [brand, setBrand] = useState("");
const [point, setPoint] = useState("");
const filtered = products.filter((product) => `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()));
function submit(event: FormEvent) {
event.preventDefault();
onCreate({
@ -44,16 +61,45 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
}
return (
<>
<section className="products-page">
<div className="page-head">
<div><h1></h1><div className="sub"><span className="mono">// {products.length} SKU</span> · 商品信息会作为脚本和资产生成素材</div></div>
<div className="actions"><button className="btn" type="button" onClick={() => navigate("productCreateUpload")}><Upload size={13} /></button><button className="btn btn-primary" type="button" onClick={() => setDrawer(true)}><Plus size={13} /></button></div>
<div>
<h1></h1>
<div className="sub"><span className="mono">// <span id="sku-count">{products.length}</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
</div>
<div className="actions">
<button className="btn btn-edit-toggle" type="button" id="edit-toggle-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="btn-edit-label"></span>
</button>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
</button>
</div>
</div>
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索商品名称、品牌" /></div><button className="chip active"> <span className="mono">{products.length}</span></button><button className="chip"></button><button className="chip"> 3C</button></div>
<div className="product-grid">
{products.map((product) => <ProductCard key={product.id} product={product} onOpen={() => openProduct(product.id)} />)}
<button className="product-card add" type="button" onClick={() => navigate("productCreateUpload")}><div className="plus-ic"><Plus size={18} /></div><div></div></button>
<div className="products-main">
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="chip-wrap" data-key="cat"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="date"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
</div>
<div className="result-meta" id="result-meta">
<span>// 显示 <span className="count">{filtered.length}</span> / {products.length} 个商品</span>
</div>
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{filtered.map((product) => <ProductCard key={product.id} product={product} onOpen={() => openProduct(product.id)} />)}
</div>
</div>
</div>
<Drawer title="新建商品" open={drawer} close={() => setDrawer(false)}>
<form onSubmit={submit}>
<div className="field"><label className="field-label"><span className="req">*</span></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} required /></div>
@ -62,16 +108,35 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit"></button></div>
</form>
</Drawer>
</>
</section>
);
}
export function ProductCard({ product, onOpen }: { product: Product; onOpen: () => void }) {
const cover = productCover(product.title);
const assetCount = product.images?.length || 0;
return (
<article className="product-card" role="button" tabIndex={0} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<div className="placeholder product-thumb"><span className="ph-frame">{product.title} · 1200×800</span></div>
<div className="product-body"><div className="product-name">{product.title}</div><div className="product-meta">{product.category || "未分类"} · {product.selling_points.length} </div><div className="product-tags">{(product.selling_points.length ? product.selling_points : [{ title: product.brand || "品牌" }]).slice(0, 3).map((tag) => <span className="tag-chip" key={tag.title}>{tag.title}</span>)}</div></div>
</article>
<div className="product-card" data-cat={product.category} data-name={product.title} role="button" tabIndex={0} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => event.stopPropagation()}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
<div className={`placeholder product-thumb${cover ? " has-mock-media" : ""}`} style={cover ? prodMock(cover) : undefined}><span className="ph-frame">{product.title} · 1200×800</span></div>
<div className="product-body">
<div className="product-name">{product.title}</div>
<div className="product-cat">{product.category || "未分类"}</div>
<div className="product-date">{(product.created_at || "").slice(0, 10)} </div>
</div>
<div className="product-footer">
<span className="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="10" r="2" /><path d="M21 17l-5-5-9 9" /></svg>
<b>{assetCount}</b>
</span>
<span className="sep">·</span>
<span className="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg>
<b>0</b>
</span>
</div>
</div>
);
}
@ -100,26 +165,300 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
);
}
export function ProductDetailPage({ product, projects, navigate, onUpdate, onDelete }: {
// 商品详情页 · 从 public/exact/product-detail.html 忠实转写。
// 真实数据仅注入 api-bridge renderProductDetail 实际 hydrate 的 4 个字段
// (名称 / 品类 / 目标人群 / 卖点),其余 图片/素材/项目 网格沿用设计稿镜像的
// 静态占位(api-bridge 在 ?product_id 加载时同样保留 mock,故像素对齐)。
const PD_ASSETS: Array<{ type: string; status: "pass" | "fail" | "archive" }> = [
{ type: "模特上身图", status: "pass" },
{ type: "模特上身图", status: "pass" },
{ type: "模特上身图", status: "fail" },
{ type: "模特上身图", status: "pass" },
{ type: "模特上身图", status: "archive" },
{ type: "平台套图", status: "pass" },
{ type: "平台套图", status: "pass" },
{ type: "平台套图", status: "fail" },
{ type: "平台套图", status: "archive" },
{ type: "平台套图", status: "pass" },
{ type: "三视图", status: "pass" },
{ type: "三视图", status: "archive" }
];
const PD_ASSET_STATUS_LABEL: Record<"pass" | "fail" | "archive", string> = { pass: "通过", fail: "不通过", archive: "归档" };
const PD_VIDEOS: Array<{ proj: string; pill: string; ver: string; label: string; date: string }> = [
{ proj: "done", pill: "ok", ver: "补水面膜 · v3", label: "已完成", date: "2026-05-20 12:08" },
{ proj: "wip", pill: "info", ver: "补水面膜 · v2", label: "视频生成 4/6", date: "2026-05-19 10:24" },
{ proj: "archived", pill: "neutral", ver: "熬夜急救 · v1", label: "已归档", date: "2026-05-18 21:42" },
{ proj: "fail", pill: "err", ver: "补水面膜 · v1", label: "故事板失败", date: "2026-05-17 16:00" }
];
const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
export function ProductDetailPage({ product, navigate, onUpdate }: {
product: Product;
projects: Project[];
navigate: (page: Page) => void;
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
onDelete: () => Promise<unknown> | void;
}) {
const [tab, setTab] = useState<"assets" | "videos">("assets");
const [editing, setEditing] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [title, setTitle] = useState(product.title);
const [description, setDescription] = useState(product.description || "");
const [triOpen, setTriOpen] = useState(false);
// 素材状态筛选 · 镜像默认即「通过」(api-bridge ALWAYS_APPLY status),只显示通过卡
const [assetStatus] = useState<"pass" | "fail" | "archive">("pass");
const assetCount = PD_ASSETS.filter((asset) => asset.status === assetStatus).length;
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
const realName = product.title || "补水保湿精华液";
const realCat = product.category || "美妆个护 / 精华液";
const realTarget = product.target_audience || "22-32 岁女性、敏感肌、办公室通勤";
const realBullets = product.selling_points.length
? product.selling_points.map((point) => point.title)
: ["透明质酸 + B5,敷完不黏不闷", "30g 大容量精华液", "0 香精 0 酒精,敏感肌可用"];
const [name, setName] = useState(realName);
const [cat, setCat] = useState(realCat);
const [target, setTarget] = useState(realTarget);
function save() {
void onUpdate({ title: name, category: cat, target_audience: target });
setEditing(false);
}
function cancel() {
setName(realName);
setCat(realCat);
setTarget(realTarget);
setEditing(false);
}
return (
<>
<div className="page-head"><div><h1>{product.title}</h1><div className="sub"><span className="mono">// product-detail</span> · 商品信息 / AI 素材 / 任务记录</div></div><div className="actions"><button className="btn" type="button" onClick={() => navigate("products")}><ArrowLeft size={13} />返回</button><button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}><Film size={13} />创建视频项目</button></div></div>
<div className="product-detail-layout">
<section className="ov-card ov-main card-hard"><div className="detail-hero"><div className="placeholder detail-main-img"><span className="ph-frame">{product.title} · </span></div><div className="detail-info"><div className="hstack"><span className="pill info"><span className="dot" />{product.category || "未分类"}</span><span className={`pill ${statusPill(product.status)}`}><span className="dot" />{product.status}</span><span className="spacer" /><button className="icon-btn-sm" type="button" onClick={() => setEditing(true)}><Settings size={13} /></button></div>{editing ? <><div className="field"><label className="field-label"></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} /></div><div className="field"><label className="field-label"></label><textarea className="textarea" value={description} onChange={(event) => setDescription(event.target.value)} /></div><div className="hstack"><button className="btn" type="button" onClick={() => setEditing(false)}></button><button className="btn btn-primary" type="button" onClick={() => { void onUpdate({ title, description }); setEditing(false); }}></button></div></> : <><h2>{product.title}</h2><div className="muted">{product.brand || "未填写品牌"} · {product.target_audience || "未填写目标人群"}</div><p>{product.description || "暂无商品描述。"}</p></>}</div></div><div className="detail-section"><div className="section-h"><h2></h2><span className="more">[ {product.selling_points.length} POINTS ]</span></div><div className="bullet-grid">{product.selling_points.map((point) => <div className="bullet-card" key={point.id}><strong>{point.title}</strong><span>{point.detail || point.title}</span></div>)}</div></div></section>
<aside className="detail-side"><div className="pane"><h3>AI </h3><div className="quick-actions"><button className="qa-item" type="button" onClick={() => navigate("modelPhoto")}><Users size={15} /><span></span></button><button className="qa-item" type="button" onClick={() => navigate("platformCover")}><ImageIcon size={15} /><span></span></button><button className="qa-item" type="button" onClick={() => navigate("imageOptimize")}><WandSparkles size={15} /><span></span></button></div></div><div className="pane"><h3></h3>{projects.length ? projects.map((project) => <button className="task-mini" key={project.id} type="button" onClick={() => navigate("pipeline")}><span>{project.name}</span><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></button>) : <div className="muted-2 mono">// 暂无关联视频项目</div>}</div><button className="btn danger-lite" type="button" onClick={() => setConfirmDelete(true)}><Trash2 size={13} />删除商品</button></aside>
<section className="product-detail-page">
{/* 顶部 标题 + 状态 */}
<div className="pd-title">
<h1 id="pd-name">{realName}</h1>
</div>
<ConfirmModal open={confirmDelete} title="确认删除商品" detail={`即将删除 ${product.title},已有关联项目请先确认。`} confirmText="删除商品" onCancel={() => setConfirmDelete(false)} onConfirm={onDelete} />
</>
{/* 商品信息(含图片) + 快速操作 · 主辅两栏 */}
<div className="pd-overview">
<div className={`ov-card ov-main${editing ? " editing" : ""}`} id="ov-main-card">
<div className="ov-h">
<span className="ti"></span>
{/* AI 生成三视图 · 按钮 + 弹出 panel(view 模式可见) */}
<div className="ov-tri-wrap">
<button className={`ov-edit ov-tri-trigger${triOpen ? " is-open" : ""}`} type="button" id="ov-tri-btn" title="AI 生成商品三视图" aria-haspopup="dialog" aria-expanded={triOpen} onClick={() => setTriOpen((value) => !value)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /><path d="M19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9L19 14z" /></svg>
AI
</button>
<div className={`ov-tri-pop${triOpen ? " show" : ""}`} id="ov-tri-pop" role="dialog" aria-label="AI 生成三视图">
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">待生成</span></div>
<div className="placeholder prod-preview-img" id="ov-tri-img"><span className="ph-frame">// 尚未生成 · 点击下方按钮开始</span></div>
<div className="prod-preview-foot" id="ov-tri-foot">
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
</button>
<span style={{ flex: 1 }}></span>
<span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-56)" }}>~¥0.30 / </span>
</div>
<div className="prod-preview-history" id="ov-tri-history">
<div className="h-lbl">// 历史版本 · <span className="ct" id="ov-tri-history-count">0</span> 版</div>
<div className="h-row" id="ov-tri-history-row"></div>
</div>
</div>
</div>
{/* view 模式: 单个 [编辑信息] */}
<button className="ov-edit ov-edit-single" type="button" id="ov-edit-btn" title="编辑商品信息" onClick={() => setEditing(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
</button>
{/* edit 模式: [重置] [取消] [保存] */}
<div className="ov-edit-group">
<button className="ov-edit" type="button" id="ov-reset-btn" title="重置为修改前" onClick={cancel}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
</button>
<button className="ov-edit" type="button" id="ov-cancel-btn" onClick={cancel}></button>
<button className="ov-edit primary" type="button" id="ov-save-btn" onClick={save}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
</button>
</div>
</div>
<div className="ov-main-grid">
<div className="ov-info">
<div className="row" data-field="name">
<div className="k"></div>
<div className="v">
<span className="v-static">{realName}</span>
<input className="v-edit v-input" type="text" value={name} maxLength={100} onChange={(event) => setName(event.target.value)} />
</div>
</div>
<div className="row" data-field="cat">
<div className="k"></div>
<div className="v">
<span className="v-static">{realCat}</span>
<select className="v-edit v-select" value={cat} onChange={(event) => setCat(event.target.value)}>
{PD_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
</div>
<div className="row" data-field="target">
<div className="k"></div>
<div className="v">
<span className="v-static">{realTarget}</span>
<input className="v-edit v-input" type="text" value={target} onChange={(event) => setTarget(event.target.value)} />
</div>
</div>
<div className="row" data-field="bullets">
<div className="k"></div>
<div className="v">
<div className="v-static">
{realBullets.map((bullet, index) => <span className="bullet" key={index}>{bullet}</span>)}
</div>
<ul className="v-edit v-bullet-list" id="v-bullets-list">
{realBullets.map((bullet, index) => (
<li className="bl-item" key={index}><span className="num">{index + 1}</span><span className="bl-text">{bullet}</span></li>
))}
</ul>
</div>
</div>
</div>
<div className="ov-images-sub">
<div className="sub-h">
<span className="ti"></span>
<span className="ct">(6)</span>
</div>
<div className="grid" id="ov-images-grid">
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="thumb placeholder"><span className="ph-frame">1:1</span></div>
<div className="img-upload" id="ov-img-add" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</div>
</div>
</div>
</div>
</div>
<div className="ov-card ov-actions">
<div className="ov-h"><span className="ti"></span></div>
<div className="qa-section">
<div className="qa-section-h">// 图片生成</div>
<div className="qa-row-3">
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21v-2a4 4 0 014-4h8a4 4 0 014 4v2" /></svg></span>
</div>
<div className="qa-item" data-go="platform-cover" role="button" tabIndex={0} onClick={() => navigate("platformCover")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 3v18" /></svg></span>
</div>
<div className="qa-item" data-go="image-optimize" role="button" tabIndex={0} onClick={() => navigate("imageOptimize")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v18M3 12h18M5 5l14 14M5 19l14-14" /></svg></span>
</div>
</div>
</div>
<div className="qa-section">
<div className="qa-section-h">// 视频生成</div>
<div className="qa-row-1">
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard")}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
</div>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="pd-tabs">
<button className={`tab${tab === "assets" ? " active" : ""}`} type="button" data-tab="assets" onClick={() => setTab("assets")}>AI </button>
<button className={`tab${tab === "videos" ? " active" : ""}`} type="button" data-tab="videos" onClick={() => setTab("videos")}></button>
<button className="tab" type="button" data-tab="tasks" hidden></button>
</div>
{/* ===== AI 生成素材 ===== */}
<div className={`tab-pane${tab === "assets" ? " active" : ""}`} data-pane="assets">
<div className="pd-toolbar">
<div className="total"> AI <span className="ct">({assetCount})</span></div>
<button className="filter" type="button" data-key="type">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<button className="filter" type="button" data-key="status">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="right">
<div className="view-tog">
<button type="button" className="active" title="网格视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
<button type="button" title="列表视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
</button>
</div>
<button className="filter" type="button" data-key="sort">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
</div>
</div>
<div className="asset-grid">
{PD_ASSETS.map((asset, index) => {
// 镜像 mock-media:平台套图 占位匹配 scene → scene-tabletop.png
const hasMock = asset.type === "平台套图";
return (
<div className="asset-card" key={index} style={asset.status === assetStatus ? undefined : { display: "none" }}>
<div className={`thumb placeholder${hasMock ? " has-mock-media" : ""}`} style={hasMock ? ({ "--mock-media-url": "url(/exact/assets/mock/scene-tabletop.png)" } as CSSProperties) : undefined}><span className="type-pill">{asset.type}</span><span className="ph-frame">3:4</span></div>
<div className="meta"><span className={`pill ${asset.status}`} data-status={asset.status} title="点击切换状态">{PD_ASSET_STATUS_LABEL[asset.status]}</span><span className="date">2026-05-19 15:30</span></div>
</div>
);
})}
</div>
<div className="pd-more"><button type="button"></button></div>
</div>
{/* ===== 视频项目 ===== */}
<div className={`tab-pane${tab === "videos" ? " active" : ""}`} data-pane="videos">
<div className="pd-toolbar">
<div className="total"> <span className="ct">(4)</span></div>
<div className="right">
<button className="filter" type="button" data-key="sort">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
</div>
</div>
<div className="asset-grid">
{PD_VIDEOS.map((video, index) => (
<div className="asset-card" data-proj-status={video.proj} key={index}>
<div className="thumb placeholder" style={{ aspectRatio: "9/16" }}><span className="type-pill"> · 9:16</span><span className="ph-frame">{video.ver}</span></div>
<div className="meta"><span className={`pill ${video.pill}`}><span className="dot"></span>{video.label}</span><span className="date">{video.date}</span></div>
</div>
))}
</div>
<div className="pd-more"><button type="button"></button></div>
</div>
</section>
);
}

View File

@ -1,11 +1,9 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { ArrowLeft, ArrowRight, Grid2X2, List, Plus, Search, Trash2 } from "lucide-react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { stageMeta, stageOrder, statusPill } from "./stage-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { Progress } from "../components/pipeline-stage";
export function ProjectWizardPage({ products, onBack, onCreate }: {
products: Product[];
@ -37,6 +35,34 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
);
}
// 对齐 api-bridge:阶段编号 / 状态分桶 / 友好标签 / pill 类
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
function projStageNo(project: Project) { return project.status === "completed" ? 5 : (PROJ_STAGE_NO[project.current_stage] || 1); }
function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
function projStatusLabel(project: Project) {
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
}
function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "fail" : "info"; }
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
// 复刻 mock-media coverFor:按项目名关键词映射封面图(无匹配 → 占位)
function projCover(name: string): string {
const t = name.replace(/\s+/g, "");
if (/蓝牙|耳机|南卡/.test(t)) return "cover-earbuds.png";
if (/速食|牛肉面|泡面|面条/.test(t)) return "cover-noodle.png";
if (/防晒/.test(t)) return "cover-sunscreen.png";
if (/咖啡|冻干/.test(t)) return "cover-coffee.png";
if (/空气炸锅|小熊/.test(t)) return "cover-air-fryer.png";
if (/瑜伽裤|露露/.test(t)) return "cover-yoga.png";
if (/v1|final|已完成|成片|敷面膜|化妆台/.test(t)) return "cover-mask-final.png";
if (/面膜|补水|玻尿酸|透真/.test(t)) return "cover-mask-v3.png";
return "";
}
const projMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
];
export function ProjectsPage({ products, projects, navigate, openPipeline, onDelete }: {
products: Product[];
projects: Project[];
@ -46,10 +72,21 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
onDelete: (projectId: string) => Promise<unknown> | void;
}) {
const [view, setView] = useState<"list" | "grid">("list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
const [query, setQuery] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const filtered = projects.filter((project) => `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase()));
const counts = {
all: projects.length,
wip: projects.filter((p) => projBucket(p) === "wip").length,
done: projects.filter((p) => projBucket(p) === "done").length,
fail: projects.filter((p) => projBucket(p) === "fail").length
};
const filtered = projects.filter((project) => {
if (tab !== "all" && projBucket(project) !== tab) return false;
return `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase());
});
async function confirmDelete() {
if (!deleteTarget) return;
@ -58,12 +95,122 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
}
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// {projects.length} 个 · {projects.filter((p) => p.status !== "completed").length} 进行中</span></div></div><div className="actions"><button className="btn btn-primary btn-lg" type="button" onClick={() => navigate("projectWizard")}><Plus size={13} />新建视频项目</button></div></div>
<div className="toolbar"><div className="search-inline"><Search size={14} /><input className="input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} /></div><span className="spacer" /><div className="view-toggle"><button className={view === "list" ? "active" : ""} type="button" onClick={() => setView("list")}><List size={13} /></button><button className={view === "grid" ? "active" : ""} type="button" onClick={() => setView("grid")}><Grid2X2 size={13} /></button></div></div>
{view === "list" ? <table className="t"><thead><tr><th></th><th></th><th></th><th>5 </th><th></th><th /></tr></thead><tbody>{filtered.map((project) => <tr key={project.id} onClick={() => openPipeline(project.id)}><td><div className="proj-name-cell"><div className="placeholder proj-thumb"><span className="ph-frame">9:16</span></div><div><div className="proj-name">{project.name}</div><div className="proj-sub">4 · 60s · AI </div></div></div></td><td>{productTitle(project.product)}</td><td>{stageMeta[project.current_stage]?.label || project.current_stage}</td><td><div className="hstack"><Progress status={project.current_stage} /><span className="muted-2 mono">{Math.max(stageOrder.indexOf(project.current_stage as never) + 1, 1)}/5</span></div></td><td><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{project.status}</span></td><td><button className="icon-btn-sm" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><Trash2 size={13} /></button></td></tr>)}</tbody></table> : <div className="project-card-grid">{filtered.map((project) => <article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}><div className="placeholder proj-card-thumb"><span className="ph-frame">9:16</span></div><div className="proj-card-body"><strong>{project.name}</strong><div className="proj-sub">{productTitle(project.product)}</div><Progress status={project.current_stage} /></div></article>)}</div>}
<section className="projects-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
</div>
<div className="actions">
<button className="btn" type="button" id="proj-manage-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="proj-manage-label"></span>
</button>
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
</button>
</div>
</div>
<div className="tabs" id="status-tabs">
{PROJ_TABS.map((t) => (
<div className={`tab${tab === t.filter ? " active" : ""}`} key={t.filter} data-filter={t.filter} onClick={() => setTab(t.filter)}>{t.label} <span className="count">{counts[t.filter]}</span></div>
))}
</div>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="chip-wrap" data-key="product"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="source"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="time"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<span className="spacer"></span>
<div className="view-toggle">
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="5" height="5" /><rect x="9" y="2" width="5" height="5" /><rect x="2" y="9" width="5" height="5" /><rect x="9" y="9" width="5" height="5" /></svg>
</button>
<button className={view === "list" ? "active" : ""} type="button" data-view="list" onClick={() => setView("list")}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 4h12M2 8h12M2 12h12" /></svg>
</button>
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {projects.length} 个项目</div>
{view === "list" ? (
<div id="list-view">
<table className="t">
<thead>
<tr>
<th style={{ width: "32%" }}></th>
<th></th>
<th></th>
<th style={{ width: "200px" }}></th>
<th></th>
<th style={{ width: "120px" }}></th>
<th style={{ width: "60px" }}></th>
</tr>
</thead>
<tbody id="list-tbody">
{filtered.map((project) => {
const no = projStageNo(project);
const shots = project.video_segments.length || 4;
const cover = projCover(project.name);
return (
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} onClick={() => openPipeline(project.id)}>
<td>
<div className="proj-name-cell">
<div className={`placeholder proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div><div className="proj-name">{project.name}</div><div className="proj-sub">{shots} · 0-60s</div></div>
</div>
</td>
<td>{productTitle(project.product)}</td>
<td><span className="muted">AI </span></td>
<td>
<div className="hstack">
<div className="prog">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={i < no ? "done" : i === no ? "cur" : ""} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/5</span>
</div>
</td>
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
<td className="muted-2">{projDate(project)}</td>
<td>
<div className="row-action">
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
<span className="row-more" onClick={(event) => event.stopPropagation()}>
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button></div>
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="proj-grid">{filtered.map((project) => {
const cover = projCover(project.name);
return (
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
<div className={`placeholder card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? projMock(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="card-body">
<div className="card-name">{project.name}</div>
<div className="card-sub">{productTitle(project.product)}</div>
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
</div>
</article>
);
})}</div>
)}
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
</>
</section>
);
}

View File

@ -1,20 +1,214 @@
import { useState } from "react";
import { CircleDollarSign, KeyRound, Settings, UserPlus } from "lucide-react";
import type { Team, TeamMember, User } from "../types";
import { CircleDollarSign, UserPlus } from "lucide-react";
import type { BillingSummary, Team, TeamMember, User } from "../types";
import type { Page } from "./route-config";
import { money, statusPill } from "./stage-config";
import { money } from "./stage-config";
import { TeamModal } from "../components/overlays";
export function TeamPage({ team, user, members, navigate }: { team: Team; user: User; members: TeamMember[]; navigate: (page: Page) => void }) {
// 角色 → pill key/label(对齐 api-bridge roleUi)
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
if (role === "owner" || role === "super") return { key: "super", label: "超管" };
if (role === "admin") return { key: "admin", label: "团管" };
if (role === "viewer") return { key: "member", label: "访客" };
return { key: "member", label: "成员" };
}
const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: boolean }> = [
{ cap: "邀请 / 移除成员", cells: ["✓", "✓", "—"] },
{ cap: "设置成员额度", cells: ["✓", "✓", "—"] },
{ cap: "团队充值", cells: ["✓", "—", "—"] },
{ cap: "设置月限额", cells: ["✓", "—", "—"] },
{ cap: "编辑别人项目", cells: ["✓", "✓", "—"] },
{ cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] },
{ cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] },
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
];
export function TeamPage({ team, user, members, billing, navigate }: {
team: Team;
user: User;
members: TeamMember[];
billing: BillingSummary | null;
navigate: (page: Page) => void;
}) {
const [modal, setModal] = useState<"" | "invite" | "limit">("");
const rows = members.length ? members : [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "3000.00", user } as TeamMember];
const [search, setSearch] = useState("");
const rows: TeamMember[] = members.length
? members
: [{ id: "owner", role: "owner", status: "active", monthly_credit_limit: "0", user } as TeamMember];
// 统计 · 对齐 api-bridge renderLiveTeamPayload
const balance = Number(billing?.account.balance || 0);
const used = Number(billing?.charged_total || 0);
const limit = rows.reduce((sum, member) => sum + Math.max(0, Number(member.monthly_credit_limit || 0)), 0) || balance;
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
const needle = search.trim().toLowerCase();
const list = rows.filter((member) => {
const name = member.user.username || "";
const email = member.user.email || "";
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
});
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div></div><button className="btn btn-primary" type="button" onClick={() => setModal("invite")}><UserPlus size={13} />创建账户</button></div>
<div className="team-top"><div className="team-banner"><span className="corner-tr">+</span><span className="corner-bl">+</span><div className="banner-head"><div className="banner-id"><div className="lbl">[ TEAM ]</div><div className="nm">{team.name} <span className="tag"></span></div><div className="meta">// 团队 ID: {team.id.slice(0, 8)} · {rows.length} 名成员</div></div><div className="banner-actions"><button className="btn btn-sm" type="button" onClick={() => navigate("account")}>充值</button><button className="btn btn-ghost btn-sm" type="button" onClick={() => setModal("limit")}>设置月限额</button></div></div></div></div>
<div className="team-grid"><div className="pane" style={{ padding: 0 }}><div className="pane-table-h"><h3> <span className="ct">// {rows.length} 人</span></h3><input className="input" placeholder="搜索姓名 / 手机号" /></div><table className="t members-table"><thead><tr><th>成员</th><th>角色</th><th>月度额度</th><th>状态</th><th /></tr></thead><tbody>{rows.map((member) => <tr key={member.id}><td><div className="proj-name-cell"><div className="av">{member.user.username.slice(0, 1).toUpperCase()}</div><div><div className="proj-name">{member.user.username}</div><div className="proj-sub">{member.user.email || "未填写邮箱"}</div></div></div></td><td><span className="pill info"><span className="dot" />{member.role}</span></td><td>{money(member.monthly_credit_limit)}</td><td><span className={`pill ${statusPill(member.status)}`}><span className="dot" />{member.status}</span></td><td><button className="icon-btn-sm" type="button"><Settings size={13} /></button><button className="icon-btn-sm" type="button"><KeyRound size={13} /></button></td></tr>)}</tbody></table></div></div>
<section className="team-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" id="open-invite" onClick={() => setModal("invite")}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6M22 11h-6" /></svg>
</button>
</div>
</div>
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
<div className="team-top">
<div className="team-banner">
<span className="corner-tr" aria-hidden></span><span className="corner-bl" aria-hidden></span>
<div className="banner-head">
<div className="banner-id">
<div className="lbl">[ TEAM ]</div>
<div className="nm">{team.name} <span className="tag"></span></div>
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
</div>
<div className="banner-actions">
<button className="btn btn-sm" type="button" onClick={() => navigate("account")}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={() => setModal("limit")}></button>
</div>
</div>
<div className="banner-divider"></div>
<div className="banner-stats">
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v">{money(balance)}</div>
<div className="sub">// 团队总池</div>
</div>
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v" id="stat-limit">{money(limit)}</div>
<div className="sub">// 自然月重置</div>
</div>
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v" id="stat-used">{money(used)}</div>
<div className="sub" id="stat-used-sub">// 占月限 {pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v warn" id="stat-left">{money(left)}</div>
<div className="sub" id="stat-left-sub">// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</div>
</div>
</div>
</div>
{/* 团队动态(banner 右栏)· 对齐 api-bridge renderLiveTeamActivity 的真实状态占位 */}
<div className="team-feed">
<div className="h">
<h3></h3>
<span className="ct">// 真实动态接口待接入</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0}> </a>
</div>
<div className="feed-list">
<div className="feed-item">
<div className="av">Q</div>
<div>
<div className="txt"><span className="who"></span><span className="act"></span><span className="obj">{rows.length} </span></div>
<div className="ts">local cache</div>
</div>
</div>
</div>
</div>
</div>{/* /.team-top */}
<div className="team-grid">
{/* 左:成员表 */}
<div>
<div className="pane" style={{ padding: 0 }}>
<div style={{ display: "flex", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid var(--border-faint)" }}>
<h3 style={{ margin: 0 }}> <span className="ct">// {list.length} / {rows.length} 人 · 真实团队表</span></h3>
<span className="spacer"></span>
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th style={{ width: "140px" }}></th>
<th style={{ textAlign: "right", width: "88px" }}></th>
</tr>
</thead>
<tbody id="members-tbody">
{list.map((member) => {
const name = member.user.username || member.user.email || "成员";
const role = roleUi(member.role);
const monthly = Number(member.monthly_credit_limit || 0);
const memberPct = monthly > 0 ? Math.min(100, (0 / monthly) * 100) : 0;
const isOwner = member.role === "owner";
return (
<tr key={member.id} data-id={member.id}>
<td><span className="member-cell"><span className="av">{name.slice(0, 1).toUpperCase()}</span><span><span className="nm">{name}</span><span className="em">{member.user.email || ""}</span></span></span></td>
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
<td><span className="quota-cell"><span className="v"></span></span></td>
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
<td><div className="quota-cell"><span className="v">¥0.00</span> <span className="lbl">/ {memberPct.toFixed(0)}%</span></div><div className="used-bar"><span className="ok" style={{ width: `${memberPct.toFixed(0)}%` }}></span></div></td>
<td><div className="acts">{isOwner
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "10.5px", color: "var(--black-alpha-32)", alignSelf: "center" }}></span>
: <>
<button className="icon-btn-sm" type="button" title="编辑"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
<button className="icon-btn-sm" type="button" title="重置密码"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
<button className="icon-btn-sm danger" type="button" title="移出"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
</>}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* 右:权限矩阵 */}
<div>
<div className="pane">
<h3></h3>
<div style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "-10px", marginBottom: "12px", fontFamily: "var(--font-mono)", letterSpacing: ".02em" }}>// PRD §10.2 权限矩阵节选</div>
<table className="perm-table">
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{PERM_ROWS.map((row) => (
<tr key={row.cap} style={row.last ? { borderBottom: 0 } : undefined}>
<td>{row.cap}</td>
{row.cells.map((cell, index) => (
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<TeamModal open={modal === "limit"} title="设置月限额" subtitle="// 自然月重置 · 仅超管可改" icon={<CircleDollarSign size={16} />} close={() => setModal("")}><div className="field"><label className="field-label"> ¥</label><input className="input" defaultValue="3000" /></div></TeamModal>
<TeamModal open={modal === "invite"} title="创建账户" subtitle="// 直接生成账号 · 分享给成员登录" icon={<UserPlus size={16} />} close={() => setModal("")}><div className="field"><label className="field-label"></label><input className="input" defaultValue="zhang.yunying" /></div><div className="field"><label className="field-label"></label><input className="input mono" defaultValue="AirShelf2026" /></div></TeamModal>
</>
</section>
);
}

View File

@ -1242,126 +1242,10 @@ nav button svg { width: 14px; height: 14px; opacity: .85; }
.result-meta .count { color: var(--orange); font-weight: 600; }
/* Pipeline */
.proj-head { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 22px; align-items: flex-start; }
.proj-head h1 { font-size: 20px; font-weight: 700; letter-spacing: -.012em; }
.stepper { display: flex; align-items: center; gap: 0; margin-bottom: 28px; padding: 14px 18px; background: var(--card); border: 1px solid var(--border); position: relative; }
.stepper::before, .stepper::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1; }
.stepper::before { top: -8px; left: -8px; }
.stepper::after { bottom: -8px; right: -8px; }
.stepper .corner-tr, .stepper .corner-bl { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.stepper .corner-tr { top: -8px; right: -8px; }
.stepper .corner-bl { bottom: -8px; left: -8px; }
.stage-step { display: flex; align-items: center; gap: 10px; padding: 6px 0; cursor: pointer; user-select: none; }
.stage-step .num { width: 26px; height: 26px; display: grid; place-items: center; font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--card); color: var(--ink-3); flex-shrink: 0; }
.stage-step.done .num { background: var(--ink); border-color: var(--ink); color: #FFF; }
.stage-step.active .num { background: var(--orange); border-color: var(--orange); color: #FFF; }
.stage-step .lbl { font-size: 13px; font-weight: 500; color: var(--ink-2); }
.stage-step.active .lbl { color: var(--ink); font-weight: 600; }
.stage-step .st { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-left: 4px; padding: 1px 6px; background: var(--bg-soft); border: 1px solid var(--border-soft); letter-spacing: .04em; }
.stage-step.active .st { background: var(--orange-tint); color: var(--orange); border-color: var(--orange-soft); }
.stage-line { flex: 1; height: 1px; background: var(--border); margin: 0 14px; min-width: 30px; }
.stage-line.done { background: var(--ink); }
.stage { display: none; }
.stage.active { display: block; }
.pane { background: var(--card); border: 1px solid var(--border); padding: 20px; margin-bottom: 16px; }
.pane-h { display: flex; align-items: center; gap: 8px; padding: 14px 18px; border-bottom: 1px solid var(--border); margin: -20px -20px 14px; }
.pane-h strong { font-size: 14px; font-weight: 600; }
.stage-script { display: grid; grid-template-columns: 1fr 1.2fr; gap: 16px; min-height: 560px; }
.chat-pane { display: flex; flex-direction: column; padding: 0; }
.chat-body { padding: 16px 18px; flex: 1; overflow-y: auto; max-height: 460px; display: flex; flex-direction: column; gap: 14px; }
.msg .bubble { max-width: 90%; padding: 10px 14px; font-size: 13px; line-height: 1.6; border: 1px solid var(--border); background: var(--card); }
.msg.user { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .bubble { background: var(--orange-tint); border-color: var(--orange-soft); }
.msg .time { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); margin-top: 4px; letter-spacing: .02em; }
.ai-avatar { width: 26px; height: 26px; background: var(--orange); color: #FFF; display: grid; place-items: center; font-size: 11px; font-weight: 700; border: 1px solid var(--orange); }
.chat-input { padding: 14px 18px; border-top: 1px solid var(--border); }
.shot-list { display: flex; flex-direction: column; padding: 0; }
.shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 10px; }
.shot-card { background: var(--bg); border: 1px solid var(--border); padding: 12px 14px; }
.shot-card.highlight { border-color: var(--orange); background: var(--orange-tint); }
.shot-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.shot-num { width: 22px; height: 22px; background: var(--ink); color: #FFF; display: grid; place-items: center; font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; }
.shot-time { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); padding: 2px 6px; background: var(--card); border: 1px solid var(--border); }
.shot-row { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 4px 0; }
.shot-k { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); padding-top: 2px; letter-spacing: .04em; }
.shot-v { font-size: 12.5px; color: var(--ink); line-height: 1.55; max-height: 220px; overflow: auto; }
.stage-assets { display: grid; grid-template-columns: 200px 1fr; gap: 24px; }
.asset-side .ttab { width: 100%; padding: 10px 12px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px; border: 1px solid transparent; }
.asset-side .ttab.active { background: var(--orange-tint); color: var(--orange); border-color: var(--orange-soft); font-weight: 600; }
.asset-side .ttab .num { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--ink-3); margin-left: auto; }
.asset-side .info { font-size: 12px; color: var(--ink-3); padding: 14px 12px; line-height: 1.6; margin-top: 14px; border-top: 1px solid var(--border); }
.asset-side .info strong { color: var(--ink-2); display: block; }
.asset-grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
.asset-card-2 { background: var(--card); border: 1px solid var(--border); }
.asset-card-2 .thumb-2 { aspect-ratio: 1; }
.asset-card-2 .body-2 { padding: 12px 14px; }
.prompt-box { background: var(--bg); border: 1px solid var(--border); padding: 8px 10px; font-size: 12px; color: var(--ink-2); margin: 8px 0; line-height: 1.55; font-family: 'JetBrains Mono', monospace; letter-spacing: .01em; }
.stage-storyboard { display: grid; grid-template-columns: 1.7fr 1fr; gap: 16px; align-items: start; }
.sb-canvas { background: var(--card); border: 1px solid var(--border); }
.sb-row { display: grid; grid-template-columns: 60px 1fr 1fr; gap: 0; border-bottom: 1px solid var(--border); }
.sb-row:last-child { border-bottom: 0; }
.sb-row.head { background: var(--bg-soft); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; text-transform: uppercase; }
.sb-row.head > div { padding: 10px 14px; }
.sb-num { padding: 14px; font-family: 'JetBrains Mono', monospace; font-size: 14px; font-weight: 700; color: var(--ink); border-right: 1px solid var(--border); }
.sb-num .t { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-top: 4px; letter-spacing: .02em; }
.sb-img { padding: 10px; border-right: 1px solid var(--border); }
.sb-img .placeholder { aspect-ratio: 16/9; }
.sb-text { padding: 14px; display: flex; flex-direction: column; gap: 6px; }
.sb-text .meta { font-family: 'JetBrains Mono', monospace; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em; }
.sb-text .dialog { font-size: 12.5px; color: var(--ink); line-height: 1.55; }
.sb-text .sfx { font-size: 11.5px; color: var(--ink-3); }
.queue-bar { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--card); border: 1px solid var(--border); margin-bottom: 18px; }
.queue-bar .bar-wrap { flex: 1; height: 6px; background: var(--bg-soft); overflow: hidden; }
.queue-bar .bar-wrap > span { display: block; height: 100%; background: var(--orange); }
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.video-card { background: var(--card); border: 1px solid var(--border); }
.video-thumb { aspect-ratio: 9/16; max-height: 320px; position: relative; }
.video-thumb .play { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.05); cursor: pointer; opacity: 0; transition: opacity .15s; }
.video-thumb:hover .play { opacity: 1; }
.video-thumb .btn-play { width: 36px; height: 36px; background: rgba(0,0,0,.7); color: #FFF; border-radius: 50%; display: grid; place-items: center; }
.video-card .body { padding: 10px 12px; }
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--card); border: 1px solid var(--border); }
.editor-preview { padding: 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 12px; }
.editor-preview .canvas { flex: 1; aspect-ratio: 9/16; max-height: 380px; margin: 0 auto; background: repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px), var(--bg-soft); border: 1px solid var(--border); display: grid; place-items: center; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
.editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }
.ctl-btn { width: 32px; height: 32px; border: 1px solid var(--border); background: var(--card); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; }
.editor-props { padding: 16px; border-bottom: 1px solid var(--border); overflow-y: auto; }
.props-tabs { display: flex; gap: 0; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
.props-tabs > div { padding: 8px 12px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.props-tabs > div.active { color: var(--orange); border-bottom-color: var(--orange); font-weight: 600; }
.props-row { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.props-row .k { color: var(--ink-3); flex: 1; font-family: 'JetBrains Mono', monospace; font-size: 11px; letter-spacing: .02em; }
.timeline { grid-column: 1 / -1; padding: 14px 16px; background: var(--bg); }
.tl-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.tl-track { display: grid; grid-template-columns: 80px 1fr; align-items: center; gap: 0; padding: 6px 0; }
.tl-track .label { font-size: 11.5px; color: var(--ink-2); display: flex; align-items: center; gap: 6px; padding-left: 4px; }
.tl-track .lane { display: flex; gap: 2px; height: 30px; position: relative; }
.clip { padding: 0 8px; font-size: 11px; display: flex; align-items: center; cursor: pointer; overflow: hidden; white-space: nowrap; user-select: none; }
.clip.video { background: rgba(229, 91, 38, 0.12); border: 1px solid rgba(229, 91, 38, 0.3); color: var(--orange); }
.clip.video.selected { background: var(--orange); color: #FFF; border-color: var(--orange-hover); }
.clip .num { font-family: 'JetBrains Mono', monospace; font-weight: 700; margin-right: 6px; opacity: .7; }
/* Account / supplemental pages */
.acc-grid { display: grid; grid-template-columns: 1.7fr 1fr; gap: 24px; align-items: start; }
.balance-banner { background: var(--ink); color: #FFF; padding: 28px 32px; margin-bottom: 24px; position: relative; border: 1px solid var(--ink); }
.balance-banner::before, .balance-banner::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.balance-banner::before { top: -8px; left: -8px; }
.balance-banner::after { bottom: -8px; right: -8px; }
.balance-banner .corner-tr, .balance-banner .corner-bl { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.balance-banner .corner-tr { top: -8px; right: -8px; }
.balance-banner .corner-bl { bottom: -8px; left: -8px; }
.balance-banner .lbl { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: rgba(255,255,255,.55); letter-spacing: .04em; }
.balance-banner .v { font-size: 42px; font-weight: 700; letter-spacing: -.018em; margin-top: 8px; font-variant-numeric: tabular-nums; }
.balance-banner .meta { font-size: 12.5px; color: rgba(255,255,255,.5); margin-top: 8px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
.balance-banner .actions { display: flex; gap: 8px; margin-top: 18px; }
.balance-banner .btn { background: #FFF; color: var(--ink); border-color: #FFF; }
.balance-banner .btn-ghost { background: transparent; color: #FFF; border: 1px solid rgba(255,255,255,.25); }
.recharge-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 12px; }
.recharge-card { border: 1px solid var(--border); padding: 18px; text-align: center; cursor: pointer; background: var(--card); position: relative; }
.recharge-card:hover { background: var(--bg-soft); }
.recharge-card.selected { border-color: var(--orange); background: var(--orange-tint); }
.recharge-card .amt { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
.recharge-card .gift { font-size: 11px; color: var(--ink-3); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
/* 旧全局 account 样式(.balance-banner / .recharge-*)已移除 —— account 页改用 account-page.css 的 .account-page 作用域版本,避免 margin/padding 泄漏导致顶部卡片高差 */
.usage-line { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; }
.usage-line .v { font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 600; }
.usage-bar { height: 4px; background: var(--bg-soft); border-radius: 2px; margin: 6px 0 12px; overflow: hidden; }
@ -1450,32 +1334,7 @@ nav button svg { width: 14px; height: 14px; opacity: .85; }
.upload-grid-mini .placeholder { aspect-ratio: 1; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.product-detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; }
.ov-card { padding: 22px; }
.detail-hero { display: grid; grid-template-columns: minmax(240px, 360px) 1fr; gap: 22px; align-items: stretch; }
.detail-main-img { min-height: 280px; aspect-ratio: 4/5; }
.detail-info { display: flex; flex-direction: column; gap: 14px; }
.detail-info h2 { font-size: 24px; font-weight: 700; letter-spacing: -.015em; }
.detail-info p { color: var(--ink-2); line-height: 1.7; max-width: 620px; }
.detail-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border); }
.bullet-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.bullet-card { border: 1px solid var(--border); background: var(--bg); padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; }
.bullet-card span { color: var(--ink-2); font-size: 12px; }
.ov-images-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
.ov-images-grid .placeholder, .img-upload { aspect-ratio: 1; }
.img-upload { border: 1px dashed var(--border); background: var(--bg-soft); display: grid; place-items: center; color: var(--ink-2); }
.detail-side { display: flex; flex-direction: column; gap: 16px; }
.quick-actions { display: grid; gap: 8px; }
.qa-item { display: grid; grid-template-columns: 24px 1fr; gap: 8px 10px; align-items: center; border: 1px solid var(--border); background: var(--card); padding: 12px; text-align: left; }
.qa-item:hover { background: var(--bg-soft); }
.qa-item svg { color: var(--orange); }
.qa-item span { font-weight: 600; }
.qa-item small { grid-column: 2; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
.qa-item.primary { border-color: var(--orange-soft); background: var(--orange-tint); color: var(--orange); }
.task-mini { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); text-align: left; }
.task-mini:last-child { border-bottom: 0; }
.tri-preview { aspect-ratio: 16/9; margin-bottom: 12px; }
.danger-lite { color: var(--red); border-color: var(--red-bd); background: var(--red-bg); }
/* 商品详情页旧全局块已移除 · 详情页样式整段 scope 进 src/product-detail-page.css 的 .product-detail-page */
.icon-btn-sm { width: 28px; height: 28px; border: 1px solid var(--border); background: var(--card); color: var(--ink-2); display: inline-grid; place-items: center; border-radius: 7px; margin-left: 4px; }
.icon-btn-sm:hover { background: var(--bg-soft); color: var(--ink); }
.project-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
@ -1526,39 +1385,8 @@ nav button svg { width: 14px; height: 14px; opacity: .85; }
.topup-qr .center { background: #fff; padding: 12px; text-align: center; font-weight: 600; }
.topup-qr .center span { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 10px; }
.team-top { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(320px, .55fr); gap: 24px; margin-bottom: 24px; }
.team-banner { border: 1px solid var(--ink); background: var(--ink); color: #fff; padding: 24px; position: relative; }
.team-banner .corner-tr, .team-banner .corner-bl { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
.team-banner .corner-tr { top: -8px; right: -8px; }
.team-banner .corner-bl { bottom: -8px; left: -8px; }
.banner-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
.banner-id .lbl, .banner-stats .lbl { color: rgba(255,255,255,.52); font-family: 'JetBrains Mono', monospace; font-size: 11px; }
.banner-id .nm { font-size: 24px; font-weight: 700; margin-top: 5px; }
.banner-id .tag { font-size: 11px; padding: 2px 6px; border: 1px solid rgba(255,255,255,.24); color: rgba(255,255,255,.72); vertical-align: middle; }
.banner-id .meta { color: rgba(255,255,255,.5); font-family: 'JetBrains Mono', monospace; font-size: 11px; margin-top: 5px; }
.banner-actions { display: flex; gap: 8px; }
.team-banner .btn { background: #fff; color: var(--ink); border-color: #fff; }
.team-banner .btn-ghost { background: transparent; color: #fff; border-color: rgba(255,255,255,.28); }
.banner-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: rgba(255,255,255,.16); margin-top: 22px; }
.banner-stats .stat { background: var(--ink); padding: 14px; border: 0; }
.banner-stats .v { color: #fff; font-size: 22px; margin-top: 6px; }
.banner-stats .v.warn { color: #ffd0bd; }
.banner-stats .sub { color: rgba(255,255,255,.45); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; margin-top: 4px; }
.team-feed { background: var(--card); border: 1px solid var(--border); padding: 16px; }
.team-feed .h { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.team-feed .ct, .pane-table-h .ct { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 400; }
.team-feed .more { margin-left: auto; color: var(--orange); font-size: 12px; }
.feed-list, .feed-all-list { display: grid; gap: 10px; }
.feed-item { display: grid; grid-template-columns: 28px 1fr; gap: 9px; align-items: start; }
.av, .feed-item .av { width: 28px; height: 28px; border-radius: 6px; background: var(--ink); color: #fff; display: grid; place-items: center; font-size: 11px; font-weight: 600; }
.feed-item .txt { font-size: 12.5px; color: var(--ink); }
.feed-item .ts { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 10.5px; margin-top: 2px; }
.pane-table-h { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.pane-table-h .input { margin-left: auto; max-width: 240px; height: 32px; }
.perm-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.perm-table td { border-bottom: 1px solid var(--border); padding: 8px 6px; font-size: 12px; }
.perm-table .yes { color: var(--green); font-weight: 700; text-align: center; }
.perm-table .no { color: var(--ink-3); text-align: center; }
/* 团队页旧全局块已移除 · 详见 src/team-page.css 的 .team-page 作用域。保留通用 .av(app-shell/account/dashboard 在用)。 */
.av { width: 28px; height: 28px; border-radius: 6px; background: var(--ink); color: #fff; display: grid; place-items: center; font-size: 11px; font-weight: 600; }
.invite-modal { max-width: 620px; }
.limit-presets, .role-choices { display: flex; flex-wrap: wrap; gap: 8px; }
.role-choice { flex: 1; min-width: 140px; border: 1px solid var(--border); padding: 12px; background: var(--card); text-align: left; }

View File

@ -0,0 +1,116 @@
/* 团队页 · public/exact/team.html 内联 <style> 忠实移植(仅页面结构 9-127,
modal 段沿用共享 TeamModal,默认隐藏不入 diff),整段 scope .team-page 防冲突
依赖 design-restraint.css 的全局 .stat(padding 24/28 + border-right)与镜像 restraint.css 一致 */
.team-page {
/* ─── 团队信息卡(深色 banner · 上标题行 + 下统计行)─── */
.team-top { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; margin-bottom: 24px; align-items: stretch; }
.team-banner {
background: var(--accent-black);
color: var(--accent-white);
padding: 22px 28px 24px;
position: relative;
border: 1px solid var(--accent-black);
border-radius: var(--r-md);
}
/* ─── 团队动态卡(贴 banner 右边)─── */
.team-feed { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px 18px; display: flex; flex-direction: column; min-width: 0; }
.team-feed .h { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.team-feed .h h3 { font-size: 13.5px; font-weight: 600; margin: 0; }
.team-feed .h .ct { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }
.team-feed .h .more { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--heat); text-decoration: none; cursor: pointer; }
.team-feed .feed-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; max-height: 240px; padding-right: 4px; scrollbar-width: thin; }
.team-feed .feed-list::-webkit-scrollbar { width: 4px; }
.team-feed .feed-list::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }
.team-feed .feed-item { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 10px; align-items: start; }
.team-feed .feed-item .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 11px; font-weight: 600; color: var(--accent-black); }
.team-feed .feed-item .txt { font-size: 12.5px; line-height: 1.45; color: var(--accent-black); min-width: 0; }
.team-feed .feed-item .txt .who { font-weight: 600; }
.team-feed .feed-item .txt .act { color: var(--black-alpha-56); margin: 0 3px; }
.team-feed .feed-item .txt .obj { color: var(--heat); }
.team-feed .feed-item .txt .obj-money { color: var(--accent-forest); font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
.team-feed .feed-item .ts { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
/* 4 个装订线小十字(2 个 pseudo + 2 个 span)*/
.team-banner::before, .team-banner::after,
.team-banner > .corner-tr, .team-banner > .corner-bl {
content: ''; position: absolute; width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain; pointer-events: none;
}
.team-banner::before { top: -7px; left: -7px; }
.team-banner::after { bottom: -7px; right: -7px; }
.team-banner > .corner-tr { top: -7px; right: -7px; }
.team-banner > .corner-bl { bottom: -7px; left: -7px; }
/* 第 1 行:标题 + 主操作 */
.banner-head { display: flex; align-items: flex-start; gap: 20px; }
.banner-id { flex: 1; min-width: 0; }
.banner-id .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.banner-id .nm { font-size: 22px; font-weight: 700; letter-spacing: -.012em; margin-top: 4px; display: flex; align-items: baseline; gap: 10px; }
.banner-id .nm .tag { font-size: 10.5px; font-family: var(--font-mono); padding: 2px 8px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); letter-spacing: .04em; font-weight: 500; }
.banner-id .meta { font-size: 12px; color: rgba(255,255,255,.5); margin-top: 6px; font-family: var(--font-mono); letter-spacing: .02em; }
.banner-actions { display: flex; gap: 8px; flex-shrink: 0; }
.banner-actions .btn { background: var(--accent-white); color: var(--accent-black); border-color: var(--accent-white); }
.banner-actions .btn:hover { background: var(--background-base); }
.banner-actions .btn-ghost { background: transparent; color: var(--accent-white); border: 1px solid rgba(255,255,255,.25); }
.banner-actions .btn-ghost:hover { background: rgba(255,255,255,.08); color: var(--accent-white); }
/* 分隔线 */
.banner-divider { height: 1px; background: rgba(255,255,255,.1); margin: 20px 0 18px; }
/* 第 2 行:4 列统计 */
.banner-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }
.banner-stats .stat { min-width: 0; }
.banner-stats .stat .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.banner-stats .stat .v { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.012em; margin-top: 6px; }
/* color 必须显式写,否则会被 restraint.css 全局 .stat .v 的 color: var(--accent-black) 覆盖成黑字 */
.banner-stats .stat .v { color: var(--accent-white); }
.banner-stats .stat .v.warn { color: #FFB870; }
.banner-stats .stat .sub { font-size: 11px; color: rgba(255,255,255,.5); margin-top: 4px; font-family: var(--font-mono); letter-spacing: .02em; }
/* ─── 主体两栏 ─── */
.team-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; }
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.pane h3 .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); font-weight: 400; }
.pane h3 .spacer { margin-left: auto; }
/* ─── 成员表 ─── */
.members-table .av { width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }
.members-table .who { display: flex; align-items: center; gap: 10px; }
.members-table .nm { font-weight: 500; font-size: 13.5px; line-height: 1.2; }
.members-table .em { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); }
.members-table .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 11px; font-weight: 500; }
.members-table .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
.members-table .role-super { background: var(--heat-12); color: var(--heat); }
.members-table .role-super .dot { background: var(--heat); }
.members-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
.members-table .role-admin .dot { background: #1E40AF; }
.members-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
.members-table .role-member .dot { background: var(--black-alpha-56); }
.members-table .quota-cell { font-variant-numeric: tabular-nums; font-family: var(--font-mono); font-size: 12px; }
.members-table .quota-cell .lbl { color: var(--black-alpha-48); }
.members-table .quota-cell .v { color: var(--accent-black); font-weight: 600; }
.members-table .used-bar { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; margin-top: 4px; }
.members-table .used-bar > span { display: block; height: 100%; background: var(--heat); }
.members-table .used-bar > span.ok { background: var(--accent-forest); }
.members-table .used-bar > span.warn { background: #B45309; }
.members-table .acts { display: flex; gap: 4px; justify-content: flex-end; }
.members-table .icon-btn-sm { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; color: var(--black-alpha-56); transition: all var(--t-base); }
.members-table .icon-btn-sm:hover { color: var(--heat); border-color: var(--heat-20); }
.members-table .icon-btn-sm svg { width: 14px; height: 14px; }
.members-table .icon-btn-sm.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
.members-table tr.pending td { opacity: .65; }
.members-table tr.pending .nm::after { content: '· 待激活'; font-size: 11px; color: var(--black-alpha-48); margin-left: 6px; font-weight: 400; font-family: var(--font-mono); }
/* ─── 角色权限矩阵 ─── */
.perm-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; font-size: 12.5px; }
.perm-table th, .perm-table td { padding: 8px 10px; border-bottom: 0; }
.perm-table th { border-bottom: 1px solid var(--border-muted); background: var(--background-lighter); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; text-align: left; }
.perm-table th:not(:first-child), .perm-table td:not(:first-child) { text-align: center; }
.perm-table tbody td:first-child { color: var(--accent-black); }
.perm-table .yes { color: var(--accent-forest); font-weight: 600; }
.perm-table .no { color: var(--black-alpha-32); }
}

View File

@ -34,7 +34,7 @@ function readPng(file) {
return PNG.sync.read(fs.readFileSync(file));
}
async function preparePage(page, url, shouldClearStorage) {
async function preparePage(page, url, shouldClearStorage, token) {
await page.goto(url, { waitUntil: "networkidle" });
if (shouldClearStorage) {
await page.evaluate(() => {
@ -43,6 +43,12 @@ async function preparePage(page, url, shouldClearStorage) {
});
await page.goto(url, { waitUntil: "networkidle" });
}
// 注入登录 token,让受保护的真 React 路由 + /exact 镜像都能 hydrate 同一份真实数据
if (token) {
await page.evaluate((value) => localStorage.setItem("airshelf_token", value), token);
await page.goto(url, { waitUntil: "networkidle" });
await page.waitForTimeout(900); // 等异步数据 hydrate 落定
}
await page.evaluate(async () => {
if ("fonts" in document) await document.fonts.ready;
});
@ -65,6 +71,7 @@ const viewport = parseViewport(arg("viewport", "1440x900"));
const outDir = path.resolve(repoRoot, arg("out", "core/qa/visual-parity/output"));
const threshold = Number.parseFloat(arg("threshold", "0.03"));
const clearTargetStorage = boolArg("clear-target-storage");
const token = arg("token", "");
fs.mkdirSync(outDir, { recursive: true });
@ -84,8 +91,8 @@ const targetPath = path.join(outDir, `${name}.implementation.png`);
const diffPath = path.join(outDir, `${name}.diff.png`);
const reportPath = path.join(outDir, `${name}.report.json`);
await preparePage(sourcePage, source, false);
await preparePage(targetPage, target, clearTargetStorage);
await preparePage(sourcePage, source, false, token);
await preparePage(targetPage, target, clearTargetStorage, token);
await sourcePage.screenshot({ path: sourcePath, fullPage: false });
await targetPage.screenshot({ path: targetPath, fullPage: false });

View File

@ -0,0 +1,70 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: airshelf-core-api
labels:
app: airshelf-core-api
spec:
replicas: 1
selector:
matchLabels:
app: airshelf-core-api
template:
metadata:
labels:
app: airshelf-core-api
spec:
imagePullSecrets:
- name: cr-pull-secret
containers:
- name: airshelf-core-api
image: ${CI_REGISTRY_IMAGE}/airshelf-core-api:latest
imagePullPolicy: Always
# No command override: the image ENTRYPOINT (docker-entrypoint.sh) runs
# migrate + collectstatic, then the default CMD (gunicorn) is exec'd.
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: airshelf-core-env
livenessProbe:
httpGet:
path: /api/health/
port: 8000
httpHeaders:
- name: Host
value: airshelf-web.airlabs.art
initialDelaySeconds: 20
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health/
port: 8000
httpHeaders:
- name: Host
value: airshelf-web.airlabs.art
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "768Mi"
cpu: "1000m"
---
apiVersion: v1
kind: Service
metadata:
name: airshelf-core-api
spec:
selector:
app: airshelf-core-api
ports:
- protocol: TCP
port: 8000
targetPort: 8000

24
k8s/core/ingress.yaml Normal file
View File

@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: airshelf-core-ingress
annotations:
kubernetes.io/ingress.class: "traefik"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
traefik.ingress.kubernetes.io/router.middlewares: "default-redirect-https@kubernetescrd"
spec:
tls:
- hosts:
- airshelf-web.airlabs.art
secretName: airshelf-core-tls
rules:
- host: airshelf-web.airlabs.art
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: airshelf-core-web
port:
number: 80

View File

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: airshelf-core-web
labels:
app: airshelf-core-web
spec:
replicas: 1
selector:
matchLabels:
app: airshelf-core-web
template:
metadata:
labels:
app: airshelf-core-web
spec:
imagePullSecrets:
- name: cr-pull-secret
containers:
- name: airshelf-core-web
image: ${CI_REGISTRY_IMAGE}/airshelf-core-web:latest
imagePullPolicy: Always
ports:
- containerPort: 80
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
memory: "32Mi"
cpu: "20m"
limits:
memory: "128Mi"
cpu: "150m"
---
apiVersion: v1
kind: Service
metadata:
name: airshelf-core-web
spec:
selector:
app: airshelf-core-web
ports:
- protocol: TCP
port: 80
targetPort: 80

View File

@ -0,0 +1,43 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: airshelf-core-worker
labels:
app: airshelf-core-worker
spec:
replicas: 1
selector:
matchLabels:
app: airshelf-core-worker
template:
metadata:
labels:
app: airshelf-core-worker
spec:
imagePullSecrets:
- name: cr-pull-secret
containers:
- name: airshelf-core-worker
image: ${CI_REGISTRY_IMAGE}/airshelf-core-api:latest
imagePullPolicy: Always
# Celery worker connects to the external (Volcano managed) Redis broker
# configured via the airshelf-core-env secret. Uses `args` (not `command`)
# so the image ENTRYPOINT still runs but skips migrate/collectstatic ($1=celery).
args: ["celery", "-A", "airshelf.celery:app", "worker", "-l", "info", "--concurrency", "2"]
envFrom:
- secretRef:
name: airshelf-core-env
livenessProbe:
exec:
command: ["sh", "-c", "celery -A airshelf.celery:app inspect ping"]
initialDelaySeconds: 40
periodSeconds: 60
timeoutSeconds: 15
failureThreshold: 3
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "768Mi"
cpu: "1000m"