feat(k8s): 新增 core 真应用(前端+Django API+Celery worker)构建与部署
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
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:
parent
cfdcd84a30
commit
d41e487f08
@ -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
2
.gitignore
vendored
@ -4,6 +4,8 @@ out
|
||||
dist
|
||||
.env*.local
|
||||
.env
|
||||
# core 后端环境变量需要进 CI 构建,放行它(其余 .env 仍忽略)
|
||||
!core/backend/.env
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
10
core/backend/.dockerignore
Normal file
10
core/backend/.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
db.sqlite3
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tests/
|
||||
*.md
|
||||
staticfiles/
|
||||
24
core/backend/.env
Normal file
24
core/backend/.env
Normal 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
26
core/backend/Dockerfile
Normal 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"]
|
||||
@ -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"},
|
||||
}
|
||||
|
||||
15
core/backend/docker-entrypoint.sh
Normal file
15
core/backend/docker-entrypoint.sh
Normal 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 "$@"
|
||||
@ -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
|
||||
|
||||
|
||||
6
core/frontend/.dockerignore
Normal file
6
core/frontend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
27
core/frontend/Dockerfile
Normal file
27
core/frontend/Dockerfile
Normal 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
67
core/frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
core/frontend/public/assets/pay-alipay.png
Normal file
BIN
core/frontend/public/assets/pay-alipay.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
core/frontend/public/assets/pay-wechat.png
Normal file
BIN
core/frontend/public/assets/pay-wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@ -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>
|
||||
|
||||
161
core/frontend/src/account-page.css
Normal file
161
core/frontend/src/account-page.css
Normal 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; }
|
||||
}
|
||||
@ -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> }) {
|
||||
|
||||
13
core/frontend/src/library-page.css
Normal file
13
core/frontend/src/library-page.css
Normal 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); }
|
||||
}
|
||||
@ -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 />);
|
||||
|
||||
398
core/frontend/src/pipeline-page.css
Normal file
398
core/frontend/src/pipeline-page.css
Normal 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%; }
|
||||
}
|
||||
911
core/frontend/src/product-detail-page.css
Normal file
911
core/frontend/src/product-detail-page.css
Normal 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); }
|
||||
}
|
||||
}
|
||||
33
core/frontend/src/products-page.css
Normal file
33
core/frontend/src/products-page.css
Normal 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; }
|
||||
}
|
||||
44
core/frontend/src/projects-page.css
Normal file
44
core/frontend/src/projects-page.css
Normal 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; }
|
||||
}
|
||||
@ -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></>;
|
||||
}
|
||||
|
||||
@ -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> 脚本生成 <span className="v">●●●●●</span></div>
|
||||
<div className="ln"><span className="k">// step·2</span> 基础资产 <span className="v">●●●●○</span></div>
|
||||
<div className="ln"><span className="k">// step·3</span> 故事板 <span className="v">●●●○○</span></div>
|
||||
<div className="ln"><span className="k">// step·4</span> 视频片段 <span className="v">●●○○○</span></div>
|
||||
<div className="ln"><span className="k">// step·5</span> 拼接导出 <span className="v">●○○○○</span></div>
|
||||
</div>
|
||||
{mode === "login" ? (
|
||||
<div className="ascii">
|
||||
<div className="ln"><span className="k">// step·1</span> 脚本生成 <span className="v">●●●●●</span></div>
|
||||
<div className="ln"><span className="k">// step·2</span> 基础资产 <span className="v">●●●●○</span></div>
|
||||
<div className="ln"><span className="k">// step·3</span> 故事板 <span className="v">●●●○○</span></div>
|
||||
<div className="ln"><span className="k">// step·4</span> 视频片段 <span className="v">●●○○○</span></div>
|
||||
<div className="ln"><span className="k">// step·5</span> 拼接导出 <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>}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
116
core/frontend/src/team-page.css
Normal file
116
core/frontend/src/team-page.css
Normal 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); }
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
70
k8s/core/api-deployment.yaml
Normal file
70
k8s/core/api-deployment.yaml
Normal 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
24
k8s/core/ingress.yaml
Normal 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
|
||||
59
k8s/core/web-deployment.yaml
Normal file
59
k8s/core/web-deployment.yaml
Normal 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
|
||||
43
k8s/core/worker-deployment.yaml
Normal file
43
k8s/core/worker-deployment.yaml
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user