Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84431c9c5 | ||
|
|
5dbb240b59 | ||
|
|
ceb5163881 | ||
|
|
cfac6006ef | ||
|
|
e7afcd0449 | ||
|
|
2a4743db09 | ||
|
|
10c4aa4437 | ||
|
|
b821e334f6 | ||
|
|
a477177bab | ||
|
|
806d6bf62a | ||
|
|
a6a3d88624 | ||
|
|
b1ef04ee30 | ||
|
|
03ceb82329 | ||
|
|
2745760d33 | ||
|
|
2889c92c34 | ||
|
|
9f15b71f19 | ||
|
|
0bd1db6bf9 | ||
|
|
690eb3d843 | ||
|
|
fb3b2904ae | ||
|
|
f2e8a5f3c1 | ||
|
|
b5d970a4b5 |
@@ -1,294 +1,20 @@
|
||||
name: Build and Deploy
|
||||
# push → Gitea Actions(本机 Act Runner)→ docker compose 部署
|
||||
name: Deploy dev
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
deploy:
|
||||
# 与服务器 act_runner 注册的 label 一致(host = 直接在宿主机跑,能用本机 docker)
|
||||
runs-on: yingqing
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Deploy on server
|
||||
run: |
|
||||
git clone --depth=1 --branch=${{ github.ref_name }} https://x:${{ secrets.GITHUB_TOKEN }}@gitea.airlabs.art/${{ github.repository }}.git .
|
||||
|
||||
- name: Set environment by branch
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
BUILD_DATE=$(date +%Y%m%d)
|
||||
|
||||
if [[ "${{ github.ref_name }}" == "master" ]]; then
|
||||
echo "IMAGE_TAG=prod-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "CR_SERVER_ACTIVE=gitea-prod-cn-shanghai.cr.volces.com" >> $GITHUB_ENV
|
||||
echo "CR_USERNAME_ACTIVE=seaislee@76339115" >> $GITHUB_ENV
|
||||
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PROD_PASSWORD }}" >> $GITHUB_ENV
|
||||
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
|
||||
echo "CR_USERNAME_ACTIVE=${{ secrets.CR_USERNAME }}" >> $GITHUB_ENV
|
||||
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PASSWORD }}" >> $GITHUB_ENV
|
||||
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
|
||||
run: |
|
||||
echo "${{ env.CR_PASSWORD_ACTIVE }}" | docker login --username "${{ env.CR_USERNAME_ACTIVE }}" --password-stdin ${{ env.CR_SERVER_ACTIVE }}
|
||||
|
||||
- name: Build and Push Web
|
||||
id: build_web
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
echo "Build web attempt $attempt/3..."
|
||||
DOCKER_BUILDKIT=0 docker build \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-web:${{ env.IMAGE_TAG }} \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-web:latest \
|
||||
"./电商AI平台" 2>&1 | tee /tmp/build.log && { ok=1; break; }
|
||||
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
done
|
||||
[ $ok -eq 1 ] || { echo "ERROR: 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-web:${{ env.IMAGE_TAG }} && \
|
||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-web:latest && { ok=1; break; }
|
||||
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
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
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && { ok=1; break; }
|
||||
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
|
||||
done
|
||||
[ $ok -eq 1 ] || { echo "ERROR: kubectl download failed after 3 attempts"; exit 1; }
|
||||
chmod +x kubectl && mv kubectl /usr/bin/kubectl
|
||||
fi
|
||||
kubectl version --client
|
||||
|
||||
- name: Set kubeconfig
|
||||
run: |
|
||||
mkdir -p $HOME/.kube
|
||||
if [[ "${{ github.ref_name }}" == "master" ]]; then
|
||||
printf '%s\n' '${{ secrets.VOLCANO_PROD_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||
printf '%s\n' '${{ secrets.VOLCANO_TEST_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||
fi
|
||||
chmod 600 $HOME/.kube/config
|
||||
echo "kubeconfig lines: $(wc -l < $HOME/.kube/config)"
|
||||
grep server $HOME/.kube/config || echo "WARNING: no server found in kubeconfig"
|
||||
|
||||
- name: Deploy to K3s
|
||||
id: deploy
|
||||
run: |
|
||||
echo "Environment: ${{ env.DEPLOY_ENV }}"
|
||||
CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}"
|
||||
|
||||
# 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 k8s/core/worker-quick-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"
|
||||
|
||||
ok=0
|
||||
for attempt in 1 2 3 4 5; do
|
||||
echo "Deploy attempt $attempt/5..."
|
||||
{
|
||||
# Create/update image pull secret for CR
|
||||
kubectl $KUBECTL_TIMEOUT create secret docker-registry cr-pull-secret \
|
||||
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
||||
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
||||
--dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f -
|
||||
|
||||
# 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/worker-quick-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-worker-quick
|
||||
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
|
||||
done
|
||||
[ $ok -eq 1 ] || { echo "ERROR: deploy to K3s failed after 5 attempts — check /tmp/deploy.log"; exit 1; }
|
||||
|
||||
# ===== Log Center: failure reporting =====
|
||||
- name: Report failure to Log Center
|
||||
if: failure()
|
||||
run: |
|
||||
BUILD_LOG=""
|
||||
DEPLOY_LOG=""
|
||||
FAILED_STEP="unknown"
|
||||
|
||||
if [[ "${{ steps.build_web.outcome }}" == "failure" ]]; then
|
||||
FAILED_STEP="build"
|
||||
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
|
||||
DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
||||
fi
|
||||
fi
|
||||
|
||||
ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}"
|
||||
if [ -z "$ERROR_LOG" ]; then
|
||||
ERROR_LOG="No captured output. Check Gitea Actions UI for details."
|
||||
fi
|
||||
|
||||
if [[ "$FAILED_STEP" == "deploy" ]]; then
|
||||
SOURCE="deployment"
|
||||
ERROR_TYPE="DeployError"
|
||||
else
|
||||
SOURCE="cicd"
|
||||
ERROR_TYPE="DockerBuildError"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"project_id\": \"airshelf\",
|
||||
\"environment\": \"${{ env.DEPLOY_ENV }}\",
|
||||
\"level\": \"ERROR\",
|
||||
\"source\": \"${SOURCE}\",
|
||||
\"commit_hash\": \"${{ github.sha }}\",
|
||||
\"repo_url\": \"https://gitea.airlabs.art/zyc/AirShelf.git\",
|
||||
\"error\": {
|
||||
\"type\": \"${ERROR_TYPE}\",
|
||||
\"message\": \"[${FAILED_STEP}] Build and Deploy failed on branch ${{ github.ref_name }}\",
|
||||
\"stack_trace\": [\"${ERROR_LOG}\"]
|
||||
},
|
||||
\"context\": {
|
||||
\"job_name\": \"build-and-deploy\",
|
||||
\"step_name\": \"${FAILED_STEP}\",
|
||||
\"workflow\": \"${{ github.workflow }}\",
|
||||
\"run_id\": \"${{ github.run_number }}\",
|
||||
\"branch\": \"${{ github.ref_name }}\",
|
||||
\"actor\": \"${{ github.actor }}\",
|
||||
\"commit\": \"${{ github.sha }}\",
|
||||
\"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
|
||||
}
|
||||
}" || true
|
||||
|
||||
# ===== Cleanup: remove unused Docker resources =====
|
||||
- name: Docker Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
docker builder prune -a -f
|
||||
echo "Disk usage after cleanup:"
|
||||
df -h / | tail -1
|
||||
set -euo pipefail
|
||||
test -x /opt/AirShelf/scripts/deploy-dev.sh
|
||||
/opt/AirShelf/scripts/deploy-dev.sh
|
||||
echo "deploy finished"
|
||||
cd /opt/AirShelf && git log -1 --oneline && docker compose ps
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 --branch=${{ github.ref_name }} https://x:${{ secrets.GITHUB_TOKEN }}@gitea.airlabs.art/${{ github.repository }}.git .
|
||||
|
||||
- name: Set environment by branch
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
BUILD_DATE=$(date +%Y%m%d)
|
||||
|
||||
if [[ "${{ github.ref_name }}" == "master" ]]; then
|
||||
echo "IMAGE_TAG=prod-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
|
||||
echo "CR_SERVER_ACTIVE=gitea-prod-cn-shanghai.cr.volces.com" >> $GITHUB_ENV
|
||||
echo "CR_USERNAME_ACTIVE=seaislee@76339115" >> $GITHUB_ENV
|
||||
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PROD_PASSWORD }}" >> $GITHUB_ENV
|
||||
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
|
||||
echo "CR_USERNAME_ACTIVE=${{ secrets.CR_USERNAME }}" >> $GITHUB_ENV
|
||||
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PASSWORD }}" >> $GITHUB_ENV
|
||||
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
|
||||
run: |
|
||||
echo "${{ env.CR_PASSWORD_ACTIVE }}" | docker login --username "${{ env.CR_USERNAME_ACTIVE }}" --password-stdin ${{ env.CR_SERVER_ACTIVE }}
|
||||
|
||||
- name: Build and Push Web
|
||||
id: build_web
|
||||
run: |
|
||||
set -o pipefail
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
echo "Build web attempt $attempt/3..."
|
||||
DOCKER_BUILDKIT=0 docker build \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-web:${{ env.IMAGE_TAG }} \
|
||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-web:latest \
|
||||
"./电商AI平台" 2>&1 | tee /tmp/build.log && { ok=1; break; }
|
||||
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
done
|
||||
[ $ok -eq 1 ] || { echo "ERROR: 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-web:${{ env.IMAGE_TAG }} && \
|
||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/airshelf-web:latest && { ok=1; break; }
|
||||
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
||||
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
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && { ok=1; break; }
|
||||
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
|
||||
done
|
||||
[ $ok -eq 1 ] || { echo "ERROR: kubectl download failed after 3 attempts"; exit 1; }
|
||||
chmod +x kubectl && mv kubectl /usr/bin/kubectl
|
||||
fi
|
||||
kubectl version --client
|
||||
|
||||
- name: Set kubeconfig
|
||||
run: |
|
||||
mkdir -p $HOME/.kube
|
||||
if [[ "${{ github.ref_name }}" == "master" ]]; then
|
||||
printf '%s\n' '${{ secrets.VOLCANO_PROD_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
|
||||
printf '%s\n' '${{ secrets.VOLCANO_TEST_KUBE_CONFIG }}' > $HOME/.kube/config
|
||||
fi
|
||||
chmod 600 $HOME/.kube/config
|
||||
echo "kubeconfig lines: $(wc -l < $HOME/.kube/config)"
|
||||
grep server $HOME/.kube/config || echo "WARNING: no server found in kubeconfig"
|
||||
|
||||
- name: Deploy to K3s
|
||||
id: deploy
|
||||
run: |
|
||||
echo "Environment: ${{ env.DEPLOY_ENV }}"
|
||||
CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}"
|
||||
|
||||
# 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 k8s/core/worker-quick-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"
|
||||
|
||||
ok=0
|
||||
for attempt in 1 2 3 4 5; do
|
||||
echo "Deploy attempt $attempt/5..."
|
||||
{
|
||||
# Create/update image pull secret for CR
|
||||
kubectl $KUBECTL_TIMEOUT create secret docker-registry cr-pull-secret \
|
||||
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
||||
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
||||
--dry-run=client -o yaml | kubectl $KUBECTL_TIMEOUT apply -f -
|
||||
|
||||
# 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/worker-quick-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-worker-quick
|
||||
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
|
||||
done
|
||||
[ $ok -eq 1 ] || { echo "ERROR: deploy to K3s failed after 5 attempts — check /tmp/deploy.log"; exit 1; }
|
||||
|
||||
# ===== Log Center: failure reporting =====
|
||||
- name: Report failure to Log Center
|
||||
if: failure()
|
||||
run: |
|
||||
BUILD_LOG=""
|
||||
DEPLOY_LOG=""
|
||||
FAILED_STEP="unknown"
|
||||
|
||||
if [[ "${{ steps.build_web.outcome }}" == "failure" ]]; then
|
||||
FAILED_STEP="build"
|
||||
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
|
||||
DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
||||
fi
|
||||
fi
|
||||
|
||||
ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}"
|
||||
if [ -z "$ERROR_LOG" ]; then
|
||||
ERROR_LOG="No captured output. Check Gitea Actions UI for details."
|
||||
fi
|
||||
|
||||
if [[ "$FAILED_STEP" == "deploy" ]]; then
|
||||
SOURCE="deployment"
|
||||
ERROR_TYPE="DeployError"
|
||||
else
|
||||
SOURCE="cicd"
|
||||
ERROR_TYPE="DockerBuildError"
|
||||
fi
|
||||
|
||||
curl -s -X POST "https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"project_id\": \"airshelf\",
|
||||
\"environment\": \"${{ env.DEPLOY_ENV }}\",
|
||||
\"level\": \"ERROR\",
|
||||
\"source\": \"${SOURCE}\",
|
||||
\"commit_hash\": \"${{ github.sha }}\",
|
||||
\"repo_url\": \"https://gitea.airlabs.art/zyc/AirShelf.git\",
|
||||
\"error\": {
|
||||
\"type\": \"${ERROR_TYPE}\",
|
||||
\"message\": \"[${FAILED_STEP}] Build and Deploy failed on branch ${{ github.ref_name }}\",
|
||||
\"stack_trace\": [\"${ERROR_LOG}\"]
|
||||
},
|
||||
\"context\": {
|
||||
\"job_name\": \"build-and-deploy\",
|
||||
\"step_name\": \"${FAILED_STEP}\",
|
||||
\"workflow\": \"${{ github.workflow }}\",
|
||||
\"run_id\": \"${{ github.run_number }}\",
|
||||
\"branch\": \"${{ github.ref_name }}\",
|
||||
\"actor\": \"${{ github.actor }}\",
|
||||
\"commit\": \"${{ github.sha }}\",
|
||||
\"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
|
||||
}
|
||||
}" || true
|
||||
|
||||
# ===== Cleanup: remove unused Docker resources =====
|
||||
- name: Docker Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
docker builder prune -a -f
|
||||
echo "Disk usage after cleanup:"
|
||||
df -h / | tail -1
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新建项目 · Airshelf</title>
|
||||
<title>新建项目 · YingQing AI</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css">
|
||||
<style>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
DJANGO_SETTINGS_MODULE=airshelf.settings.development
|
||||
DJANGO_SETTINGS_MODULE=airshelf.settings.production
|
||||
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,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
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_ALLOWED_HOSTS=yingqing.yueqing.online,localhost,127.0.0.1
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://yingqing.yueqing.online
|
||||
CORS_ALLOWED_ORIGINS=https://yingqing.yueqing.online
|
||||
DB_ENGINE=mysql
|
||||
DB_NAME=airshelf_test
|
||||
DB_USER=airshelf_app
|
||||
DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85
|
||||
DB_HOST=14.103.27.192
|
||||
DB_NAME=yingqing_app
|
||||
DB_USER=yingqing_app
|
||||
DB_PASSWORD=NWXa5nDABKxcS64A
|
||||
DB_HOST=8.163.33.160
|
||||
DB_PORT=3306
|
||||
# DB_BIND_ADDRESS=192.168.124.137 # local-dev blanked: upstream dev-host LAN addr, not present on this machine -> WinError 10049
|
||||
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==
|
||||
REDIS_CACHE_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/0
|
||||
CELERY_BROKER_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://:redis_WFSzWJ@yingqing-redis:6379/2
|
||||
REDIS_LOCK_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/3
|
||||
TOS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
|
||||
TOS_BUCKET=yingqing-media
|
||||
TOS_ACCESS_KEY_ID=LTAI5t62RF5Swgs2uwopvuZz
|
||||
TOS_SECRET_ACCESS_KEY=CMmQNGiG2ieESRrhD1ORwuB6FYBmxE
|
||||
VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26
|
||||
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
# 2026-07-09 视频(Seedance)ARK key 换成新账号,与新账号素材库审核同账号 → 真人 asset:// 可解析。EP=ep-20260707204223-wkcn9(备用,代码按模型名调不用它)。
|
||||
@@ -107,6 +107,6 @@ MODEL_TRYON_PROMPT_V2_ENABLED=true
|
||||
MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS=
|
||||
|
||||
# 全能创作 Agent:本机专用队列,避免远程 K8s worker 偷走未注册任务
|
||||
CREATION_AGENT_TASK_QUEUE=airshelf.local
|
||||
CREATION_AGENT_TASK_QUEUE=celery
|
||||
# 可选:API 线程内联跑 turn(不入 Celery)。队列方案足够时保持 false
|
||||
CREATION_AGENT_INLINE=true
|
||||
CREATION_AGENT_INLINE=false
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
DJANGO_SETTINGS_MODULE=airshelf.settings.development
|
||||
DJANGO_SETTINGS_MODULE=airshelf.settings.production
|
||||
DJANGO_SECRET_KEY=change-me
|
||||
DJANGO_DEBUG=true
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_ALLOWED_HOSTS=yingqing.yueqing.online,localhost,127.0.0.1
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://yingqing.yueqing.online
|
||||
CORS_ALLOWED_ORIGINS=https://yingqing.yueqing.online
|
||||
|
||||
DB_ENGINE=mysql
|
||||
DB_NAME=airshelf_dev
|
||||
DB_USER=airshelf
|
||||
DB_NAME=yingqing_app
|
||||
DB_USER=yingqing_app
|
||||
DB_PASSWORD=change-me
|
||||
DB_HOST=127.0.0.1
|
||||
DB_HOST=yingqing-mysql
|
||||
DB_PORT=3306
|
||||
DB_BIND_ADDRESS=
|
||||
|
||||
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
|
||||
REDIS_CACHE_URL=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/0
|
||||
CELERY_BROKER_URL=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/2
|
||||
|
||||
# 全能创作 Agent turn 队列。生产保持 celery(K8s 已部署该任务)。
|
||||
# 本机若与远程 worker 共用 broker,设为 airshelf.local,并让本机 worker -Q 含该队列。
|
||||
CREATION_AGENT_TASK_QUEUE=celery
|
||||
# true / AIRSHELF_CREATION_AGENT_INLINE=1:API 后台线程直接跑,不入 Celery(仅本机联调)
|
||||
CREATION_AGENT_INLINE=false
|
||||
REDIS_LOCK_URL=redis://127.0.0.1:6379/3
|
||||
REDIS_LOCK_URL=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/3
|
||||
|
||||
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
|
||||
TOS_BUCKET=airshelf
|
||||
|
||||
@@ -227,13 +227,37 @@ if CREATION_AGENT_TASK_QUEUE and CREATION_AGENT_TASK_QUEUE != "celery":
|
||||
"queue": CREATION_AGENT_TASK_QUEUE,
|
||||
}
|
||||
|
||||
# 全能创作 Agent 的单次模型输出上限。豆包 Seed 系列最大输出 16k,但**默认只有 4k**;
|
||||
# 不显式抬高会把长方案的 tool_call arguments 截断成坏 JSON → 工具报「缺正文」→ 模型反复重写 → 整轮超时。
|
||||
CREATION_AGENT_MAX_OUTPUT_TOKENS = env_int("CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000)
|
||||
# 思考模式(仅火山官方直连下发)。Seed 2.1 Pro 只认 enabled / disabled,收到 auto 直接 400。
|
||||
# 默认 disabled:实测同一条 120 秒方案,开思考 156s(正文 131s 后才开始吐)、关思考 56s(首字 0.9s),
|
||||
# 产出长度相当。编排 agent 的结构由 skill 提示词给定,不需要模型再自己推演。
|
||||
# 置空则不下发、跟随模型默认(= 开思考),仅在需要对比质量时才这么配。
|
||||
CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled") or "").strip()
|
||||
|
||||
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
|
||||
|
||||
def _tos_region() -> str:
|
||||
"""阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。"""
|
||||
explicit = (env("TOS_REGION") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
host = (env("TOS_ENDPOINT") or "").split("://", 1)[-1].split("/", 1)[0]
|
||||
if host.startswith("oss-") and host.endswith(".aliyuncs.com"):
|
||||
return host.split(".", 1)[0]
|
||||
return "cn-shanghai"
|
||||
|
||||
|
||||
TOS = {
|
||||
"endpoint": env("TOS_ENDPOINT"),
|
||||
"bucket": env("TOS_BUCKET"),
|
||||
"region": _tos_region(),
|
||||
"access_key_id": env("TOS_ACCESS_KEY_ID"),
|
||||
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
|
||||
# 换到阿里云之后,历史文件仍在火山引擎旧桶。按桶名把旧链接指回原地址。
|
||||
"legacy_endpoint": env("TOS_LEGACY_ENDPOINT", "https://tos-s3-cn-shanghai.volces.com"),
|
||||
"legacy_bucket": env("TOS_LEGACY_BUCKET", "airshelf"),
|
||||
}
|
||||
|
||||
VOLCANO = {
|
||||
|
||||
@@ -3,5 +3,16 @@ from .base import * # noqa: F403
|
||||
|
||||
DEBUG = True
|
||||
|
||||
# 本机 runserver 不在 K8s 的 service DNS 中;若直接沿用集群 Redis 地址,任何依赖
|
||||
# cache.get/cache.add 的普通读取接口都会变成 500。开发环境默认使用进程内缓存,确需
|
||||
# 联调分布式锁或 Celery 时再显式设 AIRSHELF_DEV_USE_REDIS_CACHE=true。
|
||||
if not env_bool("AIRSHELF_DEV_USE_REDIS_CACHE", False): # noqa: F405
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "airshelf-development-cache",
|
||||
}
|
||||
}
|
||||
|
||||
# MySQL 连接复用(CONN_MAX_AGE / 健康检查 / connect_timeout)已上移到 base.py,
|
||||
# development 与 production 共用,此处不再重复设置。
|
||||
|
||||
@@ -12,6 +12,13 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# 本机 .env 常开 CREATION_AGENT_INLINE=true,走的是 transaction.on_commit + 后台线程;
|
||||
# 而 TestCase 的事务永远不提交,on_commit 回调不会触发 → agent turn 一轮都不跑,
|
||||
# 一整批编排测试会凭空变红(fake.calls 为空、卡片没落库)。测试必须走 eager Celery。
|
||||
# 队列同时钉成 airshelf.local,跳过 require_worker_task 的 broker 探测。
|
||||
CREATION_AGENT_INLINE = False
|
||||
CREATION_AGENT_TASK_QUEUE = "airshelf.local"
|
||||
|
||||
# 测试必须完全离线:base.py 的 Redis cache 会让 billing 等普通业务逻辑在
|
||||
# 单测中意外连到实际环境。测试用进程内缓存即可,既不依赖网络,也不会碰线上数据。
|
||||
CACHES = {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0009_team_price_multiplier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="team",
|
||||
name="is_personal",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -40,6 +40,9 @@ class Team(TimeStampedModel):
|
||||
# 视频类按实结算用**下单时的快照系数**(request_payload.price_multiplier),中途改价不影响在途任务。
|
||||
# 仅平台超管可改(admin 团队定价端点,0.10~10.00 边界)。
|
||||
price_multiplier = models.DecimalField(max_digits=5, decimal_places=2, default=1)
|
||||
# 个人团队:后台直建用户时自动配的一人队,只当这个用户的积分钱包(积分账户是 OneToOne 挂 Team 的,
|
||||
# 所以「给用户发积分」在数据层仍然是给团队发)。团队体系一行没改,只是这类队不进后台团队列表。
|
||||
is_personal = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -103,6 +105,21 @@ class SingleDeviceLoginTests(TestCase):
|
||||
self.assertEqual(active.count(), 1)
|
||||
self.assertEqual(active.get().user_agent, "Device B")
|
||||
|
||||
def test_login_succeeds_when_device_audit_write_fails(self):
|
||||
"""设备审计表异常不能回滚已经签发的登录 Token。"""
|
||||
with patch(
|
||||
"apps.accounts.views.LoginSession.objects.create",
|
||||
side_effect=IntegrityError("device audit unavailable"),
|
||||
):
|
||||
response = APIClient().post(
|
||||
"/api/auth/login/",
|
||||
{"username": self.user.username, "password": "strong-password"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("token", response.data)
|
||||
|
||||
|
||||
class InvitationFlowTests(TestCase):
|
||||
def _register(self, client, username, **extra):
|
||||
|
||||
@@ -99,6 +99,9 @@ def issue_single_device_token(request, user):
|
||||
LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||
Token.objects.filter(user=locked_user).delete()
|
||||
token = Token.objects.create(user=locked_user)
|
||||
# 设备记录只用于设置页展示和审计,不属于 Token 签发的关键路径。此前它在
|
||||
# 同一事务里即使被 record_login_session 捕获,数据库事务仍可能已被标记为
|
||||
# rollback,离开 atomic 后登录照样变成 500。
|
||||
record_login_session(request, locked_user)
|
||||
return token
|
||||
|
||||
|
||||
@@ -98,20 +98,57 @@ class AdminTeamMemberSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
def wallet_membership(user):
|
||||
"""用户的钱包归属:第一个 active 成员关系(与 common.api.get_current_team 同口径,消费从这个团队扣)。
|
||||
列表页依赖 prefetch 好的 team_memberships,这里只在内存里挑,不要改成 .filter() 否则每行多一次查询。"""
|
||||
actives = [m for m in user.team_memberships.all() if m.status == TeamMember.Status.ACTIVE]
|
||||
return min(actives, key=lambda m: m.created_at) if actives else None
|
||||
|
||||
|
||||
class AdminUserSerializer(serializers.ModelSerializer):
|
||||
teams = serializers.SerializerMethodField()
|
||||
# 后台按「用户」视角管积分,但账户实际挂在团队上。这里把钱包团队拍平给前端:
|
||||
# wallet_shared=True 表示这是个多人共享池(老用户),发积分弹窗要明确提示钱会进整个团队。
|
||||
balance = serializers.SerializerMethodField()
|
||||
wallet_team = serializers.SerializerMethodField()
|
||||
wallet_team_name = serializers.SerializerMethodField()
|
||||
wallet_shared = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "status", "is_platform_admin", "date_joined", "teams"]
|
||||
fields = [
|
||||
"id", "username", "first_name", "status", "is_platform_admin", "date_joined", "teams",
|
||||
"balance", "wallet_team", "wallet_team_name", "wallet_shared",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_teams(self, obj):
|
||||
return [
|
||||
{"team_id": str(m.team_id), "team_name": m.team.name, "role": m.role}
|
||||
for m in obj.team_memberships.select_related("team").all()
|
||||
for m in obj.team_memberships.all()
|
||||
if not m.team.is_personal # 个人团队是钱包实现细节,不当「所属团队」展示
|
||||
]
|
||||
|
||||
def get_balance(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
acct = getattr(m.team, "credit_account", None) if m else None
|
||||
return str(acct.balance) if acct is not None else "0"
|
||||
|
||||
def get_wallet_team(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
return str(m.team_id) if m else None
|
||||
|
||||
def get_wallet_team_name(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
return m.team.name if m else None
|
||||
|
||||
def get_wallet_shared(self, obj):
|
||||
m = wallet_membership(obj)
|
||||
if m is None:
|
||||
return False
|
||||
count = getattr(m, "team_member_count", None)
|
||||
return bool((count if count is not None else m.team.members.count()) > 1)
|
||||
|
||||
|
||||
class AdminTaskSerializer(serializers.ModelSerializer):
|
||||
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.test import APIClient
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import AdminAuditLog, Invitation, Team, TeamMember, User
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
|
||||
|
||||
class AdminInvitationApiTests(TestCase):
|
||||
@@ -850,3 +851,116 @@ class AdminGovernanceTests(TestCase):
|
||||
self.assertGreaterEqual(checks["teams_without_account"], 1) # GovTeam 无 credit_account
|
||||
self.assertIn("totals", r.data)
|
||||
self.assertGreaterEqual(r.data["totals"]["projects"], 2)
|
||||
|
||||
|
||||
class AdminUserProvisioningTests(TestCase):
|
||||
"""后台直建用户 + 按用户发积分。积分账户仍挂 Team(计费链路不变),
|
||||
新用户配一个 is_personal 的一人团队当专属钱包;老的多人团队用户发积分则进共享池。"""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username="padmin", password="x", is_platform_admin=True)
|
||||
self.normal = User.objects.create_user(username="normal", password="x")
|
||||
self.team = Team.objects.create(name="Legacy Team", owner=self.normal)
|
||||
TeamMember.objects.create(team=self.team, user=self.normal, role=TeamMember.Role.OWNER)
|
||||
self.mate = User.objects.create_user(username="mate", password="x")
|
||||
TeamMember.objects.create(team=self.team, user=self.mate, role=TeamMember.Role.MEMBER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("100"))
|
||||
self.ac = APIClient()
|
||||
self.ac.force_authenticate(self.admin)
|
||||
self.nc = APIClient()
|
||||
self.nc.force_authenticate(self.normal)
|
||||
|
||||
def _create(self, **overrides):
|
||||
payload = {
|
||||
"username": "creat1",
|
||||
"display_name": "测试用户",
|
||||
"password": "strong-pass-1",
|
||||
"initial_credits": "500",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return self.ac.post("/api/admin/users/", payload, format="json")
|
||||
|
||||
def test_create_user_provisions_personal_wallet(self):
|
||||
r = self._create()
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["balance"], "500.0000")
|
||||
self.assertFalse(r.data["wallet_shared"])
|
||||
self.assertEqual(r.data["teams"], []) # 个人团队是钱包实现细节,不当「所属团队」露出
|
||||
|
||||
user = User.objects.get(username="creat1")
|
||||
self.assertEqual(user.first_name, "测试用户")
|
||||
team = Team.objects.get(owner=user)
|
||||
self.assertTrue(team.is_personal)
|
||||
self.assertEqual(team.credit_account.balance, Decimal("500"))
|
||||
ledger = CreditLedger.objects.get(team=team, ledger_type=CreditLedger.Type.RECHARGE)
|
||||
self.assertEqual(ledger.metadata["target_user_id"], str(user.id))
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="user.create").exists())
|
||||
|
||||
def test_created_user_can_login(self):
|
||||
self._create()
|
||||
r = APIClient().post("/api/auth/login/", {"username": "creat1", "password": "strong-pass-1"}, format="json")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_create_user_validation_and_permission(self):
|
||||
self.assertEqual(self.nc.post("/api/admin/users/", {}, format="json").status_code, 403)
|
||||
self.assertEqual(self._create(username="").status_code, 400)
|
||||
self.assertEqual(self._create(display_name="").status_code, 400)
|
||||
self.assertEqual(self._create(username="abc").status_code, 400) # 不足 6 位
|
||||
self.assertEqual(self._create(username="abc-12").status_code, 400) # 含非法字符
|
||||
self.assertEqual(self._create(username="abcdefg").status_code, 400) # 超过 6 位
|
||||
self.assertEqual(self._create(password="short").status_code, 400)
|
||||
self.assertEqual(self._create(initial_credits="-5").status_code, 400)
|
||||
# 准备一个 6 位账号占用,验证重名(大小写不敏感)
|
||||
User.objects.create_user(username="taken1", password="x")
|
||||
self.assertEqual(self._create(username="taken1").status_code, 400)
|
||||
self.assertEqual(self._create(username="TAKEN1").status_code, 400)
|
||||
self.assertEqual(User.objects.filter(username="creat1").count(), 0)
|
||||
|
||||
def test_create_user_without_initial_credits(self):
|
||||
r = self._create(initial_credits="")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["balance"], "0.0000")
|
||||
team = Team.objects.get(owner__username="creat1")
|
||||
self.assertEqual(team.name, "测试用户")
|
||||
self.assertFalse(CreditLedger.objects.filter(team=team).exists()) # 0 积分不落空流水
|
||||
|
||||
def test_adjust_user_credit(self):
|
||||
user_id = self._create().data["id"]
|
||||
r = self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "250", "reason": "活动赠送"}, format="json")
|
||||
self.assertEqual(r.status_code, 201)
|
||||
self.assertEqual(r.data["balance"], "750.0000")
|
||||
ledger = CreditLedger.objects.filter(ledger_type=CreditLedger.Type.ADJUSTMENT).latest("created_at")
|
||||
self.assertEqual(ledger.reason, "活动赠送")
|
||||
self.assertEqual(ledger.metadata["target_user_id"], user_id)
|
||||
self.assertTrue(AdminAuditLog.objects.filter(action="user.credit_adjust").exists())
|
||||
|
||||
# 扣减合法,但不能扣成负数
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-50"}, format="json").data["balance"], "700.0000")
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "-9999"}, format="json").status_code, 400)
|
||||
self.assertEqual(self.ac.post(f"/api/admin/users/{user_id}/credits/", {"amount": "0"}, format="json").status_code, 400)
|
||||
self.assertEqual(self.nc.post(f"/api/admin/users/{user_id}/credits/", {"amount": "1"}, format="json").status_code, 403)
|
||||
|
||||
def test_legacy_multi_member_user_uses_shared_pool(self):
|
||||
r = self.ac.get("/api/admin/users/?search=mate")
|
||||
row = next(u for u in r.data["results"] if u["username"] == "mate")
|
||||
self.assertEqual(row["balance"], "100.0000")
|
||||
self.assertTrue(row["wallet_shared"])
|
||||
self.assertEqual(row["wallet_team_name"], "Legacy Team")
|
||||
|
||||
self.ac.post(f"/api/admin/users/{row['id']}/credits/", {"amount": "40"}, format="json")
|
||||
self.team.credit_account.refresh_from_db()
|
||||
self.assertEqual(self.team.credit_account.balance, Decimal("140")) # 进的是团队共享池
|
||||
|
||||
def test_platform_admin_without_team_has_no_wallet(self):
|
||||
r = self.ac.get("/api/admin/users/?search=padmin")
|
||||
row = next(u for u in r.data["results"] if u["username"] == "padmin")
|
||||
self.assertIsNone(row["wallet_team"])
|
||||
self.assertEqual(row["balance"], "0")
|
||||
|
||||
def test_personal_teams_hidden_from_team_list(self):
|
||||
self._create()
|
||||
names = {t["name"] for t in self.ac.get("/api/admin/teams/").data["results"]}
|
||||
self.assertIn("Legacy Team", names)
|
||||
self.assertNotIn("created-user", names)
|
||||
all_names = {t["name"] for t in self.ac.get("/api/admin/teams/?include_personal=1").data["results"]}
|
||||
self.assertIn("created-user", all_names)
|
||||
|
||||
@@ -31,6 +31,7 @@ from .views import (
|
||||
admin_team_pricing,
|
||||
admin_team_toggle,
|
||||
admin_teams,
|
||||
admin_user_credits,
|
||||
admin_user_reset_password,
|
||||
admin_user_toggle,
|
||||
admin_users,
|
||||
@@ -47,6 +48,7 @@ urlpatterns = [
|
||||
path("users/", admin_users, name="admin-users"),
|
||||
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
|
||||
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
|
||||
path("users/<uuid:user_id>/credits/", admin_user_credits, name="admin-user-credits"),
|
||||
path("quality-words/", admin_quality_words, name="admin-quality-words"),
|
||||
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
|
||||
path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
|
||||
|
||||
@@ -3,21 +3,23 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.db.models import Case, CharField, Count, F, Q, Value, When
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Case, CharField, Count, F, Prefetch, Q, Value, When
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.accounts.audit import log_admin_action
|
||||
from apps.accounts.models import Invitation, Team, User
|
||||
from apps.accounts.models import Invitation, Team, TeamMember, User
|
||||
from apps.accounts.permissions import IsPlatformAdmin
|
||||
from apps.accounts.serializers import InvitationSerializer
|
||||
from apps.ai.model_catalog import invalidate_model_catalog_cache
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
||||
from apps.billing.models import BillingConfig, CreditLedger, QuotaPolicy
|
||||
from apps.billing.models import BillingConfig, CreditAccount, CreditLedger, QuotaPolicy
|
||||
from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache
|
||||
from apps.billing.services.ledger import adjust_credit
|
||||
from apps.common.pagination import DefaultPagination
|
||||
@@ -63,6 +65,48 @@ def _team_qs():
|
||||
)
|
||||
|
||||
|
||||
def _admin_user_qs():
|
||||
"""用户列表:把成员关系连同团队钱包一次性 prefetch 出来,序列化器才能免查询地拍出余额。
|
||||
team_member_count 用来判断钱包是不是多人共享池(决定发积分弹窗的提示文案)。"""
|
||||
memberships = (
|
||||
TeamMember.objects.select_related("team", "team__credit_account")
|
||||
.annotate(team_member_count=Count("team__members", distinct=True))
|
||||
.order_by("created_at")
|
||||
)
|
||||
return User.objects.prefetch_related(Prefetch("team_memberships", queryset=memberships))
|
||||
|
||||
|
||||
def _wallet_team(user):
|
||||
"""用户的钱包团队:第一个 active 成员关系(与 get_current_team 同口径)。无团队返回 None。"""
|
||||
membership = (
|
||||
user.team_memberships.filter(status=TeamMember.Status.ACTIVE)
|
||||
.select_related("team")
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
return membership.team if membership else None
|
||||
|
||||
|
||||
def _parse_points(raw, *, field: str, allow_negative: bool):
|
||||
"""解析后台传来的积分数量。积分全站按整数流通,这里统一 HALF_UP 取整(与计价引擎同口径)。
|
||||
返回 (Decimal, None) 或 (None, Response)。"""
|
||||
from decimal import InvalidOperation
|
||||
|
||||
try:
|
||||
value = Decimal(str(raw))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Decimal("NaN")/("Infinity") 构造不抛,进库才炸 500 → 显式拦
|
||||
if not value.is_finite():
|
||||
return None, Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
value = value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
if not allow_negative and value < 0:
|
||||
return None, Response({field: ["积分不能为负"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if abs(value) > Decimal("10000000"):
|
||||
return None, Response({field: ["单次不能超过 1,000 万积分"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return value, None
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_invitations(request):
|
||||
@@ -127,8 +171,11 @@ def admin_revoke_invitation(request, invite_id):
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_teams(request):
|
||||
"""列所有团队(跨团队,可按 status/search 过滤,分页)。"""
|
||||
"""列所有团队(跨团队,可按 status/search 过滤,分页)。
|
||||
默认隐藏个人团队(后台直建用户的钱包容器,只在用户页以「余额」形态出现),?include_personal=1 可看全。"""
|
||||
qs = _team_qs().order_by("-created_at")
|
||||
if str(request.query_params.get("include_personal") or "") not in {"1", "true"}:
|
||||
qs = qs.filter(is_personal=False)
|
||||
st = request.query_params.get("status")
|
||||
if st in dict(Team.Status.choices):
|
||||
qs = qs.filter(status=st)
|
||||
@@ -209,22 +256,139 @@ def admin_team_pricing(request, team_id):
|
||||
return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_users(request):
|
||||
"""列所有用户(跨团队,可按 status/search 过滤,分页)。"""
|
||||
qs = User.objects.prefetch_related("team_memberships__team").order_by("-date_joined")
|
||||
"""GET 列所有用户(可按 status/search 过滤,分页,每行带钱包余额);
|
||||
POST 平台超管直接开户(不走邀请码),顺带配一个个人团队当钱包。"""
|
||||
if request.method == "POST":
|
||||
return _admin_create_user(request)
|
||||
qs = _admin_user_qs().order_by("-date_joined")
|
||||
st = request.query_params.get("status")
|
||||
if st in dict(User.Status.choices):
|
||||
qs = qs.filter(status=st)
|
||||
search = (request.query_params.get("search") or "").strip()
|
||||
if search:
|
||||
qs = qs.filter(username__icontains=search)
|
||||
qs = qs.filter(Q(username__icontains=search) | Q(first_name__icontains=search))
|
||||
paginator = DefaultPagination()
|
||||
page = paginator.paginate_queryset(qs, request)
|
||||
return paginator.get_paginated_response(AdminUserSerializer(page, many=True).data)
|
||||
|
||||
|
||||
def _admin_create_user(request):
|
||||
"""后台直建用户:{username, display_name?, password, initial_credits?}。
|
||||
|
||||
username = 登录账号(仅英文+数字,固定 6 位);display_name = 展示用用户名(可中文)。
|
||||
积分账户是 OneToOne 挂 Team 的,所以这里给新用户配一个 is_personal 的一人团队当专属钱包 ——
|
||||
计费链路(预留/实扣/流水/额度)一行不用改,后台却能按「用户」视角管人和钱。
|
||||
后续要恢复多人协作,把人拉进同一个团队即可,不需要迁数据。"""
|
||||
import re
|
||||
|
||||
username = str(request.data.get("username") or "").strip()
|
||||
display_name = str(
|
||||
request.data.get("display_name")
|
||||
or request.data.get("name")
|
||||
or ""
|
||||
).strip()
|
||||
password = str(request.data.get("password") or "").strip()
|
||||
if not display_name:
|
||||
return Response({"display_name": ["请填写用户名"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(display_name) > 64:
|
||||
return Response({"display_name": ["用户名不能超过 64 字"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not username:
|
||||
return Response({"username": ["请填写登录账号"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{6}", username):
|
||||
return Response(
|
||||
{"username": ["登录账号须为 6 位英文或数字,不能含其他字符"]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# 统一小写入库,避免 Abc123 / abc123 被当成两个账号
|
||||
username = username.lower()
|
||||
if User.objects.filter(username__iexact=username).exists():
|
||||
return Response({"username": ["该登录账号已存在,不能重复"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(password) < 8:
|
||||
return Response({"password": ["密码至少 8 位"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
initial, err = _parse_points(request.data.get("initial_credits") or 0, field="initial_credits", allow_negative=False)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
with transaction.atomic():
|
||||
user = User.objects.create_user(
|
||||
username=username,
|
||||
password=password,
|
||||
first_name=display_name,
|
||||
)
|
||||
# 个人团队名用展示名,侧栏第一行显示用户名、第二行显示登录账号
|
||||
team = Team.objects.create(name=display_name, owner=user, is_personal=True)
|
||||
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=team, balance=initial)
|
||||
if initial > 0:
|
||||
# 开户额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(与注册赠送同口径)
|
||||
CreditLedger.objects.create(
|
||||
team=team,
|
||||
user=request.user,
|
||||
ledger_type=CreditLedger.Type.RECHARGE,
|
||||
amount=initial,
|
||||
balance_after=initial,
|
||||
reason="后台开户初始积分",
|
||||
metadata={"kind": "admin_create_user", "target_user_id": str(user.id)},
|
||||
)
|
||||
|
||||
log_admin_action(
|
||||
request,
|
||||
"user.create",
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
target_name=user.username,
|
||||
after={
|
||||
"initial_credits": str(initial),
|
||||
"wallet_team": str(team.id),
|
||||
"display_name": display_name,
|
||||
},
|
||||
)
|
||||
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_user_credits(request, user_id):
|
||||
"""给用户发/扣积分:{amount, reason?}。amount 可正可负,落 ADJUSTMENT 流水。
|
||||
|
||||
钱实际进的是这个用户的钱包团队 —— 个人团队即专属钱包;老用户若在多人团队里,
|
||||
这笔会进团队共享池(前端弹窗已明确提示,后端流水的 metadata 记下目标用户备查)。"""
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
if user is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
team = _wallet_team(user)
|
||||
if team is None:
|
||||
return Response({"detail": "该用户没有可用的积分钱包(无生效团队)"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
amount, err = _parse_points(request.data.get("amount"), field="amount", allow_negative=True)
|
||||
if err is not None:
|
||||
return err
|
||||
if amount == 0:
|
||||
return Response({"amount": ["调额金额不能为 0"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
reason = str(request.data.get("reason") or "").strip()
|
||||
try:
|
||||
ledger = adjust_credit(
|
||||
team=team,
|
||||
amount=amount,
|
||||
reason=reason or f"平台给 {user.username} 调额",
|
||||
operator=request.user,
|
||||
metadata={"target_user_id": str(user.id), "target_username": user.username},
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
log_admin_action(
|
||||
request,
|
||||
"user.credit_adjust",
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
target_name=user.username,
|
||||
after={"amount": str(amount), "balance_after": str(ledger.balance_after), "team": str(team.id), "reason": reason},
|
||||
)
|
||||
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_user_toggle(request, user_id):
|
||||
@@ -248,7 +412,7 @@ def admin_user_toggle(request, user_id):
|
||||
before={"status": before},
|
||||
after={"status": user.status},
|
||||
)
|
||||
return Response(AdminUserSerializer(user).data)
|
||||
return Response(AdminUserSerializer(_admin_user_qs().get(id=user.id)).data)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
|
||||
@@ -181,12 +181,21 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
|
||||
一张结果卡。任一片段先完成就先写回 GENERATING 卡供用户预览;所有分段完成后才转成
|
||||
可合并的结果卡,绝不在此处触发 ffmpeg。
|
||||
"""
|
||||
from .free_video import finalize_free_video
|
||||
from django.conf import settings
|
||||
|
||||
from .free_video import IN_FLIGHT_STATUSES, finalize_free_video, submit_free_video
|
||||
from .models import AITask
|
||||
|
||||
payload = message.payload or {}
|
||||
# 团队级锁:同一团队若有两支长视频同时回填,不能都按同一份剩余并发额度补交。
|
||||
lock_key = f"omni:segment-schedule:{message.conversation.team_id}"
|
||||
if not cache.add(lock_key, "1", timeout=90):
|
||||
return False
|
||||
try:
|
||||
message.refresh_from_db()
|
||||
payload = dict(message.payload or {})
|
||||
segments = [dict(item) for item in payload.get("segments") or [] if isinstance(item, dict)]
|
||||
ids = [str(value) for value in payload.get("task_ids") or [] if value]
|
||||
if not ids:
|
||||
if not ids or not segments:
|
||||
return False
|
||||
by_id = {
|
||||
str(task.id): task
|
||||
@@ -197,7 +206,7 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
|
||||
fail_generating_message(message, "分段视频任务不完整,请重新生成。")
|
||||
return True
|
||||
|
||||
# 本地没有 worker 时,用户的会话轮询本身即可推进每个片段;已在 worker 中的任务则幂等返回。
|
||||
# 本地没有 worker 时,会话轮询也能推进已提交片段;已在 worker 中则幂等返回。
|
||||
refreshed = []
|
||||
for task in tasks:
|
||||
assert task is not None
|
||||
@@ -210,52 +219,155 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
|
||||
task.refresh_from_db()
|
||||
refreshed.append(task)
|
||||
|
||||
segment_by_task = {
|
||||
str(item.get("task_id")): int(item.get("index") or index)
|
||||
for index, item in enumerate(segments, start=1)
|
||||
if item.get("task_id")
|
||||
}
|
||||
failed = next(
|
||||
(task for task in refreshed if task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED)),
|
||||
None,
|
||||
)
|
||||
if failed is not None:
|
||||
from .generation_errors import public_error_for_task
|
||||
|
||||
number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1)
|
||||
public_error = public_error_for_task(failed, operation="video_generate")
|
||||
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
|
||||
fail_generating_message(message, f"第 {number} 段生成失败:{detail}")
|
||||
return True
|
||||
assets: list[dict] = []
|
||||
completed_segments = 0
|
||||
for index, task in enumerate(refreshed, start=1):
|
||||
for task in refreshed:
|
||||
if task.status != AITask.Status.SUCCEEDED:
|
||||
continue
|
||||
task_assets = _assets_from_task(task)
|
||||
# 上游状态先成功、资产稍后才落库时,保留 GENERATING,下一轮再展示这段。
|
||||
if not task_assets:
|
||||
continue
|
||||
segment_index = segment_by_task.get(str(task.id), completed_segments + 1)
|
||||
completed_segments += 1
|
||||
for asset in task_assets:
|
||||
assets.append({**asset, "label": f"第 {index} 段", "segment_index": index})
|
||||
assets.append({**asset, "label": f"第 {segment_index} 段", "segment_index": segment_index})
|
||||
assets.sort(key=lambda item: int(item.get("segment_index") or 0))
|
||||
|
||||
if failed is not None:
|
||||
from .generation_errors import public_error_for_task
|
||||
|
||||
number = segment_by_task.get(str(failed.id), 1)
|
||||
public_error = public_error_for_task(failed, operation="video_generate")
|
||||
detail = public_error.fallback_message if public_error else (failed.error_message or "请重试")
|
||||
error_text = f"第 {number} 段生成失败:{detail}"
|
||||
# 取消仍在飞的其它分段,避免失败后继续扣费/刷进度。
|
||||
for task in refreshed:
|
||||
if task.id == failed.id:
|
||||
continue
|
||||
if task.status in IN_FLIGHT_STATUSES:
|
||||
from apps.billing.services.ledger import release_credit
|
||||
|
||||
task.status = AITask.Status.CANCELLED
|
||||
task.error_message = "同组其它分段已失败,已取消"
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
if task.credit_reservation is not None:
|
||||
try:
|
||||
release_credit(
|
||||
reservation=task.credit_reservation,
|
||||
reason="同组分段失败,取消未完成片段",
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if assets:
|
||||
first = next((task for task in refreshed if task.status == AITask.Status.SUCCEEDED), failed)
|
||||
finish_generating_message(
|
||||
message,
|
||||
assets=assets,
|
||||
meta={
|
||||
**_meta_from_task(first, message),
|
||||
"kind": "video_segments",
|
||||
"segment_count": len(segments),
|
||||
"total_duration": payload.get("total_duration") or "",
|
||||
"needs_merge": False,
|
||||
"partial_failure": True,
|
||||
"failed_segment_index": number,
|
||||
"error": error_text,
|
||||
"segments": segments,
|
||||
"task_ids": ids,
|
||||
},
|
||||
)
|
||||
conversation = message.conversation
|
||||
conversation.status = CreationConversation.Status.FAILED
|
||||
conversation.save(update_fields=["status", "updated_at"])
|
||||
append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ERROR,
|
||||
text=f"{error_text}。已保留成功片段供预览,未合并成片;可重新确认方案再生成。",
|
||||
)
|
||||
else:
|
||||
fail_generating_message(message, error_text)
|
||||
return True
|
||||
|
||||
# 释放出来的并发槽位自动补交下一批。首批与后续批次统一从 generation_spec 派生,
|
||||
# 因而复用同一份人物/商品参考、完整脚本和 seed。
|
||||
pending = [item for item in segments if not item.get("task_id")]
|
||||
if pending:
|
||||
in_flight = AITask.objects.filter(
|
||||
team=message.conversation.team,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=IN_FLIGHT_STATUSES,
|
||||
).count()
|
||||
slots = max(0, int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3)) - in_flight)
|
||||
spec = payload.get("generation_spec") if isinstance(payload.get("generation_spec"), dict) else {}
|
||||
if slots and not spec:
|
||||
fail_generating_message(message, "长视频续段参数不完整,请重新生成。")
|
||||
return True
|
||||
if slots:
|
||||
from .creation_agent import build_segment_video_submit
|
||||
|
||||
total_duration = int(payload.get("total_duration") or 0)
|
||||
submitted_now = 0
|
||||
try:
|
||||
for segment in pending[:slots]:
|
||||
task = submit_free_video(
|
||||
team=message.conversation.team,
|
||||
user=message.conversation.created_by,
|
||||
params=build_segment_video_submit(spec, segment, total_duration),
|
||||
)
|
||||
task_id = str(task.id)
|
||||
segment["task_id"] = task_id
|
||||
ids.append(task_id)
|
||||
request_payload = dict(task.request_payload or {})
|
||||
marker = dict(request_payload.get("omni_segment") or {})
|
||||
marker.update({
|
||||
"index": int(segment.get("index") or 0),
|
||||
"start": int(segment.get("start") or 0),
|
||||
"end": int(segment.get("end") or 0),
|
||||
"total_duration": total_duration,
|
||||
"message_id": str(message.id),
|
||||
})
|
||||
request_payload["omni_segment"] = marker
|
||||
task.request_payload = request_payload
|
||||
task.save(update_fields=["request_payload", "updated_at"])
|
||||
submitted_now += 1
|
||||
except ValueError as exc:
|
||||
if not submitted_now:
|
||||
fail_generating_message(message, f"后续分段提交失败:{exc}")
|
||||
return True
|
||||
# 逐段提交期间并发槽位被别的请求占用时,保留本轮已提交任务;余下片段下轮再补。
|
||||
payload["task_ids"] = ids
|
||||
payload["segments"] = segments
|
||||
|
||||
# 先完成的片段必须立刻回到前端,不能等另一段慢任务一起完成才出现。
|
||||
# 保持同一条 GENERATING 消息,避免把一个 60 秒视频拆成多条对话消息。
|
||||
if not all(task.status == AITask.Status.SUCCEEDED for task in refreshed):
|
||||
progress = {
|
||||
**payload,
|
||||
"assets": assets,
|
||||
"completed_segment_count": completed_segments,
|
||||
"submitted_segment_count": len(ids),
|
||||
}
|
||||
all_submitted = all(item.get("task_id") for item in segments)
|
||||
all_succeeded = all(task.status == AITask.Status.SUCCEEDED for task in refreshed)
|
||||
# 刚补交的新任务不在 refreshed 内,因此必须同时校验提交数,不能提前完成结果卡。
|
||||
if not all_submitted or len(refreshed) != len(segments) or not all_succeeded:
|
||||
if progress != payload:
|
||||
message.payload = progress
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
return True
|
||||
return False
|
||||
|
||||
# 任务都成功但有片段的资产还在落库,继续保持生成中,避免最终结果缺片。
|
||||
if completed_segments != len(refreshed):
|
||||
progress = {
|
||||
**payload,
|
||||
"assets": assets,
|
||||
"completed_segment_count": completed_segments,
|
||||
}
|
||||
# 状态已成功但资产尚未落库时继续等待,避免最终成片缺段。
|
||||
if completed_segments != len(segments):
|
||||
if progress != payload:
|
||||
message.payload = progress
|
||||
message.save(update_fields=["payload", "updated_at"])
|
||||
@@ -269,17 +381,42 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
|
||||
meta={
|
||||
**_meta_from_task(first, message),
|
||||
"kind": "video_segments",
|
||||
"segment_count": len(refreshed),
|
||||
"segment_count": len(segments),
|
||||
"total_duration": payload.get("total_duration") or "",
|
||||
"needs_merge": True,
|
||||
"segments": payload.get("segments") or [],
|
||||
"auto_merge": True,
|
||||
"segments": segments,
|
||||
"task_ids": ids,
|
||||
},
|
||||
)
|
||||
message.refresh_from_db()
|
||||
# 全部分段成功后由平台自动合并,不再等用户点「合并成片」。
|
||||
try:
|
||||
merge_task, _generating = start_segmented_video_merge(
|
||||
conversation=message.conversation,
|
||||
message=message,
|
||||
user=message.conversation.created_by,
|
||||
)
|
||||
try:
|
||||
from .tasks import merge_omni_video_segments_task
|
||||
|
||||
merge_omni_video_segments_task.apply_async(args=[str(merge_task.id)])
|
||||
except Exception: # noqa: BLE001
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(
|
||||
"omni auto video merge enqueue failed for %s", merge_task.id, exc_info=True
|
||||
)
|
||||
except ValueError:
|
||||
# 已在合并中或状态不允许时忽略,避免重复入队。
|
||||
pass
|
||||
return True
|
||||
finally:
|
||||
cache.delete(lock_key)
|
||||
|
||||
|
||||
def start_segmented_video_merge(*, conversation: CreationConversation, message: CreationMessage, user):
|
||||
"""用户明确点击后才建立合并任务;这之前绝不下载片段或调用 ffmpeg。"""
|
||||
"""建立合并任务(全部分段成功后由平台自动调用;也可由旧接口手动触发)。合并执行仍只在 run_segmented_video_merge。"""
|
||||
from .models import AITask
|
||||
|
||||
payload = dict(message.payload or {})
|
||||
@@ -406,9 +543,16 @@ def sync_generating_messages(conversation: CreationConversation) -> int:
|
||||
def sync_generating_for_task(task) -> int:
|
||||
"""worker / poll 终态后:只扫挂在这个任务上的 GENERATING。失败不能向外抛。"""
|
||||
try:
|
||||
from django.db.models import Q
|
||||
|
||||
marker = (task.request_payload or {}).get("omni_segment") or {}
|
||||
aggregate_message_id = str(marker.get("message_id") or "") if isinstance(marker, dict) else ""
|
||||
lookup = Q(task=task)
|
||||
if aggregate_message_id:
|
||||
lookup |= Q(id=aggregate_message_id)
|
||||
pending = list(
|
||||
CreationMessage.objects.filter(
|
||||
task=task, kind=CreationMessage.Kind.GENERATING,
|
||||
lookup, kind=CreationMessage.Kind.GENERATING,
|
||||
).select_related("conversation", "task", "task__model_config")
|
||||
)
|
||||
return sum(1 for message in pending if sync_generating_message(message))
|
||||
@@ -447,6 +591,17 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
).first()
|
||||
if asset is None:
|
||||
return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产")
|
||||
cast_index = int(original_payload.get("cast_index") or 1)
|
||||
cast_total = int(original_payload.get("cast_total") or 1)
|
||||
cast_model_name = str(original_payload.get("cast_model_name") or "").strip()
|
||||
is_pet = "宠物" in str(conversation.preset or "")
|
||||
if not cast_model_name:
|
||||
if is_pet:
|
||||
cast_model_name = "平台生成宠物角色"
|
||||
elif cast_total > 1:
|
||||
cast_model_name = f"平台生成出镜人物{cast_index}"
|
||||
else:
|
||||
cast_model_name = "平台生成出镜人物"
|
||||
model = Model.objects.filter(
|
||||
team=conversation.team,
|
||||
portrait_asset=asset,
|
||||
@@ -457,12 +612,24 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
model = Model.objects.create(
|
||||
team=conversation.team,
|
||||
created_by=conversation.created_by,
|
||||
name="平台生成出镜人物",
|
||||
name=cast_model_name,
|
||||
source=Model.Source.AI,
|
||||
portrait_asset=asset,
|
||||
description="由全能创作生成并锁定的视频出镜人物。",
|
||||
metadata={"feature": "omni_create", "conversation_id": str(conversation.id)},
|
||||
description=(
|
||||
"由全能创作生成并锁定的宠物角色。"
|
||||
if is_pet else
|
||||
"由全能创作生成并锁定的视频出镜人物。"
|
||||
),
|
||||
metadata={
|
||||
"feature": "omni_create",
|
||||
"conversation_id": str(conversation.id),
|
||||
"cast_index": cast_index,
|
||||
"cast_total": cast_total,
|
||||
},
|
||||
)
|
||||
elif model.name != cast_model_name and cast_total > 1:
|
||||
model.name = cast_model_name
|
||||
model.save(update_fields=["name", "updated_at"])
|
||||
pin_refs(conversation, [{
|
||||
"type": "model",
|
||||
"id": str(model.id),
|
||||
@@ -471,9 +638,37 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
}])
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source"] = "platform_generate"
|
||||
model_ids = [
|
||||
str(item)
|
||||
for item in (memory.get("person_cast_model_ids") or [])
|
||||
if str(item).strip()
|
||||
]
|
||||
model_id = str(model.id)
|
||||
if model_id not in model_ids:
|
||||
model_ids.append(model_id)
|
||||
memory["person_cast_model_ids"] = model_ids
|
||||
memory["person_model_id"] = model_id
|
||||
pending = int(memory.get("person_cast_pending") or cast_total or 1)
|
||||
pending = max(0, pending - 1)
|
||||
memory["person_cast_pending"] = pending
|
||||
memory["person_cast_total"] = int(memory.get("person_cast_total") or cast_total or 1)
|
||||
if pending > 0:
|
||||
# 还有其它角色定妆图在生成:继续等待,不打断用户确认
|
||||
memory["person_source_pending"] = True
|
||||
memory.pop("person_source_ready", None)
|
||||
memory.pop("person_confirm_pending", None)
|
||||
conversation.memory = memory
|
||||
conversation.status = CreationConversation.Status.RUNNING
|
||||
conversation.agent_status = CreationConversation.AgentStatus.IDLE
|
||||
conversation.last_active_at = timezone.now()
|
||||
conversation.save(update_fields=[
|
||||
"memory", "status", "agent_status", "last_active_at", "updated_at",
|
||||
])
|
||||
return message
|
||||
|
||||
memory["person_source_pending"] = False
|
||||
memory["person_source_ready"] = True
|
||||
memory["person_model_id"] = str(model.id)
|
||||
memory["person_confirm_pending"] = True
|
||||
conversation.memory = memory
|
||||
conversation.status = CreationConversation.Status.RUNNING
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
@@ -481,13 +676,39 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
conversation.save(update_fields=[
|
||||
"memory", "status", "agent_status", "last_active_at", "updated_at",
|
||||
])
|
||||
total = int(memory.get("person_cast_total") or 1)
|
||||
if is_pet:
|
||||
confirm_text = "宠物角色已生成。你看这只宠物合适吗?是否使用这个宠物角色继续创作?"
|
||||
reply_hint = "回复「使用这个角色」继续,或说明想要的宠物品种与外观…"
|
||||
regen = "重新生成一个宠物角色"
|
||||
upload = "我上传宠物参考图"
|
||||
upload_label = "上传其他宠物"
|
||||
elif total > 1:
|
||||
confirm_text = (
|
||||
f"已生成 {total} 位出镜角色定妆图。长视频会按这些图分别锁脸,"
|
||||
"避免前后形象漂移。你看这组角色合适吗?是否使用这些角色继续创作?"
|
||||
)
|
||||
reply_hint = "回复「使用这个角色」继续,或说明想调整哪一位…"
|
||||
regen = "重新生成一个角色"
|
||||
upload = "我上传人物参考图"
|
||||
upload_label = "上传其他人物"
|
||||
else:
|
||||
confirm_text = "人物参考已生成。你看这位角色合适吗?是否使用这个角色继续创作?"
|
||||
reply_hint = "回复「使用这个角色」继续,或说明想调整的地方…"
|
||||
regen = "重新生成一个角色"
|
||||
upload = "我上传人物参考图"
|
||||
upload_label = "上传其他人物"
|
||||
append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
text="人物参考已生成并锁定。后续所有镜头和长视频分段都会使用这位人物。",
|
||||
text=confirm_text,
|
||||
payload={
|
||||
"reply_hint": "继续创作…",
|
||||
"reply_options": [{"label": "继续创作", "text": "继续创作"}],
|
||||
"reply_hint": reply_hint,
|
||||
"reply_options": [
|
||||
{"label": "使用这个角色", "text": "使用这个角色继续创作"},
|
||||
{"label": "重新生成一个", "text": regen},
|
||||
{"label": upload_label, "text": upload},
|
||||
],
|
||||
},
|
||||
)
|
||||
return message
|
||||
|
||||
@@ -8,13 +8,22 @@ key 必须是同一个中文名(会话建的时候原样存进 CreationConversat
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
|
||||
_CLICK_SWAP_GUIDANCE = (
|
||||
"固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
|
||||
"画面只围绕同一件商品的逐款切换:商品始终居中、大小与角度不变,背景、灯光、机位不变。"
|
||||
"每次画面中的手指点击/轻触商品后,下一款在原位立即完成干净的 match cut 切换。"
|
||||
"禁止改成口播、剧情、使用教程、多场景展示、换人或商品飞舞变形;最后才用同一构图做全款式收束。"
|
||||
_CLICK_SWAP_FINGER_GUIDANCE = (
|
||||
"形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
|
||||
"画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。"
|
||||
"用手指点击/轻触触发换款,接触后以干净 match cut 切到下一款。"
|
||||
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
|
||||
"禁止完整人物出镜、禁止生成角色定妆图、禁止改成口播/剧情/使用教程;最后才用同一构图做全款式收束。"
|
||||
)
|
||||
_CLICK_SWAP_CHARACTER_GUIDANCE = (
|
||||
"形态:角色在日常使用场景中变色换款。锁定同一位角色与同一生活场景,角色自然使用或展示商品;"
|
||||
"商品颜色/款式必须按用户确认顺序切换,人物五官、发型、身形、基础服装与场景保持一致。"
|
||||
"允许自然动作与生活感,但不要改成口播带货腔、复杂剧情反转或频繁换景;结尾给出全款式收束。"
|
||||
)
|
||||
_CLICK_SWAP_GUIDANCE = _CLICK_SWAP_FINGER_GUIDANCE # 未选形态前的默认提示
|
||||
|
||||
VIDEO_PRESETS: dict[str, str] = {
|
||||
"痛点解决演示": (
|
||||
@@ -54,9 +63,12 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
"节奏清晰,转场干净。商品外观严格以参考图为准。"
|
||||
),
|
||||
"鱼眼换装": (
|
||||
"鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**,"
|
||||
"只有服装在变。每次换装用一个明确动作触发。"
|
||||
),
|
||||
"超广角鱼眼/近距离透视的连环换装短片。"
|
||||
"全片单一机位、同一构图与背景锁定;画面中心人物清晰、四边缘畸变拉伸,高饱和时尚质感,逐拍卡点剪辑。"
|
||||
"人物从第一镜起就已经穿着当前套装;换装发生在遮挡/甩头/甩镜等动态瞬间,瞬间切到已穿好的下一套。"
|
||||
"禁止拍穿衣服、套袖、拉拉链等穿戴过程。video_prompt 必须按「鱼眼连环换装」制作模板撰写"
|
||||
"(时长比例、标题、风格、单机位节拍、转场规则、主体、服装状态机、无对白卡点音效)。"
|
||||
),
|
||||
"点击换款": _CLICK_SWAP_GUIDANCE,
|
||||
"多色商品换款": _CLICK_SWAP_GUIDANCE,
|
||||
# 旧会话仍会保存「点触换款」,保留同一拍法约束以支持历史继续创作。
|
||||
@@ -76,6 +88,8 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
"AI 宠物拟人": (
|
||||
"先确定宠物的外观、性格和可执行动作,再设计一段轻巧剧情。宠物与商品互动要符合商品真实结构和正常操作方式;"
|
||||
"趣味来自角色反应和剧情,不把商品改造成不真实的玩具或凭空增加能力。"
|
||||
"有台词时必须是宠物第一人称开口(像这只宠物在说话),口语、性格化、短句;"
|
||||
"禁止旁白解说、达人口播腔、画外音旁白或第三人称讲述。"
|
||||
),
|
||||
"人物与音色替换": (
|
||||
"先理解原视频的镜头、人物、动作、台词、画外音、字幕、场景和节奏。只替换用户指定的人物、声音或两者;"
|
||||
@@ -118,10 +132,15 @@ _PLOT_TWIST_DEPTH_BY_VALUE = {item["value"]: item for item in PLOT_TWIST_STORY_D
|
||||
|
||||
|
||||
def plot_twist_story_depth(value: str) -> dict | None:
|
||||
"""兼容卡片 value、展示文案和自然语言里带的秒数。"""
|
||||
"""兼容卡片 value、展示文案和自然语言里带的秒数。
|
||||
|
||||
180 秒长视频入口已临时关闭:历史「180s」选择落到 60 秒档。
|
||||
"""
|
||||
raw = str(value or "").strip()
|
||||
if raw in _PLOT_TWIST_DEPTH_BY_VALUE:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE[raw]
|
||||
if "180" in raw or "90" in raw or "120" in raw:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
|
||||
if "60" in raw:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
|
||||
if "30" in raw:
|
||||
@@ -160,6 +179,18 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
"56-60 秒回扣商品价值并自然转化。商品可前置为伏笔但必须在后半段真正改变结局;"
|
||||
"禁止用重复对白、无意义空镜或硬插卖点填满时长。"
|
||||
)
|
||||
if depth["value"] == "180s":
|
||||
return (
|
||||
"【剧情反转带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
|
||||
"第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力,"
|
||||
"28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。"
|
||||
"第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据,"
|
||||
"在 105-120 秒完成中段转折,但不得提前总结或重复开场。"
|
||||
"第三章 120-180 秒:让前两章伏笔、人物选择和商品证据共同触发主要反转,165 秒前完成结果验证,"
|
||||
"165-176 秒释放人物情绪并回扣核心卖点,176-180 秒只做一次自然行动引导。"
|
||||
"每章都要推进新的因果关系;同一角色、服装、商品/SKU、场景空间和光线跨六个 30 秒分段连续,"
|
||||
"禁止重复对白、重复卖点、空镜凑时长或在章节边界换人换商品。"
|
||||
)
|
||||
return (
|
||||
"【剧情反转带货·智能推荐】先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒之一并说明理由;"
|
||||
"随后必须让用户选择实际时长,未确认前不得写剧情方向、策略、方案或出片指令。"
|
||||
@@ -176,23 +207,81 @@ def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) ->
|
||||
return base
|
||||
return f"{base}\n\n{contract}"
|
||||
|
||||
|
||||
def format_plot_twist_direction_contract(
|
||||
*,
|
||||
title: str = "",
|
||||
conflict: str = "",
|
||||
product_role: str = "",
|
||||
reversal: str = "",
|
||||
tone: str = "",
|
||||
detail: str = "",
|
||||
) -> str:
|
||||
"""把用户选定的剧情方向写成出片/方案硬约束。"""
|
||||
title = str(title or "").strip()
|
||||
conflict = str(conflict or "").strip()
|
||||
product_role = str(product_role or "").strip()
|
||||
reversal = str(reversal or "").strip()
|
||||
tone = str(tone or "").strip()
|
||||
detail = str(detail or "").strip()
|
||||
if not title and not detail and not any((conflict, product_role, reversal)):
|
||||
return ""
|
||||
lines = ["【剧情反转方向·强制执行·最高优先级】"]
|
||||
if title:
|
||||
lines.append(f"已选方向标题:{title}")
|
||||
if conflict:
|
||||
lines.append(f"开场冲突必须是:{conflict}")
|
||||
if product_role:
|
||||
lines.append(f"商品在剧情中的作用必须是:{product_role}")
|
||||
if reversal:
|
||||
lines.append(f"最终反转必须是:{reversal}")
|
||||
if tone:
|
||||
lines.append(f"情绪调性:{tone}")
|
||||
if detail and not any((conflict, product_role, reversal)):
|
||||
lines.append(f"方向细节:{detail}")
|
||||
lines.append(
|
||||
"策略、方案时间轴和 video_prompt 必须严格沿用上述冲突→商品作用→反转;"
|
||||
"禁止改成另一套常见带货故事(例如擅自换成「出门忘了喂宠/忘记带东西」等默认桥段),"
|
||||
"除非用户所选方向本身就是该桥段。"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflict: str = "", product_role: str = "", reversal: str = "", tone: str = "", detail: str = "") -> str:
|
||||
"""确定性把已选剧情方向写入方案/出片 prompt,避免模型另起炉灶。"""
|
||||
base = str(prompt or "").strip()
|
||||
contract = format_plot_twist_direction_contract(
|
||||
title=title,
|
||||
conflict=conflict,
|
||||
product_role=product_role,
|
||||
reversal=reversal,
|
||||
tone=tone,
|
||||
detail=detail,
|
||||
)
|
||||
if not base or not contract:
|
||||
return base
|
||||
if "【剧情反转方向·强制执行·最高优先级】" in base:
|
||||
return base
|
||||
return f"{base}\n\n{contract}"
|
||||
|
||||
|
||||
# 预设除了决定最终 Prompt,也决定 Agent 在素材整理、追问与生成前检查时的工作重点。
|
||||
# 这段进入系统提示词,避免把预设退化成一句风格修饰词。
|
||||
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
|
||||
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
|
||||
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
|
||||
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐;暂不开放更长时长),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
|
||||
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
|
||||
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
|
||||
"鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。",
|
||||
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
|
||||
"多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
|
||||
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
|
||||
"鱼眼换装": "优先确认人物参考、服装套数N与展示顺序、本条机位(低机位仰拍/高机位俯拍/荷兰角/贴地滑行/手持环绕等择一全片不变)、场景与转场方式;时长按 N×1.5–2.5 秒估算(约 4–15 秒);write_prompt 严格用「鱼眼连环换装」模板:节拍换装 + 服装状态机,无对白,禁止穿衣过程。",
|
||||
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
|
||||
"多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
|
||||
"点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;每次换款点击位置必须不同(换商品不同区域),禁止「每次点击同一位置 / 点同一点」。禁止转成剧情或口播。",
|
||||
"探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。",
|
||||
"品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。",
|
||||
"前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。",
|
||||
"AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;生成前核对宠物、商品结构和使用动作前后一致。",
|
||||
"AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;台词必须是宠物自己说的第一人称,禁止旁白/口播/画外音感;生成前核对宠物、商品结构和使用动作前后一致。",
|
||||
"人物与音色替换": "先确认原视频、替换对象和替换范围;未经要求不得改变原视频的构图、动作、商品、场景或节奏。",
|
||||
}
|
||||
|
||||
@@ -228,27 +317,39 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
|
||||
"最后以干净的产品收束。包装文字、颜色、比例和结构全程稳定,不能用 AI 生成的相似商品替代参考图。"
|
||||
),
|
||||
"鱼眼换装": (
|
||||
"【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。"
|
||||
"每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。"
|
||||
),
|
||||
"【预设执行层·鱼眼换装·小云雀模板】生成时尚换装短视频。"
|
||||
"比例默认 9:16;时长按服装套数 N 估算(每套约 1.5–2.5 秒,通常 4–15 秒)。"
|
||||
"风格:超广角鱼眼,中心清晰、四边缘明显畸变拉伸,高饱和,时尚质感,逐拍卡点剪辑。"
|
||||
"镜头:全片单一机位(本条自选一种并整条不变:低机位仰拍 / 高机位俯拍 / 荷兰角倾斜 / 贴地滑行 / 手持环绕);"
|
||||
"0–1 秒该机位对准主体、第 1 套造型定格 pose;之后逐拍换装,机位始终不变,仅服装与动作逐拍更新;最后一拍同机位定格收尾。"
|
||||
"转场:每次换装发生在遮挡/甩镜动态瞬间(抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影);"
|
||||
"转场瞬间服装切换,机位保持原位,下一拍回到同一构图。"
|
||||
"主体:只采用参考图五官、发型、妆容、姿态,不采用参考图服装与背景。"
|
||||
"服装状态机:S0 造型1 → 转场动作 → S1 造型2 → … → SN 定格收尾;"
|
||||
"全过程人物身份/体型/发型一致,机位/构图/背景锁定,仅服装逐拍变化;人物始终已穿好当前套,禁止穿戴过程镜头。"
|
||||
"声音:无对白,预留卡点 BGM,每次转场配快门/whoosh 音效。"
|
||||
),
|
||||
"点击换款": (
|
||||
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
|
||||
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
|
||||
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
|
||||
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
|
||||
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
|
||||
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
|
||||
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
|
||||
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
|
||||
),
|
||||
"多色商品换款": (
|
||||
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
|
||||
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
|
||||
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
|
||||
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
|
||||
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
|
||||
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
|
||||
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
|
||||
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
|
||||
),
|
||||
"点触换款": (
|
||||
"【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。"
|
||||
"使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。"
|
||||
"每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;"
|
||||
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
|
||||
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
|
||||
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;分镜与 video_prompt 要体现「本次点击位置不同于上次」,不要复读同一落点。"
|
||||
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。"
|
||||
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
|
||||
),
|
||||
@@ -267,6 +368,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
|
||||
"AI 宠物拟人": (
|
||||
"【预设执行层·AI 宠物拟人】宠物外观、体型、毛色和性格前后一致;趣味来自拟人动作与真实反应。"
|
||||
"宠物和商品的互动必须符合商品真实结构与正常用法,商品不变成玩具或获得不存在的能力;剧情里至少有一个可见卖点证据。"
|
||||
"人声必须是宠物角色第一人称台词(我/本汪/本喵等贴合物种的口吻),像宠物对着镜头或商品说话;"
|
||||
"禁止写成旁白解说、达人口播、画外音讲述或「今天给大家介绍…」类带货腔。"
|
||||
),
|
||||
"人物与音色替换": (
|
||||
"【预设执行层·人物与音色替换】严格保留原视频的构图、镜头顺序、动作、节奏、商品、场景和剪辑;"
|
||||
@@ -308,39 +411,173 @@ def preset_workflow_guidance(name: str) -> str:
|
||||
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "")
|
||||
|
||||
|
||||
def video_preset_delivery_contract(name: str) -> str:
|
||||
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
|
||||
return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "")
|
||||
_CLICK_SWAP_FINGER_CONTRACT = (
|
||||
"【预设执行层·点击换款·只出手】这不是口播片、剧情片或普通商品展示片。"
|
||||
"使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。"
|
||||
"换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;"
|
||||
"每一次换款的手指点击落点必须与上一次不同(点商品的不同区域/角点),"
|
||||
"禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;"
|
||||
"分镜与 video_prompt 要体现「本次点击位置不同于上次」。"
|
||||
"不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤、不生成角色定妆图。"
|
||||
"各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。"
|
||||
)
|
||||
_CLICK_SWAP_CHARACTER_CONTRACT = (
|
||||
"【预设执行层·点击换款·角色日常换款】锁定同一位角色与同一日常使用场景。"
|
||||
"角色在自然生活动作中展示/使用商品,商品颜色或款式按用户确认顺序切换;"
|
||||
"人物五官、发型、年龄感、身形与基础服装全程一致,场景与光线保持连贯。"
|
||||
"允许生活感动作,禁止口播带货腔、复杂剧情反转、频繁换景或随机换人;结尾给出全款式收束。"
|
||||
)
|
||||
|
||||
|
||||
def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str:
|
||||
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
|
||||
preset = (name or "").strip()
|
||||
if preset in CLICK_SWAP_PRESETS:
|
||||
if (click_swap_mode or "").strip() == "character":
|
||||
return _CLICK_SWAP_CHARACTER_CONTRACT
|
||||
return _CLICK_SWAP_FINGER_CONTRACT
|
||||
return VIDEO_PRESET_DELIVERY_CONTRACTS.get(preset, "")
|
||||
|
||||
|
||||
|
||||
FISH_EYE_OUTFIT_PRESET = "鱼眼换装"
|
||||
|
||||
_FISH_EYE_OUTFIT_PROMPT_TEMPLATE = """
|
||||
【鱼眼连环换装 · video_prompt 强制模板(来自小云雀写法)】
|
||||
当预设为「鱼眼换装」时,write_plan / write_prompt 的 video_prompt 必须按下面骨架填写,
|
||||
用会话里真实的人物、服装套数、机位、场景与顺序替换【】占位;不要改回通用「制作级分镜四行」长文,也不要编口播。
|
||||
|
||||
时长:【4-15秒,按N推算,每套服装约1.5-2.5秒】秒
|
||||
比例:9:16
|
||||
任务:生成时尚换装短视频。
|
||||
|
||||
标题:《鱼眼连环换装·【机位名】版》
|
||||
|
||||
风格与视觉参考:
|
||||
超广角鱼眼镜头风格,画面中心人物清晰、四边缘明显畸变拉伸,
|
||||
高饱和色彩,时尚质感,逐拍卡点剪辑。
|
||||
|
||||
镜头语言:
|
||||
全片单一机位:【本条视频机位描述,如:低机位仰拍 / 高机位俯拍 /
|
||||
荷兰角倾斜 / 贴地滑行 / 手持环绕——每条视频自选一种,整条不变】
|
||||
0-1秒:该机位对准【主体】,第1套造型定格pose
|
||||
之后逐拍换装,机位始终不变,仅服装与动作逐拍更新:
|
||||
节拍1:造型1,随节拍做动作
|
||||
节拍2:造型2,随节拍做动作
|
||||
节拍3:造型3,随节拍做动作
|
||||
……按N套延伸
|
||||
最后一拍:同机位稳定构图,定格收尾pose
|
||||
|
||||
转场规则:每次换装发生在遮挡/甩镜的动态瞬间——
|
||||
【转场方式:抬手遮挡镜头 / 甩头转身划过画面 / 镜头快速甩动出虚影,
|
||||
转场瞬间服装切换,机位保持原位,下一拍回到同一构图】
|
||||
|
||||
主体:
|
||||
主体1:【人物描述】,对应@图片1
|
||||
(只采用五官、发型、妆容、姿态,不采用服装与背景)
|
||||
|
||||
服装状态机:
|
||||
S0 造型1:【服装1描述】
|
||||
→ 动作:【转场方式】
|
||||
→ S1 造型2:【服装2描述】
|
||||
→ 动作:【转场方式】
|
||||
→ ……按N套延伸
|
||||
→ SN 造型N:【服装N描述】定格收尾
|
||||
全过程不变量:人物身份、体型、发型全程一致;
|
||||
机位、构图、背景全程锁定不变,仅服装逐拍变化;
|
||||
人物始终已穿好当前套,禁止穿衣/套袖/拉拉链等穿戴过程镜头。
|
||||
|
||||
声音:无对白,预留卡点BGM位,每次转场配快门/whoosh音效
|
||||
|
||||
全片保持主体1身份一致、鱼眼畸变风格统一、
|
||||
机位与构图全片同一,背景【场景描述】不变。
|
||||
"""
|
||||
|
||||
|
||||
def is_fish_eye_outfit_preset(name: str) -> bool:
|
||||
return (name or "").strip() == FISH_EYE_OUTFIT_PRESET
|
||||
|
||||
|
||||
def fish_eye_outfit_prompt_template() -> str:
|
||||
return _FISH_EYE_OUTFIT_PROMPT_TEMPLATE.strip()
|
||||
|
||||
def is_click_swap_preset(name: str) -> bool:
|
||||
return (name or "").strip() in CLICK_SWAP_PRESETS
|
||||
|
||||
|
||||
def apply_video_preset_prompt(name: str, prompt: str) -> str:
|
||||
|
||||
_SAME_CLICK_POS_RE = re.compile(
|
||||
r"(?:每次(?:都)?)?(?:点击|点触|轻触|手指(?:点击|点)?)(?:在|到)?(?:完全)?(?:相?同的?)?(?:一个位置|一(?:个)?位置|一点|同一处|同一点|同一个位置)"
|
||||
r"|每次点击同一位置"
|
||||
r"|点同一点"
|
||||
r"|手指点在完全相同的位置"
|
||||
r"|手指始终点同一"
|
||||
r"|点击位置保持不变"
|
||||
r"|固定点击位置"
|
||||
r"|不写点击同一位置"
|
||||
r"|不必强调每次点同一位置"
|
||||
)
|
||||
|
||||
|
||||
def scrub_click_swap_same_position_wording(text: str) -> str:
|
||||
"""去掉提示词里「每次点击同一位置」类表述,并在缺失时补上「每次点击位置不同」。"""
|
||||
raw = str(text or "")
|
||||
if not raw:
|
||||
return raw
|
||||
cleaned = _SAME_CLICK_POS_RE.sub("", raw)
|
||||
cleaned = re.sub(r"[,。;]{2,}", ",", cleaned)
|
||||
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
|
||||
if "点击位置必须不同" not in cleaned and "落点必须与上一次不同" not in cleaned and "落点必须不同于上一次" not in cleaned:
|
||||
cleaned = (
|
||||
f"{cleaned}\n\n【点击落点硬性要求】每一次换款的手指点击落点必须与上一次不同"
|
||||
"(点商品的不同区域/角点),禁止「每次点击同一位置 / 点同一点」。"
|
||||
).strip()
|
||||
return cleaned
|
||||
|
||||
|
||||
def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str:
|
||||
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
|
||||
base = str(prompt or "").strip()
|
||||
preset = (name or "").strip()
|
||||
contract = video_preset_delivery_contract(preset)
|
||||
mode = (click_swap_mode or "").strip()
|
||||
contract = video_preset_delivery_contract(preset, click_swap_mode=mode)
|
||||
if not base or not contract:
|
||||
return base
|
||||
marker = f"【视频预设】{preset}"
|
||||
if marker in base:
|
||||
return base
|
||||
if is_click_swap_preset(preset):
|
||||
# 换款是镜头结构,不是可有可无的风格词。把强制执行层放在前面,
|
||||
# 原始 Agent Prompt 只作为商品/SKU 事实来源;其中若有口播、剧情、运镜或换场景等拍法,不得执行。
|
||||
if mode == "character":
|
||||
return (
|
||||
f"{marker}\n{contract}\n\n"
|
||||
"【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式、顺序与角色/场景事实;"
|
||||
"下文任何与「同一角色、同一日常场景、按序换款」冲突的拍法一律忽略。\n"
|
||||
f"{base}\n\n"
|
||||
"【最终执行检查】全片锁定同一位角色与同一生活场景;"
|
||||
"商品颜色/款式严格按确认顺序切换,禁止随机换人、口播带货腔或复杂剧情反转。"
|
||||
)
|
||||
base = scrub_click_swap_same_position_wording(base)
|
||||
return (
|
||||
f"{marker}\n{contract}\n\n"
|
||||
"【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;"
|
||||
"下文任何与「固定机位、手指点击、商品原位换款」冲突的拍法一律忽略。\n"
|
||||
"下文任何与「固定机位、手指点击触发换款、构图稳定」冲突的拍法一律忽略。\n"
|
||||
f"{base}\n\n"
|
||||
"【最终执行检查】每一次换款都必须由画面内手指的一次清晰点击触发,"
|
||||
"切换前后商品中心点、尺寸、角度、背景、光线和机位不变。"
|
||||
"【最终执行检查】换款由手指点击触发,机位、背景、光线与商品构图保持稳定;"
|
||||
"每一次换款点击落点必须不同于上一次,禁止「每次点击同一位置 / 点同一点」;"
|
||||
"不出现完整人物、不生成角色图。"
|
||||
)
|
||||
if is_fish_eye_outfit_preset(preset):
|
||||
return (
|
||||
f"{marker}\n{contract}\n\n"
|
||||
"【原始人物与服装信息】仅提取下文中的人物身份、服装套数/顺序、机位、场景与转场事实;"
|
||||
"下文任何与「全片单机位、鱼眼畸变、节拍换装、服装状态机、无对白」冲突的拍法一律忽略。\n"
|
||||
f"{base}\n\n"
|
||||
"【最终执行检查】全片单一机位与构图锁定;鱼眼中心清晰、边缘畸变;"
|
||||
"换装发生在遮挡/甩镜瞬间且人物始终已穿好当前套;无对白,转场配快门/whoosh;"
|
||||
"禁止穿衣过程与口播带货腔。"
|
||||
)
|
||||
return f"{base}\n\n{marker}\n{contract}"
|
||||
|
||||
|
||||
def apply_image_preset_prompt(name: str, prompt: str) -> str:
|
||||
"""将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。"""
|
||||
base = str(prompt or "").strip()
|
||||
|
||||
@@ -44,6 +44,23 @@ FREE_VIDEO_MODELS = {
|
||||
"doubao-seedance-2-0-mini-260615",
|
||||
}
|
||||
HIGH_RES_MODEL = "doubao-seedance-2-0-260128" # 1080p/4k 仅标准档(火山限制)
|
||||
# 火山对 seed 的硬上限是 int32 正数上限;超一点整条请求直接 InvalidParameter 被拒。
|
||||
VIDEO_SEED_MAX = 2147483647
|
||||
|
||||
|
||||
def normalize_video_seed(value) -> int:
|
||||
"""把任何来源的 seed 收进火山认的区间。-1 = 不指定(随机)。
|
||||
|
||||
越界不报错而是折回区间内:长视频各段共用同一个确定性 seed 保一致性,
|
||||
折算规则固定,同一个会话每次算出来仍是同一个值。
|
||||
"""
|
||||
try:
|
||||
seed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
if seed < 0:
|
||||
return -1
|
||||
return seed & VIDEO_SEED_MAX
|
||||
# 视频复刻固定 Seedance 2.5(单次最长 30 秒)。原片只用来提炼分镜,不传给火山。
|
||||
REPLACE_MODEL = "doubao-seedance-2-5-260628"
|
||||
# 出片时长的兜底上限。真实上限按 ModelConfig.metadata.durations 取(见 model_duration_range),
|
||||
@@ -129,6 +146,29 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _reference_asset(team, asset_id) -> Asset | None:
|
||||
"""出片参考图允许两种来源:团队自有资产,以及官方模特库的人像/三视图。
|
||||
|
||||
官方模特挂在平台团队名下,按 team 过滤会直接查不到 —— 这是模特库里明明存在、
|
||||
生成时却报「不存在或已被删除」的根因。放行范围与视频复刻(video_replace)保持一致。
|
||||
"""
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.assets.models import Model as AssetModel
|
||||
|
||||
asset = Asset.objects.filter(id=asset_id, is_deleted=False).first()
|
||||
if asset is None:
|
||||
return None
|
||||
if asset.team_id == team.id:
|
||||
return asset
|
||||
is_official_model_asset = (
|
||||
AssetModel.objects.filter(is_official=True, is_deleted=False, purged_at__isnull=True)
|
||||
.filter(Q(portrait_asset=asset) | Q(triview_asset=asset))
|
||||
.exists()
|
||||
)
|
||||
return asset if is_official_model_asset else None
|
||||
|
||||
|
||||
def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None:
|
||||
"""三库引用前的审核闸(模块4 · 4.2)。
|
||||
|
||||
@@ -153,15 +193,20 @@ def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None
|
||||
if state == "allowed":
|
||||
return
|
||||
name = label or asset.name or "未命名"
|
||||
if state == "unavailable":
|
||||
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试")
|
||||
if state == "failed":
|
||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
||||
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
||||
waited = wait_upload_review(asset, timeout_s=20.0)
|
||||
if waited in ("active", "allowed"):
|
||||
if waited == "active":
|
||||
return
|
||||
if waited == "failed":
|
||||
err = (asset.review_error or "").strip()
|
||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"({err})" if err else ""))
|
||||
if waited == "unavailable":
|
||||
err = (asset.review_error or "").strip()
|
||||
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试" + (f"({err})" if err else ""))
|
||||
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
|
||||
|
||||
|
||||
@@ -317,7 +362,7 @@ def build_content_items(
|
||||
# 全能创作 resolve_refs / 前端常带 type=character|product 且 source 缺省为 upload,
|
||||
# 只要有 asset_id 就按 Asset 解析(含审核态 → asset://),避免直链分支把语义 type skipped。
|
||||
if ref.get("asset_id") and source in {"asset", "upload", ""}:
|
||||
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
|
||||
asset = _reference_asset(team, ref["asset_id"])
|
||||
if asset is None:
|
||||
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
|
||||
_guard_asset_reference(asset, label, ref_type)
|
||||
@@ -480,10 +525,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
duration = int(params.get("duration") or 5)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("时长参数无效")
|
||||
try:
|
||||
seed = int(params.get("seed") if params.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
seed = normalize_video_seed(params.get("seed"))
|
||||
|
||||
if not prompt:
|
||||
raise ValueError("提示词不能为空")
|
||||
@@ -637,10 +679,7 @@ def start_pending_free_video(task: AITask) -> AITask:
|
||||
duration = int(payload.get("duration") or 5)
|
||||
except (TypeError, ValueError):
|
||||
duration = 5
|
||||
try:
|
||||
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
seed = normalize_video_seed(payload.get("seed"))
|
||||
references = list(payload.get("references") or [])
|
||||
if feature == "video_replace" and payload.get("replace_mode") == "product":
|
||||
# 商品复刻:原片只用来提炼,绝不能进 Seedance,否则真人素材直接被火山拒。
|
||||
|
||||
@@ -13,6 +13,8 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.assets.models import Asset, Model
|
||||
from apps.products.models import Product
|
||||
|
||||
@@ -72,11 +74,15 @@ def _search_products(team, q: str, limit: int) -> list[dict]:
|
||||
|
||||
|
||||
def _search_models(team, q: str, limit: int) -> list[dict]:
|
||||
queryset = Model.objects.filter(team=team, is_deleted=False, purged_at__isnull=True)
|
||||
queryset = Model.objects.filter(
|
||||
Q(team=team) | Q(is_official=True),
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
)
|
||||
if q:
|
||||
queryset = queryset.filter(name__icontains=q)
|
||||
out = []
|
||||
for model in queryset.select_related("portrait_asset").order_by("-created_at")[:limit]:
|
||||
for model in queryset.select_related("portrait_asset").order_by("-is_official", "-created_at")[:limit]:
|
||||
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
|
||||
return out
|
||||
|
||||
@@ -173,7 +179,12 @@ def lookup_mention(team, value: str, types: list[str] | None = None) -> dict | N
|
||||
return _ref("product", product.id, product.title, _product_cover_url(product))
|
||||
if "model" in wanted:
|
||||
model = (
|
||||
Model.objects.filter(team=team, id=uid, is_deleted=False, purged_at__isnull=True)
|
||||
Model.objects.filter(
|
||||
Q(team=team) | Q(is_official=True),
|
||||
id=uid,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
)
|
||||
.select_related("portrait_asset")
|
||||
.first()
|
||||
)
|
||||
@@ -326,6 +337,25 @@ def _character_triview_asset(team, portrait_id):
|
||||
)
|
||||
|
||||
|
||||
def _newest_asset(*assets):
|
||||
"""多张可用图里取最新一张。已删/空值直接跳过。"""
|
||||
living = [
|
||||
asset for asset in assets
|
||||
if asset is not None and not getattr(asset, "is_deleted", False)
|
||||
]
|
||||
if not living:
|
||||
return None
|
||||
return max(living, key=lambda asset: (asset.created_at, str(asset.id)))
|
||||
|
||||
|
||||
def _single_entity_asset(cover, *triviews):
|
||||
"""一个角色/商品只喂一张参考图:有三视图用最新三视图,没有才用封面/立绘。
|
||||
|
||||
封面和三视图衣服经常不一致;两张一起送,出片模型会当成多出来的角色或 SKU。
|
||||
"""
|
||||
return _newest_asset(*triviews) or cover
|
||||
|
||||
|
||||
def _append_ref(resolved: ResolvedRefs, asset, type_: str, label: str) -> bool:
|
||||
entry = _asset_reference(asset, type_, label)
|
||||
if not entry:
|
||||
@@ -375,16 +405,24 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
resolved.facts.append(product_facts_text(product))
|
||||
chosen = _single_entity_asset(
|
||||
product.cover_asset,
|
||||
_product_triview_asset(product),
|
||||
)
|
||||
if chosen is not None and _append_ref(resolved, chosen, "product", product.title):
|
||||
continue
|
||||
entry = _product_reference(product)
|
||||
if entry:
|
||||
resolved.references.append(entry)
|
||||
triview = _product_triview_asset(product)
|
||||
if triview is not None:
|
||||
_append_ref(resolved, triview, "product", f"{product.title}三视图")
|
||||
elif chosen is None:
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
if type_ == "model":
|
||||
model = Model.objects.filter(
|
||||
team=team, id=ref_id, is_deleted=False, purged_at__isnull=True
|
||||
Q(team=team) | Q(is_official=True),
|
||||
id=ref_id,
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
).select_related("triview_asset", "portrait_asset").first()
|
||||
if model is None:
|
||||
resolved.missing.append(ref)
|
||||
@@ -399,13 +437,14 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
||||
person_lines.extend(meta_lines)
|
||||
if desc or meta_lines:
|
||||
resolved.facts.append("\n".join(person_lines))
|
||||
# 形象图和三视图都带上(有就带,没有不加、不报错)。三视图锁脸更稳。
|
||||
added = False
|
||||
if _append_ref(resolved, model.portrait_asset, "model", model.name):
|
||||
added = True
|
||||
if _append_ref(resolved, model.triview_asset, "model", f"{model.name}三视图"):
|
||||
added = True
|
||||
if not added:
|
||||
# 一人一张图:有三视图只用最新三视图。形象图和三视图衣服经常不一致,
|
||||
# 两张一起送会被当成多出来的角色。
|
||||
chosen = _single_entity_asset(
|
||||
model.portrait_asset,
|
||||
model.triview_asset,
|
||||
_character_triview_asset(team, getattr(model.portrait_asset, "id", None)),
|
||||
)
|
||||
if not _append_ref(resolved, chosen, "model", model.name):
|
||||
resolved.missing.append(ref)
|
||||
continue
|
||||
asset = Asset.objects.filter(
|
||||
@@ -421,15 +460,14 @@ def resolve_refs(team, refs: list[dict]) -> ResolvedRefs:
|
||||
person_lines.extend(meta_lines)
|
||||
if desc or meta_lines:
|
||||
resolved.facts.append("\n".join(person_lines))
|
||||
added = _append_ref(resolved, asset, type_, asset.name)
|
||||
# 角色/模特立绘若有配对三视图,一并带给出片;没有就跳过,不要当成引用失败。
|
||||
if type_ in {"character", "model", "asset"}:
|
||||
portrait_id = asset.id
|
||||
# 用户 @ 的就是三视图本身时,不再反查。
|
||||
if asset.category != Asset.Category.TRI_VIEW:
|
||||
paired = _character_triview_asset(team, portrait_id)
|
||||
if paired is not None:
|
||||
_append_ref(resolved, paired, type_, f"{asset.name}三视图")
|
||||
if type_ in {"character", "model", "asset"} and asset.category != Asset.Category.TRI_VIEW:
|
||||
chosen = _single_entity_asset(
|
||||
asset,
|
||||
_character_triview_asset(team, asset.id),
|
||||
)
|
||||
else:
|
||||
chosen = asset
|
||||
added = _append_ref(resolved, chosen, type_, asset.name)
|
||||
if not added:
|
||||
resolved.missing.append(ref)
|
||||
|
||||
|
||||
@@ -107,7 +107,13 @@ class VolcanoArkProvider:
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
if response.status_code >= 400:
|
||||
# 光有状态码定位不了问题:ARK 的参数校验原因只在响应体里(例如某个
|
||||
# 字段不被该模型支持)。带上正文再抛,否则每次都只能靠猜。
|
||||
detail = (response.text or "").strip()[:600]
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} from {endpoint}: {detail}", response=response
|
||||
)
|
||||
# SSE 响应常不带 charset,requests 会按 latin-1 解码 → 中文乱码。强制 UTF-8。
|
||||
response.encoding = "utf-8"
|
||||
for raw in response.iter_lines(decode_unicode=True):
|
||||
@@ -123,6 +129,11 @@ class VolcanoArkProvider:
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
# finish_reason=length 说明输出撞到 max_tokens 被截断。调用方必须能区分
|
||||
# 「模型没写」和「写了但被砍掉」,否则坏 JSON 只会被当成模型偷懒反复重试。
|
||||
finish = choices[0].get("finish_reason")
|
||||
if finish:
|
||||
yield {"type": "finish", "reason": str(finish)}
|
||||
delta = choices[0].get("delta") or {}
|
||||
# 推理模型(豆包 seed-pro / 部分中转 o系/gemini)思考阶段只发 reasoning_content,
|
||||
# 不发 content。必须单独转发,否则整个思考期(可达几十秒~分钟)前端零输出 = 假死。
|
||||
@@ -226,7 +237,14 @@ class VolcanoArkProvider:
|
||||
"generate_audio": generate_audio,
|
||||
}
|
||||
if seed is not None and seed != -1:
|
||||
body["seed"] = seed
|
||||
# Seedance 2.5 r2v 硬上限是 int32 正数(2147483647)。文档写 2^32-1,但 r2v 实际更严;
|
||||
# 超一点整条 InvalidParameter。这里兜住所有上游构造路径。
|
||||
try:
|
||||
clamped = int(seed) & 2147483647
|
||||
except (TypeError, ValueError):
|
||||
clamped = None
|
||||
if clamped:
|
||||
body["seed"] = clamped
|
||||
if search_mode == "smart":
|
||||
body["tools"] = [{"type": "web_search"}]
|
||||
response = requests.post(
|
||||
|
||||
@@ -10,6 +10,65 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
# 仅把可解释的阶段名返回给创作页。模型原始 reasoning 可能冗长、跑题或包含内部工作草稿,
|
||||
# 不能直接作为用户可见内容;前端据此展示「正在做什么」,避免异步规划看起来像卡住。
|
||||
_PUBLIC_AGENT_PROGRESS = {
|
||||
"starting": {
|
||||
"label": "正在读取创作要求",
|
||||
"detail": "已收到素材、角色与时长设定",
|
||||
},
|
||||
"reasoning": {
|
||||
"label": "正在分析素材与创作方向",
|
||||
"detail": "正在结合商品、角色和目标时长梳理方案",
|
||||
},
|
||||
"search_library": {
|
||||
"label": "正在查找可用素材",
|
||||
"detail": "正在核对当前创作需要的素材信息",
|
||||
},
|
||||
"write_strategy": {
|
||||
"label": "正在确定创作策略",
|
||||
"detail": "正在整理受众、核心卖点和整体表达方向",
|
||||
},
|
||||
"write_plan": {
|
||||
"label": "正在编排视频方案",
|
||||
"detail": "正在安排分段节奏,并锁定角色和商品的一致性",
|
||||
},
|
||||
"write_prompt": {
|
||||
"label": "正在整理出片指令",
|
||||
"detail": "正在把方案转成可直接生成的视频脚本",
|
||||
},
|
||||
"generate_image": {
|
||||
"label": "正在准备生成画面",
|
||||
"detail": "正在整理画面参考与生成条件",
|
||||
},
|
||||
"ask_user": {
|
||||
"label": "正在核对关键设定",
|
||||
"detail": "正在确认会影响成片效果的信息",
|
||||
},
|
||||
"responding": {
|
||||
"label": "正在整理回复内容",
|
||||
"detail": "马上把下一步呈现给你",
|
||||
},
|
||||
}
|
||||
|
||||
_PUBLIC_REASONING_DETAILS = {
|
||||
"brief": "正在理解这次创作的重点和限制",
|
||||
"product": "正在提炼商品卖点与可呈现的真实证据",
|
||||
"cast": "正在安排角色出镜方式与人物关系",
|
||||
"structure": "正在检查时长、分段节奏和前后衔接",
|
||||
"consistency": "正在锁定人物与商品在各段的一致性",
|
||||
"shots": "正在细化镜头、动作和出片表达",
|
||||
}
|
||||
|
||||
|
||||
def _public_agent_progress(phase: str, detail_key: str = "") -> dict[str, str]:
|
||||
"""把持久化的阶段键转换成用户可见的受控摘要。"""
|
||||
progress = dict(_PUBLIC_AGENT_PROGRESS.get(phase, _PUBLIC_AGENT_PROGRESS["starting"]))
|
||||
if phase == "reasoning":
|
||||
progress["detail"] = _PUBLIC_REASONING_DETAILS.get(detail_key, progress["detail"])
|
||||
return progress
|
||||
|
||||
|
||||
class ModelProviderSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ModelProvider
|
||||
@@ -114,17 +173,19 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
||||
|
||||
message_count = serializers.SerializerMethodField()
|
||||
cover_url = serializers.SerializerMethodField()
|
||||
agent_progress = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CreationConversation
|
||||
fields = [
|
||||
"id", "title", "mode", "preset", "params", "status",
|
||||
"agent_status", "agent_started_at",
|
||||
"agent_progress",
|
||||
"message_count", "cover_url",
|
||||
"last_active_at", "created_at", "updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id", "status", "agent_status", "agent_started_at",
|
||||
"id", "status", "agent_status", "agent_started_at", "agent_progress",
|
||||
"message_count", "cover_url",
|
||||
"last_active_at", "created_at", "updated_at",
|
||||
]
|
||||
@@ -149,6 +210,32 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
||||
first = assets[0] or {}
|
||||
return first.get("cover") or first.get("url") or ""
|
||||
|
||||
def get_agent_progress(self, obj):
|
||||
"""规划期间只返回受控阶段文案,绝不把模型原始 thinking 暴露给页面。"""
|
||||
if obj.agent_status != CreationConversation.AgentStatus.PLANNING:
|
||||
return None
|
||||
memory = obj.memory if isinstance(obj.memory, dict) else {}
|
||||
phase = str(memory.get("agent_progress_phase") or "")
|
||||
if not phase:
|
||||
return None
|
||||
detail_key = str(memory.get("agent_progress_detail_key") or "")
|
||||
progress = _public_agent_progress(phase, detail_key)
|
||||
history: list[dict[str, str]] = []
|
||||
for item in memory.get("agent_progress_history") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
history.append(
|
||||
_public_agent_progress(
|
||||
str(item.get("phase") or ""),
|
||||
str(item.get("detail_key") or ""),
|
||||
)
|
||||
)
|
||||
# 兼容服务端升级前已开始的轮次:至少显示当前一条进度。
|
||||
if not history or history[-1] != progress:
|
||||
history.append(progress)
|
||||
progress["history"] = history[-12:]
|
||||
return progress
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
|
||||
validated_data.pop("mode", None)
|
||||
|
||||
@@ -2991,7 +2991,7 @@ def notify_generation_failure(
|
||||
|
||||
|
||||
def _asset_preview_url(asset) -> str:
|
||||
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
|
||||
"""资产主文件的公读直链。写进会话的地址不能带签名,否则 1 小时后图片会消失。"""
|
||||
if asset is None:
|
||||
return ""
|
||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
@@ -2999,16 +2999,16 @@ def _asset_preview_url(asset) -> str:
|
||||
return ""
|
||||
if primary.preview_url:
|
||||
return primary.preview_url
|
||||
if not primary.object_key:
|
||||
return ""
|
||||
try:
|
||||
return TosStorage().presigned_get_url(object_key=primary.object_key)
|
||||
return TosStorage().public_url(object_key=primary.object_key, bucket=primary.bucket or None)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def asset_stable_url(asset) -> tuple[str, str]:
|
||||
"""资产的「长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个:
|
||||
预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。
|
||||
顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。"""
|
||||
"""资产的长期直链 (主文件, 封面图)。公读地址不过期,结果卡隔天还能打开。"""
|
||||
if asset is None:
|
||||
return "", ""
|
||||
files = list(asset.files.all())
|
||||
@@ -3031,10 +3031,6 @@ def asset_stable_url(asset) -> tuple[str, str]:
|
||||
return ""
|
||||
try:
|
||||
return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
return TosStorage().presigned_get_url(object_key=f.object_key)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
@@ -3389,6 +3385,10 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
|
||||
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
|
||||
# usage 缺失(异常响应)回落预估价,不阻断出片。
|
||||
# 提交那头(submit_video_segment)是函数内 import;这里漏了同一个名字,
|
||||
# 视频段一出片走到结算就 NameError 炸掉,钱没结、版本没建、段永远卡「生成中」。
|
||||
from apps.billing.pricing import settle_video_from_payload
|
||||
|
||||
reservation = locked_task.credit_reservation
|
||||
payload = dict(locked_task.request_payload or {})
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""全能创作 · 会话与消息底座(契约 §1/§3)。"""
|
||||
from django.test import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
@@ -208,6 +210,79 @@ class GenerationBackfillTests(TestCase):
|
||||
self.assertEqual(message.payload["assets"][0]["label"], "第 1 段")
|
||||
self.assertEqual(message.payload["assets"][0]["url"], "https://cdn.example/segment-1.mp4")
|
||||
|
||||
@override_settings(FREE_VIDEO_MAX_CONCURRENT=3)
|
||||
def test_long_video_submits_next_wave_after_first_wave_finishes(self):
|
||||
first_wave = []
|
||||
segments = []
|
||||
for index in range(1, 7):
|
||||
segment = {"index": index, "start": (index - 1) * 30, "end": index * 30, "duration": 30}
|
||||
if index <= 3:
|
||||
task = AITask.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
model_config=self.model,
|
||||
status=AITask.Status.SUCCEEDED,
|
||||
idempotency_key=f"k-wave-first-{index}",
|
||||
)
|
||||
self._asset(task, url=f"https://cdn.example/segment-{index}.mp4")
|
||||
first_wave.append(task)
|
||||
segment["task_id"] = str(task.id)
|
||||
segments.append(segment)
|
||||
message = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.GENERATING,
|
||||
task=first_wave[0],
|
||||
payload={
|
||||
"kind": "video_segments",
|
||||
"task_id": str(first_wave[0].id),
|
||||
"task_ids": [str(task.id) for task in first_wave],
|
||||
"total_duration": 180,
|
||||
"segments": segments,
|
||||
"generation_spec": {
|
||||
"prompt": "完整 180 秒连续脚本",
|
||||
"feature": "omni_create",
|
||||
"mode": "universal",
|
||||
"model": "doubao-seedance-2-5-260628",
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"duration": 180,
|
||||
"generate_audio": True,
|
||||
"references": [],
|
||||
"seed": 42,
|
||||
},
|
||||
},
|
||||
)
|
||||
created = []
|
||||
|
||||
def fake_submit(*, team, user, params):
|
||||
task = AITask.objects.create(
|
||||
team=team,
|
||||
created_by=user,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
model_config=self.model,
|
||||
status=AITask.Status.SUBMITTED,
|
||||
idempotency_key=f"k-wave-second-{len(created) + 1}",
|
||||
request_payload=params,
|
||||
)
|
||||
created.append(task)
|
||||
return task
|
||||
|
||||
with patch("apps.ai.free_video.submit_free_video", side_effect=fake_submit) as submit:
|
||||
self.assertEqual(sync_generating_messages(self.conversation), 1)
|
||||
|
||||
message.refresh_from_db()
|
||||
self.assertEqual(submit.call_count, 3)
|
||||
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
|
||||
self.assertEqual(message.payload["completed_segment_count"], 3)
|
||||
self.assertEqual(message.payload["submitted_segment_count"], 6)
|
||||
self.assertEqual(len(message.payload["task_ids"]), 6)
|
||||
self.assertTrue(all(item.get("task_id") for item in message.payload["segments"]))
|
||||
self.assertTrue(all(task.request_payload["seed"] == 42 for task in created))
|
||||
self.assertTrue(all(task.request_payload["omni_segment"]["message_id"] == str(message.id) for task in created))
|
||||
self.assertIn("第 4/6 段", created[0].request_payload["prompt"])
|
||||
|
||||
def test_retrieve_backfills_before_returning_messages(self):
|
||||
task = self._task(AITask.Status.SUCCEEDED, key="k-api")
|
||||
self._asset(task, url="https://cdn.example/api.png")
|
||||
|
||||
@@ -112,7 +112,8 @@ class ResolveRefsTests(TestCase):
|
||||
self.assertEqual(entry["review_status"], "active")
|
||||
self.assertEqual(entry["review_remote_id"], "R-1")
|
||||
|
||||
def test_model_ref_includes_portrait_and_triview(self):
|
||||
def test_model_ref_prefers_triview_over_portrait(self):
|
||||
"""一人一张图。形象图和三视图衣服经常不一致,两张一起送会被当成多出来的角色。"""
|
||||
portrait = _image_asset(self.team, self.user, "形象图", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p.jpg")
|
||||
triview = _image_asset(self.team, self.user, "三视图", Asset.Category.TRI_VIEW, url="https://cdn/t.jpg")
|
||||
model = Model.objects.create(
|
||||
@@ -121,7 +122,8 @@ class ResolveRefsTests(TestCase):
|
||||
)
|
||||
|
||||
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
|
||||
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/p.jpg", "https://cdn/t.jpg"])
|
||||
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/t.jpg"])
|
||||
self.assertEqual(resolved.references[0]["label"], "小夏")
|
||||
|
||||
def test_model_falls_back_to_portrait_when_no_triview(self):
|
||||
portrait = _image_asset(self.team, self.user, "形象图2", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p2.jpg")
|
||||
@@ -168,7 +170,7 @@ class ResolveRefsTests(TestCase):
|
||||
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
|
||||
self.assertIn("性别:男", resolved.facts_text)
|
||||
|
||||
def test_character_triview_is_attached_when_paired(self):
|
||||
def test_character_uses_triview_only_when_paired(self):
|
||||
self.person.metadata = {}
|
||||
self.person.save(update_fields=["metadata"])
|
||||
_image_asset(
|
||||
@@ -177,25 +179,39 @@ class ResolveRefsTests(TestCase):
|
||||
metadata={"triview_of": str(self.person.id)},
|
||||
)
|
||||
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
|
||||
urls = [r["url"] for r in resolved.references]
|
||||
self.assertIn("https://cdn/person.jpg", urls)
|
||||
self.assertIn("https://cdn/person-tri.jpg", urls)
|
||||
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/person-tri.jpg"])
|
||||
self.assertEqual(len(resolved.references), 1)
|
||||
|
||||
def test_missing_triview_does_not_mark_ref_missing(self):
|
||||
resolved = resolve_refs(self.team, [{"type": "character", "id": str(self.person.id)}])
|
||||
self.assertEqual(resolved.missing, [])
|
||||
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/person.jpg"])
|
||||
|
||||
def test_product_triview_is_attached_when_present(self):
|
||||
def test_product_uses_triview_only_when_present(self):
|
||||
_image_asset(
|
||||
self.team, self.user, "商品三视图", Asset.Category.PRODUCT_IMAGE,
|
||||
url="https://cdn/prod-tri.jpg",
|
||||
metadata={"product_id": str(self.product.id), "view": "three_view"},
|
||||
)
|
||||
resolved = resolve_refs(self.team, [{"type": "product", "id": str(self.product.id)}])
|
||||
urls = [r["url"] for r in resolved.references]
|
||||
self.assertIn("https://cdn/prod.jpg", urls)
|
||||
self.assertIn("https://cdn/prod-tri.jpg", urls)
|
||||
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/prod-tri.jpg"])
|
||||
self.assertEqual(resolved.references[0]["label"], "净颜精华")
|
||||
|
||||
def test_latest_triview_wins_when_several_exist(self):
|
||||
portrait = _image_asset(self.team, self.user, "形象图4", Asset.Category.MODEL_PORTRAIT, url="https://cdn/p4.jpg")
|
||||
old = _image_asset(self.team, self.user, "旧三视图", Asset.Category.TRI_VIEW, url="https://cdn/old-t.jpg")
|
||||
new = _image_asset(self.team, self.user, "新三视图", Asset.Category.TRI_VIEW, url="https://cdn/new-t.jpg")
|
||||
old.created_at = old.created_at.replace(year=2024)
|
||||
old.save(update_fields=["created_at"])
|
||||
model = Model.objects.create(
|
||||
team=self.team, created_by=self.user, name="周野",
|
||||
portrait_asset=portrait, triview_asset=old,
|
||||
)
|
||||
new.metadata = {"triview_of": str(portrait.id)}
|
||||
new.save(update_fields=["metadata"])
|
||||
|
||||
resolved = resolve_refs(self.team, [{"type": "model", "id": str(model.id)}])
|
||||
self.assertEqual([r["url"] for r in resolved.references], ["https://cdn/new-t.jpg"])
|
||||
|
||||
def test_product_facts_text_without_selling_points_still_has_title(self):
|
||||
bare = Product.objects.create(team=self.team, created_by=self.user, title="裸商品")
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
"""实体提取已经改成**本地**拆脚本:不调模型、不扣积分、不换模型重试。
|
||||
|
||||
这份文件原来锁的是「调模型 + 失败换模型 + 按 token 结算」那条链路(AIModelAttempt、
|
||||
CreditLedger、model_routing_v1)。那条链路已经随 submit_extract_entities 改成本地提取一起下线,
|
||||
`run_extract_entities_task` 现在没有任何地方会入队。所以这里改为锁住真正在跑的契约:
|
||||
提取必须零成本、不留模型调用痕迹,脚本缺 entities 时本地补齐。
|
||||
|
||||
落库内容本身(cast/scenes/entity_refs 回填)由 apps/projects/tests.py 的本地提取用例覆盖。
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||
from apps.ai.services import run_extract_entities_task, submit_extract_entities
|
||||
from apps.ai.services import submit_extract_entities
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project, ScriptSegment, ScriptVersion
|
||||
|
||||
|
||||
def _metadata(*, outbound=True, base_cost="0.50"):
|
||||
return {
|
||||
"routing": {"fallback_on_failure": outbound, "fallback_candidate": True},
|
||||
"capabilities": {
|
||||
"operations": ["chat"],
|
||||
"features": ["streaming", "structured_output"],
|
||||
},
|
||||
"pricing": {"base_cost_yuan": base_cost},
|
||||
}
|
||||
|
||||
|
||||
class EntityExtractionRoutingTests(TestCase):
|
||||
class LocalEntityExtractionTests(TestCase):
|
||||
def setUp(self):
|
||||
ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update(
|
||||
status=ModelConfig.Status.DISABLED
|
||||
)
|
||||
self.user = User.objects.create_user(username="entity-routing", password="x")
|
||||
self.team = Team.objects.create(name="Entity Routing", owner=self.user)
|
||||
self.user = User.objects.create_user(username="entity-local", password="x")
|
||||
self.team = Team.objects.create(name="Entity Local", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000"))
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品")
|
||||
@@ -37,222 +35,77 @@ class EntityExtractionRoutingTests(TestCase):
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="实体提取路由项目",
|
||||
name="实体提取项目",
|
||||
metadata={"cast": ["旧角色"], "entities_extracted": False},
|
||||
)
|
||||
provider = ModelProvider.objects.create(
|
||||
name="entity-local-provider",
|
||||
display_name="entity-local-provider",
|
||||
status=ModelProvider.Status.ACTIVE,
|
||||
)
|
||||
ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="entity-local-model",
|
||||
display_name="entity-local-model",
|
||||
capability=ModelConfig.Capability.TEXT,
|
||||
endpoint="chat/completions",
|
||||
unit_price=Decimal("10"),
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
)
|
||||
self.script = ScriptVersion.objects.create(
|
||||
project=self.project,
|
||||
title="脚本",
|
||||
content="结构化脚本",
|
||||
is_adopted=True,
|
||||
metadata={"entities": [
|
||||
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女主"},
|
||||
{"id": "s1", "type": "scene", "name": "客厅", "visual_prompt": "现代客厅"},
|
||||
]},
|
||||
)
|
||||
self.segment = ScriptSegment.objects.create(
|
||||
ScriptSegment.objects.create(
|
||||
script_version=self.script,
|
||||
sort_order=0,
|
||||
narration="女主在客厅展示商品",
|
||||
visual_prompt="女主站在客厅",
|
||||
entity_refs=["old"],
|
||||
)
|
||||
self.provider_mocks = {}
|
||||
patch("apps.ai.services.get_text_provider", side_effect=self._provider_for).start()
|
||||
patch("apps.ai.tasks.extract_entities_task.delay").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def provider(self, name, priority):
|
||||
return ModelProvider.objects.create(
|
||||
name=name,
|
||||
display_name=name,
|
||||
status=ModelProvider.Status.ACTIVE,
|
||||
metadata={"routing": {"fallback_priority": priority}},
|
||||
)
|
||||
def test_extraction_is_free_and_never_calls_a_model(self):
|
||||
with patch("apps.ai.services.get_text_provider") as get_provider:
|
||||
task = submit_extract_entities(project=self.project, user=self.user)
|
||||
|
||||
def model(self, provider, name, *, outbound=True, base_cost="0.50", is_default=False):
|
||||
return ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name=name,
|
||||
display_name=name,
|
||||
capability=ModelConfig.Capability.TEXT,
|
||||
endpoint="chat/completions",
|
||||
unit_price=Decimal("10"),
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
is_default=is_default,
|
||||
metadata=_metadata(outbound=outbound, base_cost=base_cost),
|
||||
)
|
||||
get_provider.assert_not_called()
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.request_payload.get("mode"), "local")
|
||||
self.assertEqual(task.estimated_cost, Decimal("0"))
|
||||
self.assertEqual(task.actual_cost, Decimal("0"))
|
||||
self.assertEqual(task.base_cost, Decimal("0"))
|
||||
# 不走模型就不该留下换模型痕迹,也不该有任何积分流水。
|
||||
self.assertFalse(task.model_attempts.exists())
|
||||
self.assertFalse(CreditLedger.objects.filter(task=task).exists())
|
||||
|
||||
@staticmethod
|
||||
def valid_events():
|
||||
return [
|
||||
{
|
||||
"type": "delta",
|
||||
"text": (
|
||||
'{"entities":['
|
||||
'{"id":"c1","type":"character","name":"女主","visual_prompt":"都市女主"},'
|
||||
'{"id":"s1","type":"scene","name":"客厅","visual_prompt":"现代客厅"}'
|
||||
'],"segments":[{"index":0,"entity_refs":["c1","s1"]}]}'
|
||||
),
|
||||
},
|
||||
{"type": "done"},
|
||||
]
|
||||
def test_entities_are_persisted_and_old_metadata_is_replaced(self):
|
||||
submit_extract_entities(project=self.project, user=self.user)
|
||||
|
||||
@staticmethod
|
||||
def invalid_events():
|
||||
return [{"type": "delta", "text": "这是一段没有 JSON 的解释"}, {"type": "done"}]
|
||||
|
||||
@classmethod
|
||||
def _new_provider_mock(cls):
|
||||
provider = Mock()
|
||||
provider.chat_completion_stream.return_value = cls.valid_events()
|
||||
return provider
|
||||
|
||||
def _provider_for(self, model):
|
||||
return self.provider_mocks.setdefault(model.id, self._new_provider_mock())
|
||||
|
||||
def submit(self):
|
||||
return submit_extract_entities(project=self.project, user=self.user)
|
||||
|
||||
@staticmethod
|
||||
def ledger_count(task, ledger_type):
|
||||
return CreditLedger.objects.filter(task=task, ledger_type=ledger_type).count()
|
||||
|
||||
def test_first_success_records_streaming_structured_attempt_and_persists_entities(self):
|
||||
primary = self.model(self.provider("entity-primary", 20), "entity-primary", is_default=True)
|
||||
task = self.submit()
|
||||
|
||||
run_extract_entities_task(task_id=str(task.id))
|
||||
|
||||
task.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.segment.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertTrue(task.request_payload["model_routing_v1"])
|
||||
attempt = task.model_attempts.get()
|
||||
self.assertEqual(attempt.model_config_id, primary.id)
|
||||
self.assertEqual(attempt.operation, "chat")
|
||||
self.assertTrue(attempt.request_summary["streaming"])
|
||||
self.assertTrue(attempt.request_summary["structured_output"])
|
||||
self.assertEqual(attempt.request_summary["business_operation"], "entity_extract")
|
||||
self.assertEqual(attempt.public_model_name, "AirShelf Script")
|
||||
self.assertTrue(self.project.metadata["entities_extracted"])
|
||||
self.assertEqual(self.project.metadata["cast"], ["女主"])
|
||||
self.assertEqual(self.project.metadata["scenes"], ["客厅"])
|
||||
self.assertEqual(self.segment.entity_refs, ["c1", "s1"])
|
||||
call = self.provider_mocks[primary.id].chat_completion_stream.call_args
|
||||
self.assertGreater(call.kwargs["timeout"], 0)
|
||||
self.assertEqual(call.kwargs["temperature"], 0.3)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
|
||||
metadata = self.project.metadata or {}
|
||||
self.assertTrue(metadata.get("entities_extracted"))
|
||||
self.assertIn("女主", metadata.get("cast") or [])
|
||||
self.assertNotIn("旧角色", metadata.get("cast") or [])
|
||||
|
||||
def test_invalid_structure_retries_then_falls_back_and_records_failed_call_costs(self):
|
||||
primary = self.model(
|
||||
self.provider("entity-fallback-primary", 100),
|
||||
"entity-primary",
|
||||
base_cost="0.25",
|
||||
def test_script_without_segments_is_rejected_before_any_task(self):
|
||||
empty_script = ScriptVersion.objects.create(
|
||||
project=self.project, title="空脚本", content="{}", is_adopted=True,
|
||||
)
|
||||
candidate = self.model(
|
||||
self.provider("entity-fallback-candidate", 10),
|
||||
"entity-candidate",
|
||||
outbound=False,
|
||||
base_cost="0.75",
|
||||
self.script.is_adopted = False
|
||||
self.script.save(update_fields=["is_adopted"])
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
submit_extract_entities(project=self.project, user=self.user)
|
||||
|
||||
self.assertFalse(
|
||||
AITask.objects.filter(
|
||||
project=self.project, task_type=AITask.Type.ENTITY_EXTRACTION
|
||||
).exists()
|
||||
)
|
||||
primary_mock = self._new_provider_mock()
|
||||
primary_mock.chat_completion_stream.side_effect = [
|
||||
self.invalid_events(),
|
||||
self.invalid_events(),
|
||||
self.invalid_events(),
|
||||
]
|
||||
self.provider_mocks[primary.id] = primary_mock
|
||||
task = self.submit()
|
||||
|
||||
run_extract_entities_task(task_id=str(task.id))
|
||||
|
||||
task.refresh_from_db()
|
||||
attempts = list(task.model_attempts.all())
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(
|
||||
[a.model_config_id for a in attempts],
|
||||
[primary.id, primary.id, primary.id, candidate.id],
|
||||
)
|
||||
self.assertEqual(
|
||||
[a.status for a in attempts],
|
||||
["failed", "failed", "failed", "succeeded"],
|
||||
)
|
||||
self.assertTrue(attempts[-1].is_fallback)
|
||||
self.assertTrue(attempts[0].response_summary["validation_failed"])
|
||||
self.assertEqual(task.base_cost, Decimal("1.5000"))
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0)
|
||||
|
||||
def test_all_candidates_fail_releases_and_preserves_old_project_metadata(self):
|
||||
primary = self.model(self.provider("entity-all-primary", 100), "entity-primary")
|
||||
fallback_1 = self.model(
|
||||
self.provider("entity-all-fallback-1", 10), "entity-fallback-1", outbound=False
|
||||
)
|
||||
fallback_2 = self.model(
|
||||
self.provider("entity-all-fallback-2", 20), "entity-fallback-2", outbound=False
|
||||
)
|
||||
for model in (primary, fallback_1, fallback_2):
|
||||
provider = self._new_provider_mock()
|
||||
provider.chat_completion_stream.side_effect = requests.ConnectionError("offline")
|
||||
self.provider_mocks[model.id] = provider
|
||||
task = self.submit()
|
||||
|
||||
run_extract_entities_task(task_id=str(task.id))
|
||||
|
||||
task.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.segment.refresh_from_db()
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertEqual(task.model_attempts.count(), 4)
|
||||
self.assertEqual(self.project.metadata["cast"], ["旧角色"])
|
||||
self.assertFalse(self.project.metadata["entities_extracted"])
|
||||
self.assertEqual(self.segment.entity_refs, ["old"])
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1)
|
||||
self.assertEqual(CreditAccount.objects.get(team=self.team).reserved_balance, Decimal("0"))
|
||||
|
||||
def test_direct_doubao_primary_retries_but_does_not_switch_when_outbound_disabled(self):
|
||||
primary = self.model(
|
||||
self.provider("doubao", 10), "doubao-seed-2-0-pro-260215", outbound=False
|
||||
)
|
||||
candidate = self.model(
|
||||
self.provider("entity-unused-candidate", 20), "entity-unused", outbound=False
|
||||
)
|
||||
provider = self._new_provider_mock()
|
||||
provider.chat_completion_stream.side_effect = requests.ConnectionError("offline")
|
||||
self.provider_mocks[primary.id] = provider
|
||||
task = self.submit()
|
||||
|
||||
run_extract_entities_task(task_id=str(task.id))
|
||||
|
||||
task.refresh_from_db()
|
||||
attempts = list(task.model_attempts.all())
|
||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||
self.assertEqual(
|
||||
[attempt.model_config_id for attempt in attempts],
|
||||
[primary.id, primary.id, primary.id],
|
||||
)
|
||||
self.assertFalse(any(attempt.model_config_id == candidate.id for attempt in attempts))
|
||||
self.assertTrue(all(attempt.public_model_name == primary.display_name for attempt in attempts))
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1)
|
||||
|
||||
def test_uses_database_default_text_model_not_older_doubao_pin(self):
|
||||
older = self.model(self.provider("volcengine-old", 20), "doubao-seed-2-0-pro-260215")
|
||||
default = self.model(
|
||||
self.provider("volcengine-default", 10),
|
||||
"doubao-seed-2-1-pro-260628",
|
||||
is_default=True,
|
||||
)
|
||||
task = self.submit()
|
||||
|
||||
run_extract_entities_task(task_id=str(task.id))
|
||||
|
||||
task.refresh_from_db()
|
||||
attempt = task.model_attempts.get()
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(attempt.model_config_id, default.id)
|
||||
self.assertNotEqual(attempt.model_config_id, older.id)
|
||||
self.assertEqual(task.request_payload["model"], "doubao-seed-2-1-pro-260628")
|
||||
|
||||
self.assertFalse(empty_script.segments.exists())
|
||||
|
||||
@@ -261,6 +261,21 @@ class SubmitFreeVideoTests(TestCase):
|
||||
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
|
||||
self.assertEqual(task.request_payload["feature"], "free_video")
|
||||
|
||||
def test_oversized_seed_is_clamped_before_volcano(self):
|
||||
"""全能创作长视频用会话 id 前 8 位十六进制当 seed,最大约 42.9 亿。
|
||||
Seedance 2.5 r2v 只接受 <= 2147483647,用户这次翻车的值就是 3857139456。"""
|
||||
from apps.ai.free_video import VIDEO_SEED_MAX, normalize_video_seed
|
||||
|
||||
raw = 3857139456
|
||||
task = submit_free_video(
|
||||
team=self.team, user=self.user, params=self._params(seed=raw)
|
||||
)
|
||||
sent = self.provider.create_video_task.call_args.kwargs["seed"]
|
||||
self.assertEqual(task.request_payload["seed"], normalize_video_seed(raw))
|
||||
self.assertEqual(sent, normalize_video_seed(raw))
|
||||
self.assertLessEqual(sent, VIDEO_SEED_MAX)
|
||||
self.assertNotEqual(sent, raw)
|
||||
|
||||
def test_insufficient_balance_leaves_nothing(self):
|
||||
CreditAccount.objects.filter(team=self.team).update(balance="0.0100")
|
||||
with self.assertRaisesMessage(ValueError, "余额不足"):
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Team, User
|
||||
from apps.ai.free_video import build_content_items
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.models import Asset, AssetFile, Model as AssetModel
|
||||
from apps.assets.review import reference_review_state
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def _asset(team, *, source, review_status="", review_remote_id="", category=Asse
|
||||
|
||||
|
||||
class ReferenceReviewStateTests(TestCase):
|
||||
"""审核判定四态。审核服务开着时才生效,关着一律放行(不能拿没配置的机制拦人)。"""
|
||||
"""审核判定四态。平台生成素材可免责,用户上传绝不能因审核停用而放行。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="refowner", password="p")
|
||||
@@ -83,9 +83,27 @@ class ReferenceReviewStateTests(TestCase):
|
||||
asset.save(update_fields=["in_library"])
|
||||
self.assertEqual(reference_review_state(asset), "unsubmitted")
|
||||
|
||||
def test_review_disabled_lets_everything_through(self):
|
||||
def test_review_disabled_still_blocks_uploaded_asset(self):
|
||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "allowed")
|
||||
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unavailable")
|
||||
|
||||
|
||||
class VideoSeedRangeTests(TestCase):
|
||||
"""火山 seed 上限是 int32 正数上限。长视频各段共用的确定性 seed 取会话 id 前 8 位
|
||||
十六进制,最大约 42.9 亿 —— 不收口整条请求会被 InvalidParameter 拒掉。"""
|
||||
|
||||
def test_seed_is_clamped_into_volcano_range(self):
|
||||
from apps.ai.free_video import VIDEO_SEED_MAX, normalize_video_seed
|
||||
|
||||
self.assertEqual(VIDEO_SEED_MAX, 2147483647)
|
||||
self.assertLessEqual(normalize_video_seed(3857139456), VIDEO_SEED_MAX)
|
||||
self.assertLessEqual(normalize_video_seed(0xFFFFFFFF), VIDEO_SEED_MAX)
|
||||
self.assertEqual(normalize_video_seed(12345), 12345)
|
||||
# 同一个值每次折算结果一致,跨段一致性不会因此漂移
|
||||
self.assertEqual(normalize_video_seed(3857139456), normalize_video_seed(3857139456))
|
||||
self.assertEqual(normalize_video_seed(None), -1)
|
||||
self.assertEqual(normalize_video_seed(-1), -1)
|
||||
self.assertEqual(normalize_video_seed("abc"), -1)
|
||||
|
||||
|
||||
class AssetReferenceBuildTests(TestCase):
|
||||
@@ -175,6 +193,44 @@ class AssetReferenceBuildTests(TestCase):
|
||||
self._build(asset)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
def test_official_model_asset_is_usable_across_teams(self):
|
||||
"""官方模特挂在平台团队名下。按 team 过滤会让模特库里明明在的模特,
|
||||
出片时报「不存在或已被删除」—— 放行范围与视频复刻保持一致。"""
|
||||
platform = Team.objects.create(
|
||||
name="PLATFORM",
|
||||
owner=User.objects.create_user(username="platform-owner", password="p"),
|
||||
)
|
||||
portrait = _asset(platform, source=Asset.Source.AI_GENERATED)
|
||||
AssetModel.objects.create(
|
||||
team=platform,
|
||||
name="阳光运动 · 周野",
|
||||
source=AssetModel.Source.AI,
|
||||
portrait_asset=portrait,
|
||||
is_official=True,
|
||||
)
|
||||
|
||||
built = self._build(portrait, label="阳光运动 · 周野")
|
||||
|
||||
self.assertEqual(built["image_n"], 1)
|
||||
self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png")
|
||||
|
||||
def test_non_official_model_asset_stays_team_scoped(self):
|
||||
"""只放行官方模特。别的团队自建模特的图仍然不可跨团队引用。"""
|
||||
stranger = Team.objects.create(
|
||||
name="STRANGER",
|
||||
owner=User.objects.create_user(username="stranger-owner", password="p"),
|
||||
)
|
||||
portrait = _asset(stranger, source=Asset.Source.AI_GENERATED)
|
||||
AssetModel.objects.create(
|
||||
team=stranger,
|
||||
name="别人家的模特",
|
||||
source=AssetModel.Source.AI,
|
||||
portrait_asset=portrait,
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self._build(portrait)
|
||||
self.assertIn("不存在", str(ctx.exception))
|
||||
|
||||
def test_keyframe_rejects_non_image(self):
|
||||
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
|
||||
asset.asset_type = Asset.Type.VIDEO
|
||||
|
||||
@@ -19,6 +19,20 @@ from apps.billing.models import CreditAccount
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project, ScriptVersion
|
||||
|
||||
# 这组只验「来源」徽标记得对不对,但稿子仍要真的过脚本自检(口播字数、画面 ≥110 字、秒级分镜),
|
||||
# 否则生成会先以「结果处理失败」收场,根本走不到落库那一步。
|
||||
VALID_NARRATION = (
|
||||
"下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。"
|
||||
"后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,"
|
||||
"整个人才慢慢醒过来,状态也稳下来。"
|
||||
)
|
||||
VALID_VISUAL = (
|
||||
"0-3s:近景;平视;固定机位;女主在画面右侧对镜头抬眼,工位键盘还亮着,窗外是下午的光\n"
|
||||
"3-8s:中近景;侧后方过肩;手持跟拍到桌面;右手把茶包放进盛了热水的玻璃杯,水面起蒸汽\n"
|
||||
"8-12s:特写;俯拍杯口;缓慢推近;茶汤从浅金慢慢变深,标签贴在杯沿\n"
|
||||
"12-15s:中近景;平视;拉回;女主双手捧杯喝一口,眉头松开肩膀塌下来"
|
||||
)
|
||||
|
||||
|
||||
class ScriptEntrySourceTests(TransactionTestCase):
|
||||
reset_sequences = True
|
||||
@@ -61,7 +75,7 @@ class ScriptEntrySourceTests(TransactionTestCase):
|
||||
def _provider(self, _model):
|
||||
raw = json.dumps(
|
||||
{
|
||||
"hook": "开场钩子",
|
||||
"hook": "下午三点工位犯困,键盘都敲不利索",
|
||||
"tone": "自然",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 15,
|
||||
@@ -72,7 +86,7 @@ class ScriptEntrySourceTests(TransactionTestCase):
|
||||
"segments": [
|
||||
{
|
||||
"index": 0, "duration": 15, "role": "钩子",
|
||||
"narration": "全新脚本口播", "visual": "女主展示商品",
|
||||
"narration": VALID_NARRATION, "visual": VALID_VISUAL,
|
||||
"speaker": "女主", "product_exposure": "展示",
|
||||
"entity_refs": ["c1"], "dialogue": [],
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ from apps.products.models import Product
|
||||
from apps.projects.models import Project, ScriptSegment, ScriptVersion
|
||||
|
||||
|
||||
# 15 秒一镜的合法口播与画面:口播要到 narration_floor(15),画面要 ≥110 字且带足秒级分镜、
|
||||
# 景别与运镜标记。路由测试只关心换模型和结算,但稿子仍要真的过得了脚本自检。
|
||||
VALID_NARRATION = (
|
||||
"下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。"
|
||||
"后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,"
|
||||
"整个人才慢慢醒过来,状态也稳下来。"
|
||||
)
|
||||
VALID_VISUAL = (
|
||||
"0-3s:近景;平视;固定机位;女主在画面右侧对镜头抬眼,工位键盘还亮着,窗外是下午的光\n"
|
||||
"3-8s:中近景;侧后方过肩;手持跟拍到桌面;右手把茶包放进盛了热水的玻璃杯,水面起蒸汽\n"
|
||||
"8-12s:特写;俯拍杯口;缓慢推近;茶汤从浅金慢慢变深,标签贴在杯沿\n"
|
||||
"12-15s:中近景;平视;拉回;女主双手捧杯喝一口,眉头松开肩膀塌下来"
|
||||
)
|
||||
|
||||
|
||||
def _metadata(*, outbound=True, base_cost="0.50"):
|
||||
return {
|
||||
"routing": {"fallback_on_failure": outbound, "fallback_candidate": True},
|
||||
@@ -69,8 +84,10 @@ class ScriptStreamRoutingTests(TransactionTestCase):
|
||||
|
||||
@staticmethod
|
||||
def valid_draft():
|
||||
# 口播字数、画面字数和秒级分镜都必须真的过 assert_shot_density / assert_script_has_a_hook;
|
||||
# 写成「全新脚本口播」这种占位文本会被当成模型偷懒直接判失败,整条流就退化成 error。
|
||||
return {
|
||||
"hook": "开场钩子",
|
||||
"hook": "下午三点工位犯困,键盘都敲不利索",
|
||||
"tone": "自然",
|
||||
"aspect_ratio": "9:16",
|
||||
"total_duration": 15,
|
||||
@@ -89,8 +106,8 @@ class ScriptStreamRoutingTests(TransactionTestCase):
|
||||
"index": 0,
|
||||
"duration": 15,
|
||||
"role": "钩子",
|
||||
"narration": "全新脚本口播",
|
||||
"visual": "女主展示商品",
|
||||
"narration": VALID_NARRATION,
|
||||
"visual": VALID_VISUAL,
|
||||
"speaker": "女主",
|
||||
"product_exposure": "展示",
|
||||
"entity_refs": ["c1"],
|
||||
|
||||
@@ -1907,6 +1907,51 @@ class ChatStreamReasoningTests(SimpleTestCase):
|
||||
self.assertEqual([e["text"] for e in events if e["type"] == "reasoning"], ["先想想", "用户要4镜"])
|
||||
self.assertEqual("".join(e["text"] for e in events if e["type"] == "delta"), "正在生成脚本…")
|
||||
|
||||
def test_length_finish_reason_is_forwarded(self):
|
||||
"""撞 max_tokens 的那一刀必须能被上层看见,否则截断的 tool 参数会被当成模型偷懒反复重试。"""
|
||||
lines = [
|
||||
"data: " + json.dumps({"choices": [{"delta": {"content": "前半段"}}]}, ensure_ascii=False),
|
||||
"data: " + json.dumps({"choices": [{"delta": {}, "finish_reason": "length"}]}, ensure_ascii=False),
|
||||
"data: [DONE]",
|
||||
]
|
||||
prov = VolcanoArkProvider(api_key="k", base_url="http://x")
|
||||
with patch("apps.ai.providers.volcano.requests.post", return_value=_FakeStreamResp(lines)):
|
||||
events = list(prov.chat_completion_stream(model="m", messages=[{"role": "user", "content": "hi"}]))
|
||||
|
||||
self.assertEqual([e["type"] for e in events], ["delta", "finish", "done"])
|
||||
self.assertEqual(events[1]["reason"], "length")
|
||||
|
||||
|
||||
class VolcanoVideoSeedTests(SimpleTestCase):
|
||||
"""Seedance 2.5 r2v 的 seed 上限是 int32,超了会 InvalidParameter。"""
|
||||
|
||||
def test_create_video_task_clamps_seed_to_int32(self):
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
ok = True
|
||||
status_code = 200
|
||||
text = "{}"
|
||||
|
||||
def json(self):
|
||||
return {"id": "ark-1"}
|
||||
|
||||
def _post(*_args, **kwargs):
|
||||
captured["body"] = kwargs["json"]
|
||||
return _Resp()
|
||||
|
||||
prov = VolcanoArkProvider(api_key="k", base_url="http://x")
|
||||
with patch("apps.ai.providers.volcano.requests.post", side_effect=_post):
|
||||
prov.create_video_task(
|
||||
model="doubao-seedance-2-5-260628",
|
||||
endpoint="contents/generations/tasks",
|
||||
prompt="test",
|
||||
seed=3857139456,
|
||||
)
|
||||
|
||||
self.assertEqual(captured["body"]["seed"], 3857139456 & 2147483647)
|
||||
self.assertLessEqual(captured["body"]["seed"], 2147483647)
|
||||
|
||||
|
||||
class WorkbenchAndUnreadTests(TestCase):
|
||||
"""R100(工作台记录后端持久化)+ R96(未读生成任务角标)+ R109(删除联动)端到端:
|
||||
|
||||
@@ -33,6 +33,7 @@ from .free_video import (
|
||||
IN_FLIGHT_STATUSES,
|
||||
RATIOS,
|
||||
RESOLUTIONS,
|
||||
normalize_video_seed,
|
||||
_reap_stale_free_video_tasks,
|
||||
model_duration_range,
|
||||
serialize_free_video_task,
|
||||
@@ -654,6 +655,21 @@ def video_replace_q() -> Q:
|
||||
return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
|
||||
|
||||
|
||||
def not_video_replace_q() -> Q:
|
||||
"""「不是视频复刻」。不能写成 exclude(video_replace_q())。
|
||||
|
||||
JSON 键不存在时取值是 SQL NULL,NOT(NULL = 'x') 仍是 NULL,整行会被 exclude 一起筛掉 ——
|
||||
于是 request_payload 里没有 feature 的历史任务在列表和回收站里凭空消失。必须把「键缺失」
|
||||
显式算作不匹配。
|
||||
"""
|
||||
return (
|
||||
Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature=FEATURE)
|
||||
) & (
|
||||
Q(request_payload__prompt__isnull=True)
|
||||
| ~Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _fresh_digest_source(payload: dict) -> dict | None:
|
||||
"""历史卡「原视频」用的原片快照。快照 URL 会过期,统一走 rehydrate_ref_urls 重新取长期直链。"""
|
||||
from .free_video import rehydrate_ref_urls
|
||||
@@ -1028,10 +1044,7 @@ def _legacy_start_pending_replace_shots(task):
|
||||
generate_audio = bool(payload.get("generate_audio", True))
|
||||
search_mode = str(payload.get("search_mode") or "off")
|
||||
feature = str(payload.get("feature") or FEATURE)
|
||||
try:
|
||||
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
seed = normalize_video_seed(payload.get("seed"))
|
||||
billed_duration = sum(int(item.get("seconds") or 0) for item in plan) or int(payload.get("duration") or 5)
|
||||
references = _seedance_references(payload)
|
||||
try:
|
||||
@@ -1238,10 +1251,7 @@ def _dispatch_next_replace_shot(task, shot: dict):
|
||||
locked.request_payload = next_payload
|
||||
locked.save(update_fields=["request_payload", "updated_at"])
|
||||
|
||||
try:
|
||||
seed = int(payload.get("seed") if payload.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
seed = normalize_video_seed(payload.get("seed"))
|
||||
dispatched = _dispatch_free_video_provider(
|
||||
task=locked,
|
||||
built=built,
|
||||
@@ -1403,7 +1413,9 @@ def _concat_shot_media(urls: list[str]) -> bytes:
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-threads", "2",
|
||||
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output),
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=180)
|
||||
# 180 秒成片最多 6 段,1080p 重编码明显可能超过旧的 180 秒固定超时。
|
||||
# 按片段数放宽,但仍保留上限式超时,避免异常 ffmpeg 永久占住 worker。
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=max(180, len(paths) * 120))
|
||||
if proc.returncode != 0 or not output.exists() or not output.stat().st_size:
|
||||
err = (proc.stderr or b"").decode("utf-8", errors="replace")[-500:]
|
||||
raise RuntimeError(f"镜头拼接失败:{err or 'ffmpeg 未产出文件'}")
|
||||
@@ -1605,10 +1617,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
|
||||
if available < reserve_amount:
|
||||
raise ValueError("团队余额不足,请充值后重试")
|
||||
|
||||
try:
|
||||
seed = int(params.get("seed") if params.get("seed") is not None else -1)
|
||||
except (TypeError, ValueError):
|
||||
seed = -1
|
||||
seed = normalize_video_seed(params.get("seed"))
|
||||
request_payload = {
|
||||
"feature": FEATURE,
|
||||
"mode": "universal",
|
||||
|
||||
@@ -39,12 +39,15 @@ from .creation_agent import (
|
||||
_ASSET_CARD_LABELS,
|
||||
_RESTART_CONTINUATION,
|
||||
apply_cast_relation_choice,
|
||||
apply_click_swap_mode,
|
||||
append_person_source_gate,
|
||||
apply_pain_point_direction,
|
||||
apply_confirm_params,
|
||||
apply_restart_intent,
|
||||
apply_session_params,
|
||||
emit_prompt_gate,
|
||||
emit_final_confirm_gate,
|
||||
locked_product_references,
|
||||
set_plot_twist_story_depth,
|
||||
is_greeting,
|
||||
is_pain_point_conversation,
|
||||
@@ -55,6 +58,8 @@ from .creation_agent import (
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
submit_generated_person_reference,
|
||||
is_incomplete_product_brand_answer,
|
||||
PRODUCT_BRAND_EMPTY_TEMPLATE,
|
||||
)
|
||||
from .tasks import run_creation_agent_turn_task
|
||||
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
|
||||
@@ -255,23 +260,43 @@ def _plot_twist_direction_continuation(
|
||||
)
|
||||
if isinstance(direction, dict):
|
||||
title = str(direction.get("title") or choice).strip()
|
||||
conflict = str(direction.get("conflict") or "").strip()
|
||||
product_role = str(direction.get("product_role") or "").strip()
|
||||
reversal = str(direction.get("reversal") or "").strip()
|
||||
tone = str(direction.get("tone") or "").strip()
|
||||
detail = ";".join(
|
||||
str(direction.get(key) or "").strip()
|
||||
for key in ("conflict", "product_role", "reversal", "tone")
|
||||
if str(direction.get(key) or "").strip()
|
||||
part for part in (conflict, product_role, reversal, tone) if part
|
||||
)
|
||||
payload_dir = {
|
||||
"id": str(direction.get("id") or "").strip(),
|
||||
"title": title,
|
||||
"conflict": conflict,
|
||||
"product_role": product_role,
|
||||
"reversal": reversal,
|
||||
"tone": tone,
|
||||
}
|
||||
else:
|
||||
title = choice.strip() or "用户自定义方向"
|
||||
conflict = product_role = reversal = tone = ""
|
||||
detail = title
|
||||
payload_dir = {
|
||||
"id": "",
|
||||
"title": title,
|
||||
"conflict": "",
|
||||
"product_role": "",
|
||||
"reversal": "",
|
||||
"tone": "",
|
||||
}
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["plot_twist_story_direction"] = title
|
||||
memory["plot_twist_story_direction_detail"] = detail
|
||||
memory["plot_twist_story_direction_payload"] = payload_dir
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return (
|
||||
f"用户已选择剧情方向【{title}】。方向细节:{detail}。"
|
||||
"现在只调用 write_strategy 写创作策略卡,必须沿用该冲突、商品作用与反转;"
|
||||
"不要再展示方向卡、不要复述选择、不要直接写方案或出片。"
|
||||
"现在只调用 write_strategy 写创作策略卡,策略的创作方向与后续方案/分镜必须严格沿用该冲突、商品作用与反转,"
|
||||
"不得改写成另一套常见带货故事;不要再展示方向卡、不要复述选择、不要直接写方案或出片。"
|
||||
)
|
||||
|
||||
|
||||
@@ -289,6 +314,7 @@ def _store_click_swap_sequence(conversation: CreationConversation, value: str) -
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _mark_product_source_resolved(conversation: CreationConversation) -> None:
|
||||
"""用户选择跳过、自动推荐或直接描述商品后,不重复弹同一个商品闸门。"""
|
||||
memory = dict(conversation.memory or {})
|
||||
@@ -297,13 +323,92 @@ def _mark_product_source_resolved(conversation: CreationConversation) -> None:
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
|
||||
def _is_product_like_ref(ref: dict) -> bool:
|
||||
"""商品库商品,或本地上传的商品图(排除人物角色/模特)。"""
|
||||
if not isinstance(ref, dict):
|
||||
return False
|
||||
type_ = str(ref.get("type") or "").strip()
|
||||
ref_id = str(ref.get("id") or "").strip()
|
||||
if not ref_id:
|
||||
return False
|
||||
if type_ == "product":
|
||||
return True
|
||||
if type_ != "asset":
|
||||
return False
|
||||
category = str(ref.get("category") or "").strip().lower()
|
||||
return category not in {"character", "person", "model"}
|
||||
|
||||
|
||||
def _dedupe_refs(refs: list[dict]) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for ref in refs or []:
|
||||
if not _is_product_like_ref(ref):
|
||||
continue
|
||||
mark = (str(ref.get("type")), str(ref.get("id")))
|
||||
if mark in seen:
|
||||
continue
|
||||
seen.add(mark)
|
||||
out.append(ref)
|
||||
return out
|
||||
|
||||
|
||||
def _session_product_refs(conversation: CreationConversation, request_refs=None) -> list[dict]:
|
||||
"""优先收集本会话已上传/已锁定的商品图,避免「你来推荐」盲取商品库最新一条。"""
|
||||
buckets: list[list] = [list(request_refs or []), list(locked_product_references(conversation))]
|
||||
recent = (
|
||||
conversation.messages.filter(role=CreationMessage.Role.USER)
|
||||
.order_by("-seq")[:12]
|
||||
)
|
||||
for message in recent:
|
||||
buckets.append(list(message.refs or []))
|
||||
merged: list[dict] = []
|
||||
for bucket in buckets:
|
||||
merged.extend(bucket)
|
||||
return _dedupe_refs(merged)
|
||||
|
||||
|
||||
def _ref_display_name(ref: dict) -> str:
|
||||
name = str(ref.get("name") or "").split(" · ")[0].strip()
|
||||
if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
|
||||
return name
|
||||
return "已上传商品图"
|
||||
|
||||
|
||||
def _auto_pick_product_continuation(conversation: CreationConversation, request_refs=None):
|
||||
"""「你来推荐」:有本地上传/已锁定商品图时用它们;否则才回落到商品库检索。"""
|
||||
preferred = _session_product_refs(conversation, request_refs)
|
||||
if preferred:
|
||||
pin_refs(conversation, preferred)
|
||||
names = "、".join(_ref_display_name(item) for item in preferred[:6])
|
||||
return preferred, (
|
||||
f"用户已提供商品参考图({names})。必须基于这些上传图推进创作,"
|
||||
"禁止改用商品库里的其他商品(例如库里最新一条)。"
|
||||
"若品牌或具体品名仍不明确,先确认品牌与品名,再继续方案。"
|
||||
)
|
||||
|
||||
hits = search_mentions(conversation.team, q="", types=["product"], limit=1)
|
||||
if hits:
|
||||
pin_refs(conversation, hits)
|
||||
return hits, (
|
||||
f"用户希望你帮忙挑选素材,会话里尚无本地上传商品图,已从商品库选定【{hits[0]['name']}】。"
|
||||
"直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
|
||||
)
|
||||
return [], (
|
||||
"用户希望你帮忙定素材,目前会话无本地上传商品图,商品库也暂无可用商品。"
|
||||
"请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
|
||||
)
|
||||
|
||||
|
||||
_STEP_CONTINUE_INSTRUCTIONS = {
|
||||
"strategy": (
|
||||
"用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);"
|
||||
"用户已确认创作策略。现在只调用 write_plan 写方案卡并存档 video_prompt;"
|
||||
"超过 60 秒时只写紧凑章节稿(章节结构 + 每个 30 秒段关键镜头 + 交接状态),不要写制作级长文。"
|
||||
"不要再写策略,不要调用 write_prompt,不要出片。"
|
||||
),
|
||||
"plan": (
|
||||
"用户已确认视频方案。补齐完整 video_prompt 后直接调用 write_prompt 整理后台出片指令并给出积分确认卡;"
|
||||
"用户已确认视频方案。调用 write_prompt 整理后台出片指令并给出积分确认卡;"
|
||||
"超过 60 秒时长优先沿用方案里已存的 video_prompt,只补简短全片规则,不要重写成短片密度的制作长文。"
|
||||
"不要重写策略/方案,也不要直接出片。"
|
||||
),
|
||||
"prompt": (
|
||||
@@ -318,12 +423,12 @@ _STEP_REVISE_INSTRUCTIONS = {
|
||||
"写完即停,不要同轮 write_plan / write_prompt。"
|
||||
),
|
||||
"plan": (
|
||||
"用户要求修改视频方案。根据反馈只重新调用 write_plan 写一版修订方案"
|
||||
"(含完整 video_prompt 存档);写完即停,不要同轮 write_prompt 或出片。"
|
||||
"用户要求修改视频方案。根据反馈只重新调用 write_plan 写一版修订方案并更新 video_prompt;"
|
||||
"超过 60 秒时仍写紧凑章节稿,不要写制作级长文。写完即停,不要同轮 write_prompt 或出片。"
|
||||
),
|
||||
"prompt": (
|
||||
"用户要求修改出片细节。根据反馈只重新调用 write_prompt 整理一版修订指令;"
|
||||
"完成后给出积分确认卡,不要直接出片。"
|
||||
"长视频不要把逐镜再扩写成短片密度。完成后给出积分确认卡,不要直接出片。"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1046,9 +1151,15 @@ def _free_video_task_queryset(team):
|
||||
)
|
||||
|
||||
|
||||
def _not_omni_create_q() -> Q:
|
||||
"""「不是全能创作」。同样不能写成 exclude(request_payload__feature="omni_create")——
|
||||
JSON 键缺失时比较结果是 NULL,exclude 会把没写 feature 的历史任务一起筛没。"""
|
||||
return Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature="omni_create")
|
||||
|
||||
|
||||
def _free_video_list_queryset(team, *, include_replace=False):
|
||||
"""正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。"""
|
||||
from .video_replace import video_replace_q
|
||||
from .video_replace import not_video_replace_q, video_replace_q
|
||||
|
||||
video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO)
|
||||
active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True)
|
||||
@@ -1067,13 +1178,13 @@ def _free_video_list_queryset(team, *, include_replace=False):
|
||||
if include_replace:
|
||||
return qs.filter(video_replace_q())
|
||||
# 全能创作也走 FREE_VIDEO 任务类型,但不能出现在自由生成任务流里。
|
||||
return qs.exclude(video_replace_q()).exclude(request_payload__feature="omni_create")
|
||||
return qs.filter(not_video_replace_q()).filter(_not_omni_create_q())
|
||||
|
||||
|
||||
def _free_video_trash_queryset(team):
|
||||
return (
|
||||
AITask.objects.filter(team=team, task_type=AITask.Type.FREE_VIDEO, is_deleted=True, purged_at__isnull=True)
|
||||
.exclude(request_payload__feature="omni_create")
|
||||
.filter(_not_omni_create_q())
|
||||
.select_related("model_config")
|
||||
.prefetch_related("generated_assets", "generated_assets__files")
|
||||
)
|
||||
@@ -1604,8 +1715,14 @@ class FreeVideoUploadView(APIView):
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
||||
from apps.assets import assets_client
|
||||
from apps.assets.review import wait_upload_review
|
||||
|
||||
if not assets_client.is_enabled():
|
||||
return Response(
|
||||
{"detail": "素材审核服务暂不可用,请稍后重试", "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
review_status = wait_upload_review(asset)
|
||||
asset.refresh_from_db()
|
||||
if review_status == "failed":
|
||||
@@ -1616,7 +1733,13 @@ class FreeVideoUploadView(APIView):
|
||||
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||
)
|
||||
# active / allowed:进库,可供引用
|
||||
if review_status != "active":
|
||||
detail = (asset.review_error or "素材审核服务暂不可用,请稍后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
# active:进库,可供引用
|
||||
if not asset.in_library:
|
||||
asset.in_library = True
|
||||
asset.save(update_fields=["in_library", "updated_at"])
|
||||
@@ -1631,7 +1754,7 @@ class FreeVideoUploadView(APIView):
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||
"review_status": "active" if review_status in ("active", "allowed") else review_status,
|
||||
"review_status": "active",
|
||||
"in_library": True,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
@@ -1669,12 +1792,20 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
|
||||
)
|
||||
cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}"
|
||||
if is_plain:
|
||||
# 模型目录缓存只是加速层;开发机或 Redis 短暂不可用时仍应正常返回目录。
|
||||
try:
|
||||
cached = cache.get(cache_key)
|
||||
except Exception:
|
||||
logger.warning("模型目录缓存不可用,改为直接读取", exc_info=True)
|
||||
cached = None
|
||||
if cached is not None:
|
||||
return Response(cached)
|
||||
response = super().list(request, *args, **kwargs)
|
||||
if is_plain and response.status_code == 200:
|
||||
try:
|
||||
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
|
||||
except Exception:
|
||||
logger.warning("模型目录缓存写入失败,已返回直读结果", exc_info=True)
|
||||
return response
|
||||
|
||||
|
||||
@@ -1774,7 +1905,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="merge-video-segments")
|
||||
def merge_video_segments(self, request, pk=None):
|
||||
"""用户确认后才合并多段成片;未点击前不下载视频、更不调用 ffmpeg。"""
|
||||
"""合并多段成片(兼容旧前端手动触发;正常流程在分段全成功后由平台自动合并)。"""
|
||||
conversation = self.get_object()
|
||||
message_id = str(request.data.get("message_id") or "").strip()
|
||||
message = conversation.messages.filter(id=message_id).first() if message_id else None
|
||||
@@ -1921,6 +2052,24 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
continuation_instruction = _plot_twist_direction_continuation(
|
||||
conversation, payload, choice
|
||||
)
|
||||
elif payload.get("interaction") == "click_swap_mode_gate":
|
||||
raw = text.strip()
|
||||
mode = ""
|
||||
if re.search(r"只出手|手指|不用角色|不要角色|无角色", raw):
|
||||
mode = "finger"
|
||||
elif re.search(r"角色|日常|拟人|出镜人物|达人", raw):
|
||||
mode = "character"
|
||||
instruction = apply_click_swap_mode(conversation, mode) if mode else ""
|
||||
if instruction:
|
||||
payload["answers"] = {"click_swap_mode": mode}
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = instruction
|
||||
elif payload.get("interaction") == "click_swap_sku_gate":
|
||||
sequence = text.strip()
|
||||
if sequence:
|
||||
@@ -1977,31 +2126,82 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
],
|
||||
}, status=200)
|
||||
|
||||
# 2. 用户说「你帮我选/你定/随便」
|
||||
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑)", text):
|
||||
# 2. 用户说「你帮我选/你定/随便/你来推荐」
|
||||
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑|你来推荐|推荐一款|推荐一个)", text):
|
||||
if is_product_gate:
|
||||
preferred, continuation_instruction = _auto_pick_product_continuation(
|
||||
conversation, refs
|
||||
)
|
||||
chosen_name = (
|
||||
_ref_display_name(preferred[0]) if preferred else "推荐商品"
|
||||
)
|
||||
for item in preferred:
|
||||
mark = (item.get("type"), str(item.get("id")))
|
||||
existing = {
|
||||
(r.get("type"), str(r.get("id")))
|
||||
for r in refs if isinstance(r, dict)
|
||||
}
|
||||
if mark not in existing:
|
||||
refs.append(item)
|
||||
payload["answers"] = {
|
||||
"_asset_gate": "auto",
|
||||
str(primary_field.get("key") or "product"): chosen_name,
|
||||
}
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
_mark_product_source_resolved(conversation)
|
||||
force_creative_turn = True
|
||||
else:
|
||||
hits = search_mentions(conversation.team, q="", types=asset_types, limit=1)
|
||||
chosen_name = hits[0]["name"] if hits else "推荐商品"
|
||||
chosen_name = hits[0]["name"] if hits else "推荐素材"
|
||||
if hits:
|
||||
mark = (hits[0].get("type"), str(hits[0].get("id")))
|
||||
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
|
||||
if mark not in existing:
|
||||
refs.append(hits[0])
|
||||
pin_refs(conversation, [hits[0]])
|
||||
payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name}
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
if is_product_gate:
|
||||
_mark_product_source_resolved(conversation)
|
||||
is_character_gate = any(t in ("character", "model") for t in asset_types)
|
||||
if is_character_gate and hits:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source"] = "auto"
|
||||
memory["person_source_ready"] = True
|
||||
memory["person_source_pending"] = False
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
force_creative_turn = True
|
||||
if hits:
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
|
||||
f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。"
|
||||
"直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。"
|
||||
)
|
||||
elif is_character_gate:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory.pop("person_source_ready", None)
|
||||
memory.pop("person_source_pending", None)
|
||||
memory["person_source"] = ""
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
gate = append_person_source_gate(conversation)
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [CreationMessageSerializer(gate).data],
|
||||
}, status=200)
|
||||
else:
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户让你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
|
||||
"用户让你帮忙定素材,目前素材库暂无现成项。"
|
||||
"按用户刚说的外观要求继续创作;禁止再次弹出同类「发列表 / 你来推荐」追问。"
|
||||
)
|
||||
|
||||
# 3. 用户说「先不用/不选了/跳过」
|
||||
@@ -2059,6 +2259,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
|
||||
if fields:
|
||||
field = fields[0]
|
||||
if payload.get("topic") == "product_info":
|
||||
clean_probe = text.strip()
|
||||
design_from_image = any(
|
||||
kw in clean_probe for kw in ("按图片", "设计品牌", "不用再问")
|
||||
)
|
||||
if (not design_from_image) and is_incomplete_product_brand_answer(clean_probe):
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。",
|
||||
"messages": [CreationMessageSerializer(pending).data],
|
||||
}, status=200)
|
||||
answers = {
|
||||
str(field.get("key") or "answer"):
|
||||
_chat_answer_for_field(field, text, conversation.team)
|
||||
@@ -2089,6 +2303,45 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
continuation_instruction = apply_pain_point_direction(
|
||||
conversation, payload, choice
|
||||
)
|
||||
elif payload.get("topic") == "product_info":
|
||||
clean_ans = text.strip()
|
||||
memory = dict(conversation.memory or {})
|
||||
design_from_image = any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问"))
|
||||
if design_from_image:
|
||||
memory["product_info_resolved"] = True
|
||||
memory["product_info_from_image"] = True
|
||||
continuation_instruction = (
|
||||
"用户要求直接根据图片内容设计品牌与品名推进创作。"
|
||||
"直接基于图片外观特征推进方案,不要重复追问品牌品名,也不要再弹出品牌确认卡。"
|
||||
)
|
||||
elif is_incomplete_product_brand_answer(clean_ans):
|
||||
memory.pop("product_info_resolved", None)
|
||||
continuation_instruction = (
|
||||
"用户尚未填写完整品牌与品名。请继续请他补充,不要进入下一步。"
|
||||
)
|
||||
else:
|
||||
memory["product_info_resolved"] = True
|
||||
memory["product_brand_and_name"] = clean_ans
|
||||
bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans)
|
||||
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans)
|
||||
if bm:
|
||||
memory["product_brand"] = bm.group(1).strip()
|
||||
if nm:
|
||||
memory["product_name"] = nm.group(1).strip()
|
||||
for r in (conversation.pinned_refs or []):
|
||||
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
|
||||
r["name"] = clean_ans
|
||||
break
|
||||
conversation.pinned_refs = conversation.pinned_refs
|
||||
continuation_instruction = (
|
||||
f"用户已提供商品品牌与品名:【{clean_ans}】。"
|
||||
"直接围绕该商品推进方案,不要重复追问。"
|
||||
)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
|
||||
# 保留用户原文气泡,便于回看刚填的品牌/品名;不要清空 text,
|
||||
# 否则会落到「消息不能为空」且对话里看不到这次回答。
|
||||
force_creative_turn = True
|
||||
else:
|
||||
continuation_instruction = (
|
||||
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
|
||||
@@ -2097,6 +2350,98 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if params_changed:
|
||||
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
|
||||
|
||||
# 用户对刚生成的角色给出确认/重刷/外观修改反馈
|
||||
clean_text = text.strip()
|
||||
memory_now = dict(conversation.memory or {})
|
||||
person_confirm_pending = bool(memory_now.get("person_confirm_pending"))
|
||||
is_platform_person = memory_now.get("person_source") == "platform_generate"
|
||||
confirmed_person = (
|
||||
bool(re.search(
|
||||
r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$",
|
||||
clean_text,
|
||||
))
|
||||
or clean_text in (
|
||||
"使用这个角色继续创作",
|
||||
"使用这个宠物角色继续创作",
|
||||
)
|
||||
or (person_confirm_pending and clean_text in ("继续", "继续创作"))
|
||||
)
|
||||
regenerate_button = bool(re.search(
|
||||
r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$",
|
||||
clean_text,
|
||||
))
|
||||
upload_person = bool(re.search(
|
||||
r"(上传|发一张|发个).{0,8}(人物|角色|模特|宠物)|我上传",
|
||||
clean_text,
|
||||
))
|
||||
if confirmed_person:
|
||||
if person_confirm_pending:
|
||||
memory_now["person_confirm_pending"] = False
|
||||
conversation.memory = memory_now
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户已确认使用当前生成的角色出镜。"
|
||||
"本轮必须实质推进创作:若还缺商品则 ask_user 选商品;"
|
||||
"痛点解决演示若还缺痛点方向则 ask_user(pain_point_direction);"
|
||||
"信息够了就立刻 write_strategy。"
|
||||
"禁止只回一句「可以回复继续完善方案」之类的空引导,也不要再次追问角色来源。"
|
||||
)
|
||||
elif is_platform_person and (regenerate_button or (person_confirm_pending and clean_text and not upload_person)):
|
||||
prev_prompt = str(memory_now.get("person_prompt") or "").strip()
|
||||
if regenerate_button:
|
||||
appearance_prompt = prev_prompt
|
||||
elif prev_prompt:
|
||||
appearance_prompt = f"{prev_prompt};按用户最新要求调整:{clean_text}"
|
||||
else:
|
||||
appearance_prompt = clean_text
|
||||
# 卸掉上一批平台定妆锁定,避免新旧角色叠在 pinned_refs 里
|
||||
old_model_ids = {
|
||||
str(item).strip()
|
||||
for item in (memory_now.get("person_cast_model_ids") or [])
|
||||
if str(item).strip()
|
||||
}
|
||||
legacy_id = str(memory_now.get("person_model_id") or "").strip()
|
||||
if legacy_id:
|
||||
old_model_ids.add(legacy_id)
|
||||
if old_model_ids:
|
||||
conversation.pinned_refs = [
|
||||
ref for ref in (conversation.pinned_refs or [])
|
||||
if not (
|
||||
isinstance(ref, dict)
|
||||
and ref.get("type") in {"model", "character"}
|
||||
and str(ref.get("id") or "") in old_model_ids
|
||||
)
|
||||
]
|
||||
memory_now["person_confirm_pending"] = False
|
||||
memory_now.pop("person_source_ready", None)
|
||||
memory_now.pop("person_model_id", None)
|
||||
memory_now.pop("person_cast_model_ids", None)
|
||||
memory_now.pop("person_cast_pending", None)
|
||||
memory_now.pop("person_cast_total", None)
|
||||
conversation.memory = memory_now
|
||||
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
|
||||
try:
|
||||
user_msg = append_message(conversation, role="user", text=clean_text)
|
||||
generating_messages = submit_generated_person_reference(
|
||||
conversation=conversation,
|
||||
user=request.user,
|
||||
appearance_prompt=appearance_prompt,
|
||||
)
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [
|
||||
CreationMessageSerializer(user_msg).data,
|
||||
*[
|
||||
CreationMessageSerializer(item).data
|
||||
for item in generating_messages
|
||||
],
|
||||
],
|
||||
}, status=202)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if kind == "confirm":
|
||||
reply_to = str(request.data.get("reply_to") or "").strip()
|
||||
card = conversation.messages.filter(
|
||||
@@ -2215,7 +2560,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
]
|
||||
valid = any(
|
||||
AssetModel.objects.filter(
|
||||
team=conversation.team,
|
||||
Q(team=conversation.team) | Q(is_official=True),
|
||||
id=ref.get("id"),
|
||||
is_deleted=False,
|
||||
purged_at__isnull=True,
|
||||
@@ -2224,6 +2569,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
if not valid:
|
||||
return JsonResponse({"detail": "请先从模特库选择一位人物"}, status=400)
|
||||
if payload.get("interaction") == "click_swap_mode_gate":
|
||||
mode = str(answers.get("click_swap_mode") or "").strip()
|
||||
if mode not in {"finger", "character"}:
|
||||
return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400)
|
||||
if payload.get("interaction") == "click_swap_sku_gate":
|
||||
sequence = str(answers.get("sku_sequence") or "").strip()
|
||||
if not sequence:
|
||||
@@ -2299,6 +2648,15 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
else "商家选择系统推荐卖点。现在只调用 write_strategy 写创作策略;"
|
||||
"从商品资料和现有素材中挑一个最容易被画面证明的真实核心卖点,不要虚构功效、价格或规格。"
|
||||
)
|
||||
elif payload.get("interaction") == "click_swap_mode_gate":
|
||||
mode = str(answers.get("click_swap_mode") or "").strip()
|
||||
instruction = apply_click_swap_mode(conversation, mode)
|
||||
if not instruction:
|
||||
return JsonResponse({"detail": "请选择只出手指出镜,或角色日常换款"}, status=400)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = instruction
|
||||
elif payload.get("interaction") == "click_swap_sku_gate":
|
||||
sequence = str(answers.get("sku_sequence") or "").strip()
|
||||
text = ""
|
||||
@@ -2322,10 +2680,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
elif payload.get("interaction") == "person_source_gate":
|
||||
source = str(answers.get("person_source") or "").strip()
|
||||
if source == "platform_generate":
|
||||
person_prompt = str(answers.get("person_prompt") or "").strip()[:500]
|
||||
try:
|
||||
generating = submit_generated_person_reference(
|
||||
generating_messages = submit_generated_person_reference(
|
||||
conversation=conversation,
|
||||
user=request.user,
|
||||
appearance_prompt=person_prompt,
|
||||
)
|
||||
except ValueError as exc:
|
||||
# 生图没提交成功,把闸门放回去供用户换方式或重试。
|
||||
@@ -2337,7 +2697,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [CreationMessageSerializer(generating).data],
|
||||
"messages": [
|
||||
CreationMessageSerializer(item).data
|
||||
for item in generating_messages
|
||||
],
|
||||
}, status=202)
|
||||
|
||||
# 上传/模特库的人物立即进入实体锁定,不等 Celery turn 开始才保存。
|
||||
@@ -2357,10 +2720,62 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
is_pet = "宠物" in str(conversation.preset or "")
|
||||
continuation_instruction = (
|
||||
"用户已选定出镜宠物角色,该宠物形象已作为整条视频的固定身份参考。"
|
||||
"直接继续创作;所有镜头和分段保持同一宠物,不要再追问角色来源。"
|
||||
if is_pet else
|
||||
"用户已选定出镜人物,该人物已作为整条视频的固定身份参考。"
|
||||
"直接继续创作;所有镜头和分段保持同一人,不要再追问人物来源。"
|
||||
)
|
||||
elif payload.get("topic") == "product_info":
|
||||
ans = str(answers.get("product_brand_and_name") or "").strip()
|
||||
memory = dict(conversation.memory or {})
|
||||
design_from_image = any(kw in ans for kw in ("按图片", "设计品牌", "不用再问"))
|
||||
clean_ans = ans.replace(PRODUCT_BRAND_EMPTY_TEMPLATE, "").strip()
|
||||
if (not design_from_image) and is_incomplete_product_brand_answer(ans):
|
||||
payload["submitted"] = False
|
||||
payload["answers"] = {}
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"detail": "请填写品牌和品名后再发送,例如「品牌浅蓝小熊,品名婴儿柔湿巾」。",
|
||||
"messages": [CreationMessageSerializer(pending).data],
|
||||
}, status=200)
|
||||
if design_from_image:
|
||||
memory["product_info_resolved"] = True
|
||||
memory["product_info_from_image"] = True
|
||||
continuation_instruction = (
|
||||
"用户要求直接根据图片内容设计品牌与品名推进创作。"
|
||||
"直接基于图片外观特征推进方案,不要重复追问品牌品名,也不要再弹出品牌确认卡。"
|
||||
)
|
||||
else:
|
||||
memory["product_info_resolved"] = True
|
||||
memory["product_brand_and_name"] = clean_ans or ans
|
||||
bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans or ans)
|
||||
nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans or ans)
|
||||
if bm:
|
||||
memory["product_brand"] = bm.group(1).strip()
|
||||
if nm:
|
||||
memory["product_name"] = nm.group(1).strip()
|
||||
for r in (conversation.pinned_refs or []):
|
||||
if isinstance(r, dict) and r.get("type") in ("asset", "product"):
|
||||
r["name"] = clean_ans or ans
|
||||
break
|
||||
conversation.pinned_refs = conversation.pinned_refs
|
||||
continuation_instruction = (
|
||||
f"用户已提供商品品牌与品名:【{clean_ans or ans}】。"
|
||||
"直接围绕该商品推进方案,不要重复追问。"
|
||||
)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "pinned_refs", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
elif payload.get("phase") == "gate":
|
||||
choice = str(answers.get("_asset_gate") or "").strip()
|
||||
pending = payload.get("pending_fields") or []
|
||||
@@ -2395,16 +2810,88 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"messages": [CreationMessageSerializer(pick).data],
|
||||
}, status=200)
|
||||
elif choice == "auto":
|
||||
if is_product_gate:
|
||||
preferred, continuation_instruction = _auto_pick_product_continuation(
|
||||
conversation, refs
|
||||
)
|
||||
refs = list(refs)
|
||||
existing = {
|
||||
(item.get("type"), str(item.get("id")))
|
||||
for item in refs if isinstance(item, dict)
|
||||
}
|
||||
for item in preferred:
|
||||
mark = (item.get("type"), str(item.get("id")))
|
||||
if mark not in existing:
|
||||
refs.append(item)
|
||||
existing.add(mark)
|
||||
_mark_product_source_resolved(conversation)
|
||||
else:
|
||||
hits = search_mentions(conversation.team, q="", types=gate_types, limit=1)
|
||||
is_character_gate = any(t in ("character", "model") for t in gate_types)
|
||||
if hits:
|
||||
refs = list(refs)
|
||||
refs.append(hits[0])
|
||||
pin_refs(conversation, [hits[0]])
|
||||
continuation_instruction = (
|
||||
f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。"
|
||||
f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。"
|
||||
"直接基于该素材推进创作方案,不要复述选项,不要再次 ask_user 追问同一类素材。"
|
||||
)
|
||||
else:
|
||||
if is_character_gate:
|
||||
# 库里没有角色却点了「你来推荐」:不能假完成,否则会跳到核心卖点。
|
||||
# 改走人物来源闸门(上传 / 模特库 / 平台生成)。
|
||||
memory = dict(conversation.memory or {})
|
||||
memory.pop("person_source_ready", None)
|
||||
memory.pop("person_source_pending", None)
|
||||
memory["person_source"] = ""
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
gate = append_person_source_gate(conversation)
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
return JsonResponse({
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [CreationMessageSerializer(gate).data],
|
||||
}, status=200)
|
||||
continuation_instruction = (
|
||||
"用户希望你帮忙定素材,目前素材库暂无现成项。"
|
||||
"按用户刚说的外观要求继续创作;不要再弹同一类「发列表 / 你来推荐」。"
|
||||
)
|
||||
if is_character_gate and hits:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source"] = "auto"
|
||||
memory["person_source_ready"] = True
|
||||
memory["person_source_pending"] = False
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
elif choice == "upload":
|
||||
# 闸门「上传商品图」:用本轮 refs / 会话已上传图,禁止再去商品库抽奖。
|
||||
preferred = _session_product_refs(conversation, refs)
|
||||
if preferred:
|
||||
pin_refs(conversation, preferred)
|
||||
refs = list(refs)
|
||||
existing = {
|
||||
(item.get("type"), str(item.get("id")))
|
||||
for item in refs if isinstance(item, dict)
|
||||
}
|
||||
for item in preferred:
|
||||
mark = (item.get("type"), str(item.get("id")))
|
||||
if mark not in existing:
|
||||
refs.append(item)
|
||||
existing.add(mark)
|
||||
names = "、".join(_ref_display_name(item) for item in preferred[:6])
|
||||
continuation_instruction = (
|
||||
f"用户刚上传了商品参考图({names})。必须基于这些上传图推进创作,"
|
||||
"禁止改用商品库里的其他商品。若品牌或具体品名不明确,先确认品牌与品名。"
|
||||
)
|
||||
else:
|
||||
continuation_instruction = (
|
||||
"用户希望你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。"
|
||||
"用户选择上传商品图,但本轮尚未收到可用图片。"
|
||||
"请提醒用户再传一张清晰的商品实物图,不要改从商品库挑选。"
|
||||
)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
@@ -2423,6 +2910,29 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
|
||||
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
|
||||
)
|
||||
elif payload.get("phase") == "pick" and (
|
||||
answers.get("_action") == "cancel"
|
||||
or any(str(v).lower() in ("cancel", "取消", "skip", "跳过") for v in answers.values())
|
||||
):
|
||||
pick_fields = payload.get("fields") or []
|
||||
primary = pick_fields[0] if pick_fields else {}
|
||||
types = primary.get("asset_types") or []
|
||||
key = str(primary.get("key") or "").strip()
|
||||
if "product" in types or key == "product":
|
||||
_mark_product_source_resolved(conversation)
|
||||
if any(t in types for t in ("character", "model")) or key in ("character", "model", "person"):
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source_ready"] = True
|
||||
memory["person_source_pending"] = False
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户取消了从列表选择素材(素材库暂无合适素材或用户放弃选择)。"
|
||||
"接受这个选择,根据会话里已有的需求和合理默认继续推进原任务,不要重复追问同一项素材。"
|
||||
)
|
||||
else:
|
||||
params_changed = apply_session_params(conversation, payload.get("fields") or [], answers)
|
||||
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
|
||||
@@ -2443,7 +2953,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
)
|
||||
if params_changed:
|
||||
continuation_instruction += " 会话参数已经更新,旧方案作废,按新参数重新产出方案。"
|
||||
elif not text and not refs:
|
||||
elif not text and not refs and not (force_creative_turn or continuation_instruction):
|
||||
# 追问卡(品牌品名/点选闸门等)会把正文清空、改走 continuation;
|
||||
# 这时没有 text/refs 也是合法推进,不能误报「消息不能为空」。
|
||||
return JsonResponse({"detail": "消息不能为空"}, status=400)
|
||||
|
||||
# 同一账号同时只允许一个整理方案 turn(含本会话已在 planning 的二次发送)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""角色库文件名 → 模特标签。
|
||||
|
||||
文件名形如「警察-男性-日本-年轻男性.png」「动画角色-齿轮蝠.png」。
|
||||
文件夹名(医生/警察/动画角色…)作为职业/分类标签;性别归一成「男人」「女人」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
GENDER_TOKENS = {
|
||||
"男性": "男人",
|
||||
"男童": "男人",
|
||||
"年轻男性": "男人",
|
||||
"中年男性": "男人",
|
||||
"老年男性": "男人",
|
||||
"青少年男性": "男人",
|
||||
"女性": "女人",
|
||||
"女童": "女人",
|
||||
"少女": "女人",
|
||||
"年轻女性": "女人",
|
||||
"中年女性": "女人",
|
||||
"老年女性": "女人",
|
||||
"青少年女性": "女人",
|
||||
"成年女性": "女人",
|
||||
}
|
||||
|
||||
AGE_TOKENS = ("年轻", "中年", "老年", "青少年", "童", "成年")
|
||||
|
||||
# 文件名里常带的噪声后缀,不当标签
|
||||
NOISE = {"面孔", "中国面孔", "欧美面孔", "亚洲面孔", "非洲面孔", "美国面孔", "印度面孔"}
|
||||
|
||||
|
||||
def _split_tokens(stem: str) -> list[str]:
|
||||
parts = re.split(r"[-_·//]+", stem)
|
||||
return [p.strip() for p in parts if p and p.strip()]
|
||||
|
||||
|
||||
def parse_character_tags(*, folder: str, filename: str) -> list[str]:
|
||||
"""从文件夹 + 文件名解析去重后的标签列表(顺序:分类 → 性别 → 其余)。"""
|
||||
stem = Path(filename).stem
|
||||
tokens = _split_tokens(stem)
|
||||
folder_tag = folder.strip()
|
||||
tags: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(tag: str) -> None:
|
||||
tag = tag.strip()
|
||||
if not tag or tag in seen or tag in NOISE:
|
||||
return
|
||||
seen.add(tag)
|
||||
tags.append(tag)
|
||||
|
||||
if folder_tag:
|
||||
add(folder_tag)
|
||||
|
||||
# 性别优先归一
|
||||
for tok in tokens:
|
||||
if tok in GENDER_TOKENS:
|
||||
add(GENDER_TOKENS[tok])
|
||||
break
|
||||
for key, gender in GENDER_TOKENS.items():
|
||||
if key in tok:
|
||||
add(gender)
|
||||
break
|
||||
|
||||
for tok in tokens:
|
||||
if tok == folder_tag:
|
||||
continue
|
||||
if tok in GENDER_TOKENS:
|
||||
continue
|
||||
# 年龄词单独保留简写
|
||||
age_hit = next((a for a in AGE_TOKENS if a in tok and tok not in GENDER_TOKENS), None)
|
||||
if age_hit and tok in GENDER_TOKENS.values():
|
||||
continue
|
||||
if tok in NOISE:
|
||||
continue
|
||||
# 去掉已归一进性别的复合词本体
|
||||
if any(g in tok for g in ("男性", "女性", "男童", "女童", "少女")):
|
||||
for a in AGE_TOKENS:
|
||||
if a in tok:
|
||||
add(a)
|
||||
break
|
||||
continue
|
||||
add(tok)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def display_name_from_file(*, folder: str, filename: str) -> str:
|
||||
stem = Path(filename).stem
|
||||
parts = _split_tokens(stem)
|
||||
if not parts:
|
||||
return folder or "角色"
|
||||
# 动画角色:「动画角色-齿轮蝠」→ 齿轮蝠
|
||||
if folder == "动画角色" and len(parts) >= 2:
|
||||
return parts[-1][:64]
|
||||
|
||||
body = parts[1:] if parts[0] in {folder, "民族服饰"} and len(parts) > 1 else parts
|
||||
# 去掉纯性别词,保留「中年女性」这类年龄+性别复合,以及地区
|
||||
cleaned: list[str] = []
|
||||
for tok in body:
|
||||
if tok in {"男性", "女性"}:
|
||||
continue
|
||||
cleaned.append(tok.replace("面孔", ""))
|
||||
label = "·".join(t for t in cleaned if t) or stem
|
||||
if folder and folder not in {"动画角色"}:
|
||||
return f"{folder}·{label}"[:64]
|
||||
return label[:64]
|
||||
|
||||
|
||||
def import_key(relative_path: str) -> str:
|
||||
return f"character_pack:{relative_path.replace(chr(92), '/')}"
|
||||
@@ -37,12 +37,15 @@ OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建
|
||||
|
||||
|
||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||
# 火山仍在审核的素材不能把 TOS 原始链接回传给浏览器。否则前端虽已禁选,
|
||||
# 仍可能把未审核直链带入其它生成入口。审核通过后 poll 接口会再返回链接。
|
||||
available = fa.status == FreeAsset.Status.ACTIVE
|
||||
return {
|
||||
"id": str(fa.id),
|
||||
"name": fa.name,
|
||||
"url": fa.url,
|
||||
"url": fa.url if available else "",
|
||||
"type": fa.asset_type.lower(),
|
||||
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
|
||||
"thumb_url": (fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else "")) if available else "",
|
||||
"duration": fa.duration,
|
||||
"status": fa.status,
|
||||
"error_message": fa.error_message,
|
||||
@@ -167,7 +170,9 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
if not group.thumbnail_url and fa.thumbnail_url:
|
||||
group.thumbnail_url = fa.thumbnail_url
|
||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
||||
return Response({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
|
||||
# 202 明确表示「已受理审核」而不是上传成功。只有轮询到 active 后,序列化结果
|
||||
# 才会含可用于生成的 URL。
|
||||
return Response({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class FreeAssetGroupListView(APIView):
|
||||
@@ -263,7 +268,7 @@ class FreeAssetUploadView(APIView):
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
response = _upload_asset_to_group(request, team=team, group=group)
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
if response.status_code in (status.HTTP_201_CREATED, status.HTTP_202_ACCEPTED):
|
||||
response.data.pop("group", None)
|
||||
return response
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""把本地「角色库」文件夹批量导入为官方模特(带标签)。
|
||||
|
||||
用法:
|
||||
python manage.py import_character_pack "/Users/Admin/Downloads/角色库"
|
||||
python manage.py import_character_pack "/path" --dry-run
|
||||
python manage.py import_character_pack "/path" --team-owner admin
|
||||
|
||||
幂等:同一相对路径(metadata.import_key)不会重复导入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.assets.character_tags import display_name_from_file, import_key, parse_character_tags
|
||||
from apps.assets.models import Asset, AssetFile, Model
|
||||
from apps.assets.storage import TosStorage
|
||||
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Import a local character-pack folder into official Model library with tags"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("folder", type=str, help="角色库根目录")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只解析标签,不上传")
|
||||
parser.add_argument("--team-owner", default="admin", help="托管官方模特的团队 owner 用户名")
|
||||
parser.add_argument("--limit", type=int, default=0, help="最多导入 N 张(0=全部)")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
root = Path(options["folder"]).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise CommandError(f"目录不存在: {root}")
|
||||
|
||||
owner = User.objects.filter(username=options["team_owner"]).first()
|
||||
if owner is None:
|
||||
raise CommandError(f"找不到用户 {options['team_owner']}")
|
||||
team = (
|
||||
Team.objects.filter(owner=owner, is_personal=True).order_by("created_at").first()
|
||||
or Team.objects.filter(owner=owner).order_by("created_at").first()
|
||||
)
|
||||
if team is None:
|
||||
team = Team.objects.create(name=f"{owner.username}-official", owner=owner, is_personal=True)
|
||||
TeamMember.objects.create(team=team, user=owner, role=TeamMember.Role.OWNER)
|
||||
self.stdout.write(f"已创建托管团队 {team.name}")
|
||||
|
||||
files = sorted(
|
||||
p for p in root.rglob("*")
|
||||
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES and p.name != ".DS_Store"
|
||||
)
|
||||
if options["limit"]:
|
||||
files = files[: options["limit"]]
|
||||
if not files:
|
||||
raise CommandError("目录里没有图片")
|
||||
|
||||
storage = None if options["dry_run"] else TosStorage()
|
||||
created = skipped = failed = 0
|
||||
|
||||
for path in files:
|
||||
rel = str(path.relative_to(root)).replace("\\", "/")
|
||||
folder = path.parent.name if path.parent != root else ""
|
||||
tags = parse_character_tags(folder=folder, filename=path.name)
|
||||
name = display_name_from_file(folder=folder, filename=path.name)
|
||||
key = import_key(rel)
|
||||
|
||||
existing = Model.objects.filter(
|
||||
is_deleted=False,
|
||||
metadata__import_key=key,
|
||||
).first()
|
||||
if existing:
|
||||
skipped += 1
|
||||
self.stdout.write(f"skip {rel} tags={tags}")
|
||||
continue
|
||||
|
||||
self.stdout.write(f"{'dry' if options['dry_run'] else 'add '} {rel} → {name} tags={tags}")
|
||||
if options["dry_run"]:
|
||||
created += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
self._create_one(
|
||||
storage=storage,
|
||||
team=team,
|
||||
owner=owner,
|
||||
path=path,
|
||||
name=name,
|
||||
tags=tags,
|
||||
key=key,
|
||||
folder=folder,
|
||||
rel=rel,
|
||||
)
|
||||
created += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
failed += 1
|
||||
self.stderr.write(self.style.ERROR(f"fail {rel}: {exc}"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"done created={created} skipped={skipped} failed={failed} team={team.id}"
|
||||
))
|
||||
|
||||
def _create_one(self, *, storage, team, owner, path: Path, name: str, tags: list[str], key: str, folder: str, rel: str):
|
||||
asset_id = uuid.uuid4()
|
||||
suffix = path.suffix.lower() or ".png"
|
||||
object_key = f"teams/{team.id}/models/official/{asset_id}{suffix}"
|
||||
content_type = mimetypes.guess_type(path.name)[0] or "image/png"
|
||||
with path.open("rb") as fh:
|
||||
stored = storage.upload_fileobj(
|
||||
fileobj=fh,
|
||||
object_key=object_key,
|
||||
content_type=content_type,
|
||||
)
|
||||
with transaction.atomic():
|
||||
portrait = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
created_by=owner,
|
||||
name=f"{name}·形象图",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.MODEL_PORTRAIT,
|
||||
metadata={
|
||||
"kind": "model",
|
||||
"view": "frontal",
|
||||
"source": "character_pack",
|
||||
"tags": tags,
|
||||
},
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=portrait,
|
||||
object_key=stored.object_key,
|
||||
bucket=stored.bucket,
|
||||
content_type=stored.content_type,
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
Model.objects.create(
|
||||
team=team,
|
||||
created_by=owner,
|
||||
name=name,
|
||||
is_official=True,
|
||||
source=Model.Source.UPLOAD,
|
||||
portrait_asset=portrait,
|
||||
description=f"角色库 · {folder}" if folder else "角色库",
|
||||
metadata={
|
||||
"source": "character_pack",
|
||||
"tags": tags,
|
||||
"import_key": key,
|
||||
"folder": folder,
|
||||
"relative_path": rel,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.15 on 2026-09-21 08:04
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0010_asset_model_purged_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ModelLibraryTag',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
('label', models.CharField(blank=True, max_length=64)),
|
||||
('visible', models.BooleanField(default=True)),
|
||||
('sort', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,144 @@
|
||||
"""模特库标签目录:从 Model.metadata.tags 聚合 + 与 ModelLibraryTag 同步。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from .models import Model, ModelLibraryTag
|
||||
|
||||
# 前台默认优先露出的粗粒度标签(即使 count 偏低也显示)
|
||||
PREFERRED = (
|
||||
"男人", "女人", "动画角色", "学生", "医生", "警察", "法官", "厨师",
|
||||
"民族", "民族服饰", "中国",
|
||||
)
|
||||
|
||||
# 首次入库:出现次数低于此值且不在 PREFERRED → 默认隐藏
|
||||
DEFAULT_VISIBLE_MIN_COUNT = 3
|
||||
|
||||
|
||||
def aggregate_tag_counts(*, official_only: bool = False, mine_team_id=None) -> dict[str, int]:
|
||||
qs = Model.objects.filter(is_deleted=False, purged_at__isnull=True)
|
||||
if official_only:
|
||||
qs = qs.filter(is_official=True)
|
||||
elif mine_team_id is not None:
|
||||
qs = qs.filter(is_official=False, team_id=mine_team_id)
|
||||
counts: Counter[str] = Counter()
|
||||
for md in qs.values_list("metadata", flat=True):
|
||||
tags = (md or {}).get("tags") if isinstance(md, dict) else None
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
for tag in tags:
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
counts[tag.strip()] += 1
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def sync_tag_catalog(*, create_missing: bool = True) -> list[ModelLibraryTag]:
|
||||
"""把库里出现过的标签写入目录;已有行不改 visible/sort/label。"""
|
||||
counts = aggregate_tag_counts()
|
||||
existing = {t.name: t for t in ModelLibraryTag.objects.all()}
|
||||
created: list[ModelLibraryTag] = []
|
||||
if create_missing:
|
||||
for name, count in counts.items():
|
||||
if name in existing:
|
||||
continue
|
||||
visible = name in PREFERRED or count >= DEFAULT_VISIBLE_MIN_COUNT
|
||||
sort = PREFERRED.index(name) if name in PREFERRED else 100 + (0 if count >= 10 else 50)
|
||||
obj = ModelLibraryTag.objects.create(name=name, visible=visible, sort=sort)
|
||||
existing[name] = obj
|
||||
created.append(obj)
|
||||
return created
|
||||
|
||||
|
||||
def public_tag_rows(*, tab: str | None = None, team_id=None) -> list[dict]:
|
||||
"""前台筛选条:只返回 visible=True 的标签 + 当前范围计数。"""
|
||||
sync_tag_catalog(create_missing=True)
|
||||
if tab == "official":
|
||||
counts = aggregate_tag_counts(official_only=True)
|
||||
elif tab == "mine":
|
||||
counts = aggregate_tag_counts(mine_team_id=team_id)
|
||||
else:
|
||||
counts = aggregate_tag_counts()
|
||||
rows = []
|
||||
for tag in ModelLibraryTag.objects.filter(visible=True).order_by("sort", "name"):
|
||||
c = counts.get(tag.name, 0)
|
||||
if c <= 0:
|
||||
continue
|
||||
rows.append({"name": tag.name, "label": tag.display_name, "count": c})
|
||||
return rows
|
||||
|
||||
|
||||
def rename_tag_everywhere(old: str, new: str) -> int:
|
||||
old, new = old.strip(), new.strip()
|
||||
if not old or not new or old == new:
|
||||
return 0
|
||||
updated = 0
|
||||
qs = Model.objects.filter(metadata__tags__contains=[old])
|
||||
for m in qs.iterator():
|
||||
md = dict(m.metadata or {})
|
||||
tags = md.get("tags")
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
nxt: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
v = new if t.strip() == old else t.strip()
|
||||
if not v or v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
nxt.append(v)
|
||||
if nxt != tags:
|
||||
md["tags"] = nxt
|
||||
m.metadata = md
|
||||
m.save(update_fields=["metadata", "updated_at"])
|
||||
updated += 1
|
||||
# catalog row
|
||||
src = ModelLibraryTag.objects.filter(name=old).first()
|
||||
dst = ModelLibraryTag.objects.filter(name=new).first()
|
||||
if src and dst:
|
||||
src.delete()
|
||||
elif src:
|
||||
src.name = new
|
||||
src.save(update_fields=["name", "updated_at"])
|
||||
return updated
|
||||
|
||||
|
||||
def replace_tag_on_models(old: str, new: str | None) -> int:
|
||||
"""把 old 换成 new;new=None 表示从模特上移除该标签。"""
|
||||
if new is None:
|
||||
new_s = None
|
||||
else:
|
||||
new_s = new.strip()
|
||||
if not new_s:
|
||||
return 0
|
||||
old = old.strip()
|
||||
updated = 0
|
||||
qs = Model.objects.filter(metadata__tags__contains=[old])
|
||||
for m in qs.iterator():
|
||||
md = dict(m.metadata or {})
|
||||
tags = md.get("tags")
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
nxt: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
v = t.strip()
|
||||
if v == old:
|
||||
if new_s is None:
|
||||
continue
|
||||
v = new_s
|
||||
if not v or v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
nxt.append(v)
|
||||
if nxt != tags:
|
||||
md["tags"] = nxt
|
||||
m.metadata = md
|
||||
m.save(update_fields=["metadata", "updated_at"])
|
||||
updated += 1
|
||||
return updated
|
||||
@@ -118,6 +118,29 @@ class Model(TeamOwnedModel):
|
||||
return self.name
|
||||
|
||||
|
||||
|
||||
class ModelLibraryTag(TimeStampedModel):
|
||||
"""模特库筛选标签目录 · 控制前台芯片显隐 / 排序 / 展示名。
|
||||
|
||||
实际标签仍写在 Model.metadata.tags;本表只管「筛选条怎么展示」。
|
||||
未入表的标签在 sync 时按出现次数决定默认显隐。
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
label = models.CharField(max_length=64, blank=True) # 空则用 name
|
||||
visible = models.BooleanField(default=True)
|
||||
sort = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ["sort", "name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.label or self.name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return (self.label or self.name).strip() or self.name
|
||||
|
||||
class AssetReviewGroup(TimeStampedModel):
|
||||
"""一团队一火山人像素材组(真人资产审核统一上传到这里,单组可放 500 万)。"""
|
||||
|
||||
|
||||
@@ -157,7 +157,10 @@ def reference_review_state(asset: Asset, *, require_registered: bool = False) ->
|
||||
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
|
||||
# 平台自生成资产不依赖用户上传审核;用户上传则必须阻止,不能因为审核服务
|
||||
# 短暂不可用而把历史未审素材带进 Seedance。
|
||||
if asset.source == Asset.Source.UPLOAD:
|
||||
return "unavailable"
|
||||
return "allowed"
|
||||
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
||||
return "allowed"
|
||||
@@ -243,16 +246,22 @@ def ensure_review_drain_loop() -> None:
|
||||
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
|
||||
"""上传素材:立刻送审并等到终态。
|
||||
|
||||
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)。
|
||||
审核未配置时不能拦上传,直接当 allowed。
|
||||
返回 active / failed / processing(超时仍在审) / unavailable(审核未启用或送审失败)。
|
||||
|
||||
用户上传的素材不能因为审核服务暂时不可用而被当作已通过。调用方应把
|
||||
unavailable 当作上传未完成处理,既不返回可引用链接,也不允许进入生成链路。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
return "allowed"
|
||||
submit_asset_for_review(asset, force=True)
|
||||
return "unavailable"
|
||||
submitted = submit_asset_for_review(asset, force=True)
|
||||
asset.refresh_from_db()
|
||||
status = asset.review_status or ""
|
||||
if status in ("active", "failed"):
|
||||
return status
|
||||
# CreateAsset 失败时没有 remote id 可以轮询;此前会空等到超时后把上传误报为
|
||||
# 「审核中」。直接把真实的不可用状态交给上传接口,让用户可以重试。
|
||||
if not submitted and not asset.review_remote_id:
|
||||
return "unavailable"
|
||||
deadline = time.monotonic() + max(3.0, float(timeout_s))
|
||||
while time.monotonic() < deadline:
|
||||
status = poll_asset_review(asset) or ""
|
||||
|
||||
@@ -45,9 +45,8 @@ class AssetFileSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
def get_preview_url(self, obj):
|
||||
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。
|
||||
# TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL
|
||||
# (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。
|
||||
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼公读直链。
|
||||
# 桶公读,直链不过期、可缓存。不再签发 1 小时就失效的预签名 URL。
|
||||
if obj.preview_url:
|
||||
return obj.preview_url
|
||||
if not obj.object_key or not settings.TOS.get("endpoint"):
|
||||
|
||||
@@ -19,13 +19,25 @@ class TosStorage:
|
||||
def __init__(self) -> None:
|
||||
tos = settings.TOS
|
||||
self.bucket = tos["bucket"]
|
||||
config_kwargs = {
|
||||
"signature_version": "s3v4",
|
||||
"s3": {"addressing_style": "virtual"},
|
||||
}
|
||||
try:
|
||||
client_config = Config(
|
||||
**config_kwargs,
|
||||
request_checksum_calculation="when_required",
|
||||
response_checksum_validation="when_required",
|
||||
)
|
||||
except TypeError:
|
||||
client_config = Config(**config_kwargs)
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=tos["endpoint"],
|
||||
aws_access_key_id=tos["access_key_id"],
|
||||
aws_secret_access_key=tos["secret_access_key"],
|
||||
region_name="cn-shanghai",
|
||||
config=Config(s3={"addressing_style": "virtual"}),
|
||||
region_name=tos.get("region") or "cn-shanghai",
|
||||
config=client_config,
|
||||
)
|
||||
|
||||
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
|
||||
@@ -57,6 +69,13 @@ class TosStorage:
|
||||
def public_url(self, *, object_key: str, bucket: str | None = None) -> str:
|
||||
"""桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。
|
||||
相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存
|
||||
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。"""
|
||||
host = urlparse(settings.TOS["endpoint"]).netloc
|
||||
return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}"
|
||||
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。
|
||||
历史文件的 bucket 仍是火山引擎旧桶,必须用旧域名,不能拼到当前阿里云地址上。"""
|
||||
bucket_name = bucket or self.bucket
|
||||
endpoint = settings.TOS.get("endpoint") or ""
|
||||
legacy_bucket = settings.TOS.get("legacy_bucket") or ""
|
||||
legacy_endpoint = settings.TOS.get("legacy_endpoint") or ""
|
||||
if legacy_bucket and legacy_endpoint and bucket_name == legacy_bucket:
|
||||
endpoint = legacy_endpoint
|
||||
host = urlparse(endpoint).netloc
|
||||
return f"https://{bucket_name}.{host}/{quote(object_key, safe='/')}"
|
||||
|
||||
@@ -20,6 +20,74 @@ def _mk_team(username, team_name):
|
||||
return user, team
|
||||
|
||||
|
||||
class AssetUploadReviewGateTests(TestCase):
|
||||
"""通用本地上传必须审核通过,才暴露可引用的资产数据。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("upload-review-user", "UploadReview")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.stored = SimpleNamespace(
|
||||
object_key="teams/upload-review/uploads/photo.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
size_bytes=12,
|
||||
)
|
||||
|
||||
def _post(self):
|
||||
return self.client.post(
|
||||
"/api/assets/upload/",
|
||||
{
|
||||
"file": SimpleUploadedFile("photo.png", b"fake-image", content_type="image/png"),
|
||||
"asset_type": Asset.Type.IMAGE,
|
||||
"category": Asset.Category.UPLOAD,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
def test_returns_asset_and_links_only_after_review_is_active(self):
|
||||
def approve(asset, **_kwargs):
|
||||
asset.review_status = "active"
|
||||
asset.review_remote_id = "remote-approved"
|
||||
asset.save(update_fields=["review_status", "review_remote_id"])
|
||||
return "active"
|
||||
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=True
|
||||
), patch("apps.assets.views.wait_upload_review", side_effect=approve) as wait:
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["review_status"], "active")
|
||||
self.assertIn("files", response.json())
|
||||
wait.assert_called_once()
|
||||
|
||||
def test_processing_review_does_not_return_asset_link(self):
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=True
|
||||
), patch("apps.assets.views.wait_upload_review", return_value="processing"):
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 408)
|
||||
self.assertEqual(response.json()["review_status"], "processing")
|
||||
self.assertNotIn("files", response.json())
|
||||
self.assertNotIn("url", response.json())
|
||||
|
||||
def test_unavailable_review_service_does_not_return_asset_link(self):
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=False
|
||||
), patch("apps.assets.views.wait_upload_review") as wait:
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json()["review_status"], "unavailable")
|
||||
self.assertNotIn("files", response.json())
|
||||
wait.assert_not_called()
|
||||
|
||||
|
||||
class ModelLibraryApiTests(TestCase):
|
||||
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
||||
|
||||
@@ -421,7 +489,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
storage.public_url.return_value = "http://tos/a.png"
|
||||
|
||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 202)
|
||||
group = FreeAssetGroup.objects.get(team=self.team)
|
||||
self.assertEqual(group.name, "默认素材")
|
||||
self.assertEqual(group.remote_group_id, "Group-1")
|
||||
@@ -429,6 +497,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
asset = group.assets.get()
|
||||
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
||||
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
|
||||
self.assertEqual(res.json()["asset"]["url"], "")
|
||||
self.assertEqual(res.json()["asset"]["thumb_url"], "")
|
||||
create_group.assert_called_once()
|
||||
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
|
||||
self.assertEqual(res.json()["group"]["id"], str(group.id))
|
||||
@@ -445,8 +515,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
|
||||
first = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("a.png")}, format="multipart")
|
||||
second = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("b.png")}, format="multipart")
|
||||
self.assertEqual(first.status_code, 201)
|
||||
self.assertEqual(second.status_code, 201)
|
||||
self.assertEqual(first.status_code, 202)
|
||||
self.assertEqual(second.status_code, 202)
|
||||
self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
|
||||
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
|
||||
create_group.assert_called_once()
|
||||
@@ -475,7 +545,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
|
||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("new.png")}, format="multipart")
|
||||
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 202)
|
||||
self.assertFalse(FreeAsset.objects.filter(group=old_group).exists())
|
||||
new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team)
|
||||
self.assertEqual(new_group.remote_group_id, "Group-2")
|
||||
@@ -888,22 +958,52 @@ class ReviewScopeTests(TestCase):
|
||||
("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"),
|
||||
)
|
||||
|
||||
def test_poll_team_reviews_only_scans_review_categories(self):
|
||||
def test_poll_only_covers_assets_that_really_reached_volcano(self):
|
||||
"""轮询的判据是「拿到了火山远端 Id」,不是类别。
|
||||
|
||||
force 送审的上传视频、临时图也会是 processing,worker 必须盯到绿/红;
|
||||
反过来,没有远端 Id 的 processing 是没真正送出去的,轮它只会白打请求。
|
||||
送审范围仍然只挑 REVIEW_CATEGORIES(见下一条)。
|
||||
"""
|
||||
from apps.assets import review
|
||||
|
||||
_, team = _mk_team("uc", "TeamC")
|
||||
keep = {}
|
||||
polled = {}
|
||||
for cat in ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"):
|
||||
a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
|
||||
category=cat, review_status="processing")
|
||||
keep[cat] = str(a.id)
|
||||
# 图片趴:不应进送审队列
|
||||
for cat in ("model_tryon", "platform_kit", "free_create"):
|
||||
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
|
||||
category=cat, review_status="processing")
|
||||
category=cat, review_status="processing",
|
||||
review_remote_id=f"remote-{cat}")
|
||||
polled[cat] = str(a.id)
|
||||
# 图片趴不在送审范围里,但已经 force 送出去的同样要盯到终态。
|
||||
forced = Asset.objects.create(
|
||||
team=team, name="force", asset_type="image", source="ai_generated",
|
||||
category="free_create", review_status="processing", review_remote_id="remote-force",
|
||||
)
|
||||
# 没有远端 Id = 没真送出去,不该被轮询。
|
||||
Asset.objects.create(team=team, name="没送出去", asset_type="image", source="ai_generated",
|
||||
category="person", review_status="processing")
|
||||
|
||||
with patch("apps.assets.review.assets_client.is_enabled", return_value=False):
|
||||
out = review.poll_team_reviews(team)
|
||||
self.assertEqual(set(out.keys()), set(keep.values()))
|
||||
|
||||
self.assertEqual(set(out.keys()), set(polled.values()) | {str(forced.id)})
|
||||
|
||||
def test_submission_still_only_covers_review_categories(self):
|
||||
from apps.assets import review
|
||||
|
||||
_, team = _mk_team("uc-sub", "TeamCSub")
|
||||
for cat in ("person", "product_image"):
|
||||
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
|
||||
category=cat, review_status="")
|
||||
for cat in ("model_tryon", "platform_kit", "free_create"):
|
||||
Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated",
|
||||
category=cat, review_status="")
|
||||
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub:
|
||||
review.submit_unsubmitted_reviews(team=team)
|
||||
|
||||
submitted = {asset.category for (asset,), _ in (call for call in sub.call_args_list)}
|
||||
self.assertEqual(submitted, {"person", "product_image"})
|
||||
|
||||
def test_poll_team_reviews_auto_submits_unsubmitted(self):
|
||||
from apps.assets import review
|
||||
@@ -931,6 +1031,7 @@ class ReviewScopeTests(TestCase):
|
||||
proc = Asset.objects.create(
|
||||
team=team, name="审中", asset_type="image", source="ai_generated",
|
||||
category=Asset.Category.PERSON, review_status="processing",
|
||||
review_remote_id="remote-proc", # 只有真的送到火山、拿到 Id 的才会被轮询
|
||||
)
|
||||
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch(
|
||||
"apps.assets.review.poll_asset_review", return_value="active"
|
||||
|
||||
@@ -19,7 +19,9 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
|
||||
from . import assets_client
|
||||
from .models import Asset, AssetFile, Model
|
||||
from .review import wait_upload_review
|
||||
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
||||
from .storage import TosStorage
|
||||
|
||||
@@ -43,6 +45,15 @@ _KNOWN_CATS = [
|
||||
]
|
||||
|
||||
|
||||
def _not_omni_create_q() -> Q:
|
||||
"""「不是全能创作产出」。不能只写 ~Q(metadata__feature="omni_create")。
|
||||
|
||||
metadata 里没有 feature 键时取值是 SQL NULL,NOT(NULL = 'x') 还是 NULL,整行会被筛掉 ——
|
||||
历史资产(没写过 feature)会从「视频自由创作」「自由创作」「素材」这些分桶里整批消失。
|
||||
"""
|
||||
return Q(metadata__feature__isnull=True) | ~Q(metadata__feature="omni_create")
|
||||
|
||||
|
||||
def _tab_q(tab: str) -> Q:
|
||||
if tab == "people":
|
||||
return Q(category="person")
|
||||
@@ -57,13 +68,13 @@ def _tab_q(tab: str) -> Q:
|
||||
if tab == "image_creations": # 图片自由创作
|
||||
return Q(category="free_create", asset_type="image")
|
||||
if tab == "video_creations": # 视频自由创作
|
||||
return Q(category="free_create", asset_type="video") & ~Q(metadata__feature="omni_create")
|
||||
return Q(category="free_create", asset_type="video") & _not_omni_create_q()
|
||||
if tab == "creations": # 自由创作(兼容旧入口:图片+视频)
|
||||
return Q(category="free_create") & ~Q(metadata__feature="omni_create")
|
||||
return Q(category="free_create") & _not_omni_create_q()
|
||||
if tab == "uploads":
|
||||
return Q(category="upload")
|
||||
if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列)
|
||||
return ~Q(category="final_video") & Q(asset_type="video") & ~Q(metadata__feature="omni_create")
|
||||
return ~Q(category="final_video") & Q(asset_type="video") & _not_omni_create_q()
|
||||
if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底)
|
||||
return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video"))
|
||||
if tab == "unclassified": # 未归类且非视频(也不含最终成片)
|
||||
@@ -482,6 +493,14 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
if self.request.query_params.get("q"):
|
||||
q = self.request.query_params["q"]
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q))
|
||||
# 标签筛选: ?tag=男人 或 ?tag=男人&tag=警察(多标签 AND)
|
||||
raw_tags = self.request.query_params.getlist("tag") or []
|
||||
if not raw_tags:
|
||||
joined = (self.request.query_params.get("tags") or "").strip()
|
||||
if joined:
|
||||
raw_tags = [t.strip() for t in joined.split(",") if t.strip()]
|
||||
for tag in raw_tags:
|
||||
qs = qs.filter(metadata__tags__contains=[tag])
|
||||
# 官方模板靠前,再按新→旧
|
||||
return qs.order_by("-is_official", "-created_at")
|
||||
|
||||
@@ -605,6 +624,18 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
model.save(update_fields=["purged_at", "updated_at"])
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="tags")
|
||||
def tags(self, request):
|
||||
"""当前可见模特里出现过的标签(供前端筛选条)· 只返回后台标记为显示的。"""
|
||||
from .model_library_tags import public_tag_rows
|
||||
|
||||
tab = (request.query_params.get("tab") or "").strip() or None
|
||||
if tab not in (None, "official", "mine"):
|
||||
tab = None
|
||||
team = self.get_team()
|
||||
items = public_tag_rows(tab=tab, team_id=getattr(team, "id", None))
|
||||
return Response({"results": items})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser])
|
||||
def upload(self, request):
|
||||
"""真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。"""
|
||||
@@ -640,13 +671,27 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
raw_tags = request.data.get("tags")
|
||||
tags: list[str] = []
|
||||
if isinstance(raw_tags, str) and raw_tags.strip():
|
||||
import json
|
||||
try:
|
||||
parsed = json.loads(raw_tags)
|
||||
if isinstance(parsed, list):
|
||||
tags = [str(t).strip() for t in parsed if str(t).strip()]
|
||||
else:
|
||||
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||||
except Exception:
|
||||
tags = [t.strip() for t in raw_tags.split(",") if t.strip()]
|
||||
elif isinstance(raw_tags, list):
|
||||
tags = [str(t).strip() for t in raw_tags if str(t).strip()]
|
||||
model = Model.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
name=name,
|
||||
source=Model.Source.UPLOAD,
|
||||
portrait_asset=portrait,
|
||||
metadata={"source": "upload"},
|
||||
metadata={"source": "upload", "tags": tags},
|
||||
)
|
||||
return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -735,7 +780,6 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
class AssetUploadView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
serializer = AssetUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -750,6 +794,9 @@ class AssetUploadView(APIView):
|
||||
object_key=object_key,
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
)
|
||||
# 先持久化,审核服务才能从 TOS 拉取文件;网络审核绝不能放在数据库事务里,
|
||||
# 否则几十秒的轮询会长期锁住请求事务。
|
||||
with transaction.atomic():
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
@@ -768,4 +815,31 @@ class AssetUploadView(APIView):
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
# 所有本地上传(图片/视频/音频)一律 force 送审。未通过、审核中或审核
|
||||
# 服务不可用时都不返回 AssetSerializer,避免提前泄露可供生成使用的链接。
|
||||
if not assets_client.is_enabled():
|
||||
return Response(
|
||||
{"detail": "素材审核服务暂不可用,请稍后重试", "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
review_status = wait_upload_review(asset)
|
||||
asset.refresh_from_db()
|
||||
if review_status == "active":
|
||||
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)
|
||||
if review_status == "failed":
|
||||
detail = (asset.review_error or "素材未通过审核,请更换后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "failed", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if review_status == "processing":
|
||||
return Response(
|
||||
{"detail": "素材仍在审核中,请稍后重试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||
)
|
||||
detail = (asset.review_error or "素材审核服务暂不可用,请稍后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
@@ -113,7 +113,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) -> CreditLedger:
|
||||
def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None, metadata: dict | None = None) -> CreditLedger:
|
||||
"""平台超管手动调额(争议 / 补偿)。amount 可正可负;落 ADJUSTMENT 流水并更新余额。
|
||||
调额后余额不得为负(否则拒绝)。"""
|
||||
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
||||
@@ -129,7 +129,7 @@ def adjust_credit(*, team, amount: Decimal, reason: str = "", operator=None) ->
|
||||
amount=amount,
|
||||
balance_after=new_balance,
|
||||
reason=reason or "平台手动调额",
|
||||
metadata={"kind": "admin_adjust"},
|
||||
metadata={"kind": "admin_adjust", **(metadata or {})},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
@@ -25,6 +26,9 @@ from .models import Notification
|
||||
from .serializers import NotificationSerializer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def project_stage_label(project):
|
||||
return {
|
||||
"script": "Stage 1 · 脚本",
|
||||
@@ -288,7 +292,13 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if not Notification.objects.filter(team=team).exists():
|
||||
ensure_team_notifications(team, request.user)
|
||||
return
|
||||
if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60):
|
||||
# 通知列表是普通读接口;Redis 不可用时跳过后台刷新,不能因此返回 500。
|
||||
try:
|
||||
should_refresh = cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60)
|
||||
except Exception:
|
||||
logger.warning("通知刷新缓存不可用,跳过本次后台刷新", exc_info=True)
|
||||
should_refresh = False
|
||||
if should_refresh:
|
||||
from .tasks import ensure_team_notifications_task
|
||||
|
||||
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
||||
|
||||
@@ -37,6 +37,22 @@ class QuickCreateApiTests(TestCase):
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def _fake_video_model(self, name="doubao-seedance-2-0-fast-260128"):
|
||||
"""提交时会先按 metadata["pricing"] 预估费用,缺价目一律 400 —— 假模型必须带价。"""
|
||||
return SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
display_name="Seedance 2.0 Fast",
|
||||
metadata={
|
||||
"capabilities": {
|
||||
"resolutions": ["480p", "720p"],
|
||||
"aspect_ratios": ["9:16"],
|
||||
"durations": [15, 30],
|
||||
},
|
||||
"pricing": {"default": {"no_ref_video": 46, "with_ref_video": 28}},
|
||||
},
|
||||
)
|
||||
|
||||
def _uploaded_asset(self, **kwargs):
|
||||
upload = kwargs["upload"]
|
||||
return Asset.objects.create(
|
||||
@@ -55,16 +71,9 @@ class QuickCreateApiTests(TestCase):
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
video_model_id = uuid.uuid4()
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
SimpleNamespace(
|
||||
id=video_model_id,
|
||||
name="doubao-seedance-2-0-fast-260128",
|
||||
display_name="Seedance 2.0 Fast",
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
|
||||
),
|
||||
]
|
||||
video_model = self._fake_video_model()
|
||||
video_model_id = video_model.id
|
||||
get_model.side_effect = [object(), video_model]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
@@ -120,12 +129,7 @@ class QuickCreateApiTests(TestCase):
|
||||
"""刷新页面时前端拉状态有空档,用户容易以为「没任务」再提交一次 ——
|
||||
那会重复建商品、重复扣费。后端必须拦住,并把在跑的那条带回去。"""
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
video_model = SimpleNamespace(
|
||||
id=uuid.uuid4(),
|
||||
name="doubao-seedance-2-0-fast-260128",
|
||||
display_name="Seedance 2.0 Fast",
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
|
||||
)
|
||||
video_model = self._fake_video_model()
|
||||
get_model.side_effect = [object(), video_model, object(), video_model]
|
||||
|
||||
def _post():
|
||||
@@ -167,10 +171,7 @@ class QuickCreateApiTests(TestCase):
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_requires_product_category(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={}),
|
||||
]
|
||||
get_model.side_effect = [object(), self._fake_video_model("seedance")]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
@@ -200,7 +201,7 @@ class QuickCreateApiTests(TestCase):
|
||||
)
|
||||
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
|
||||
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
|
||||
get_model.side_effect = [object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
|
||||
get_model.side_effect = [object(), self._fake_video_model("seedance")]
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
@@ -230,7 +231,7 @@ class QuickCreateApiTests(TestCase):
|
||||
other = User.objects.create_user(username="quick-image-other", password="pass")
|
||||
other_team = Team.objects.create(name="Image Other Team", owner=other)
|
||||
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
|
||||
get_model.side_effect = [object(), SimpleNamespace(metadata={})]
|
||||
get_model.side_effect = [object(), self._fake_video_model("seedance")]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
@@ -1371,6 +1372,12 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
- **语气**:穿搭分享、身材/场景导向,强调上身效果。
|
||||
- **卖点侧重**:版型显瘦/显高、面料舒适、好搭配、场合适配、尺码建议。
|
||||
- **常用露出**:上身使用中、面料细节特写、多场景/多角度展示。
|
||||
- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语。
|
||||
- **常用露出**:已穿着上身效果、面料细节特写、走动垂坠、多场景/多角度展示;换款用瞬间切换。
|
||||
- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语;**不要写穿衣服、套袖、扣扣、拉拉链等穿戴过程镜头**。
|
||||
- **示例**:「这个版型是真的藏肉,梨形身材穿上腿一下显直,通勤约会都能穿。」
|
||||
|
||||
## 家居日用
|
||||
|
||||
|
Before Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 256 KiB |
@@ -6,6 +6,20 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="icon" href="/assets/yz/logo.png" />
|
||||
<title>影擎</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var key = "yingqing-appearance";
|
||||
var raw = localStorage.getItem(key) || "system";
|
||||
var theme = raw;
|
||||
if (raw !== "light" && raw !== "dark") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"be": "npm --prefix ../.. run be",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 2.0 MiB |
@@ -21,6 +21,7 @@ import type {
|
||||
UserPreference
|
||||
} from "./types";
|
||||
import { publicModelDisplayName } from "./model-display";
|
||||
import { applyTheme, normalizeAppearance, readStoredAppearance } from "./theme";
|
||||
import { generationErrorText } from "./generation-error";
|
||||
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
||||
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
||||
@@ -143,6 +144,22 @@ export function App() {
|
||||
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
|
||||
const exportingRef = useRef(false);
|
||||
const [preferences, setPreferences] = useState<UserPreference | null>(null);
|
||||
|
||||
// 首屏先用本地外观,后端 preferences 到达后再对齐;跟随系统时监听系统切换
|
||||
useEffect(() => {
|
||||
applyTheme(readStoredAppearance());
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => {
|
||||
if (readStoredAppearance() === "system") applyTheme("system");
|
||||
};
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const appearance = preferences?.display?.appearance;
|
||||
if (appearance) applyTheme(normalizeAppearance(appearance));
|
||||
}, [preferences?.display?.appearance]);
|
||||
|
||||
const [sessions, setSessions] = useState<LoginSession[]>([]);
|
||||
|
||||
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
||||
@@ -211,19 +228,25 @@ export function App() {
|
||||
}
|
||||
}, [loadData]);
|
||||
|
||||
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
|
||||
// 设置页数据:偏好 + 登录会话。settingsLoadSeq 挡住过期 GET 覆盖更新的 PUT 结果。
|
||||
const settingsLoadSeq = useRef(0);
|
||||
const loadSettingsData = useCallback(async () => {
|
||||
const seq = ++settingsLoadSeq.current;
|
||||
const [pref, sess] = await Promise.all([
|
||||
api.preferences().catch(() => null),
|
||||
api.loginSessions().catch(() => [])
|
||||
]);
|
||||
if (seq !== settingsLoadSeq.current) return;
|
||||
if (pref) setPreferences(pref);
|
||||
setSessions(sess);
|
||||
}, []);
|
||||
|
||||
async function savePreferences(payload: Partial<UserPreference>) {
|
||||
const next = await api.updatePreferences(payload).catch(() => null);
|
||||
if (next) setPreferences(next);
|
||||
// 失败必须抛出,不能吞成 null —— 否则 settings 会误以为已落盘并推进 baseline
|
||||
const next = await api.updatePreferences(payload);
|
||||
// 使在途的 preferences GET 失效,避免刚保存的外观被旧响应打回 system
|
||||
settingsLoadSeq.current += 1;
|
||||
setPreferences(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -358,13 +381,17 @@ export function App() {
|
||||
}, [page, route.projectId, route.productId]);
|
||||
|
||||
// 平台后台 gating(身份就绪后):
|
||||
// - 平台超管且无团队:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading)
|
||||
// - 平台超管不进工作台:任何非 /admin 路由都送回后台
|
||||
// - 非超管访问 /admin/*:纠回工作台
|
||||
useLayoutEffect(() => {
|
||||
if (booting || !user?.is_platform_admin || route.admin !== undefined) return;
|
||||
navigateAdmin("", { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, route.admin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (booting || !user) return;
|
||||
if (user.is_platform_admin && !team && route.admin === undefined) {
|
||||
navigateAdmin("", { replace: true });
|
||||
} else if (!user.is_platform_admin && route.admin !== undefined) {
|
||||
if (!user.is_platform_admin && route.admin !== undefined) {
|
||||
navigate("omniCreate", { replace: true });
|
||||
}
|
||||
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
|
||||
@@ -386,6 +413,8 @@ export function App() {
|
||||
useLayoutEffect(() => {
|
||||
if (booting || !user || page !== "dashboard") return;
|
||||
if (route.admin !== undefined) return;
|
||||
// 超管由上面的后台 gating 接走,这里不能再改写成全能创作。
|
||||
if (user.is_platform_admin) return;
|
||||
navigate("omniCreate", { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [booting, user, page, route.admin]);
|
||||
@@ -393,16 +422,30 @@ export function App() {
|
||||
// Load preferences + sessions when entering settings.
|
||||
// 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存
|
||||
useEffect(() => {
|
||||
if (!authed || !dataLoaded) return;
|
||||
// 模型目录是创作页的必要数据,不能被商品/项目等全局数据的加载状态卡住。
|
||||
// 否则任一非关键列表短暂失败时,模型按钮仍显示默认名却展开空菜单。
|
||||
if (!authed) return;
|
||||
if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return;
|
||||
refreshModelConfigs();
|
||||
}, [authed, page, dataLoaded, refreshModelConfigs]);
|
||||
}, [authed, page, refreshModelConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||
loadSettingsData();
|
||||
}, [authed, page, loadSettingsData]);
|
||||
|
||||
// 登录后拉一次偏好(外观等),不必等进设置页才生效;仍走 settingsLoadSeq,与设置页重载互斥
|
||||
useEffect(() => {
|
||||
if (!authed || !user) return;
|
||||
const seq = ++settingsLoadSeq.current;
|
||||
void api.preferences()
|
||||
.then((pref) => {
|
||||
if (seq !== settingsLoadSeq.current) return;
|
||||
if (pref) setPreferences(pref);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [authed, user]);
|
||||
|
||||
// YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮)
|
||||
useEffect(() => {
|
||||
if (!authed || !user) return;
|
||||
@@ -888,23 +931,22 @@ export function App() {
|
||||
setTeam(payload.team);
|
||||
setRole(payload.role || "");
|
||||
setBooting(false);
|
||||
// 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错)
|
||||
if (payload.user.is_platform_admin && !payload.team) {
|
||||
// 平台超管只进后台,不拉工作台数据。
|
||||
if (payload.user.is_platform_admin) {
|
||||
setAuthed(true);
|
||||
navigateAdmin("", { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("omniCreate", { replace: true });
|
||||
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
||||
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
||||
try {
|
||||
await loadDataWithRetry();
|
||||
} catch (error) {
|
||||
console.error("[login] data hydrate failed:", error);
|
||||
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
|
||||
} finally {
|
||||
// 登录凭证已验证即可进入工作台。此前等待商品/项目等整套数据(包含重试)才
|
||||
// setAuthed,任一慢接口都会让登录页长时间卡在「正在进入工作台」;而刷新后的
|
||||
// boot 路径本来就是先展示外壳、后台补数据。首次登录也保持同一体验。
|
||||
setDataLoaded(false);
|
||||
setAuthed(true);
|
||||
}
|
||||
void loadDataWithRetry().catch((error) => {
|
||||
console.error("[login] data hydrate failed:", error);
|
||||
setNotice({ type: "error", text: "部分数据加载失败,页面会自动重试" });
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -934,7 +976,6 @@ export function App() {
|
||||
<ConfirmModal
|
||||
open={sessionInvalidated}
|
||||
title="当前账号已在其他设备登录"
|
||||
subtitle="// SESSION REPLACED"
|
||||
icon={<MonitorOff size={16} />}
|
||||
detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。"
|
||||
confirmText="确认并返回登录"
|
||||
@@ -970,9 +1011,7 @@ export function App() {
|
||||
<AdminApp
|
||||
section={route.admin}
|
||||
user={user}
|
||||
team={team}
|
||||
navigateAdmin={navigateAdmin}
|
||||
navigate={navigate}
|
||||
logout={logout}
|
||||
/>
|
||||
{sessionInvalidatedModal}
|
||||
@@ -1213,9 +1252,9 @@ export function App() {
|
||||
case "modelPhotoDemoB":
|
||||
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
|
||||
case "settings":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(type, text) => setNotice({ type, text })} onLogout={logout} />;
|
||||
case "settingsNotify":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(type, text) => setNotice({ type, text })} onLogout={logout} />;
|
||||
default:
|
||||
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
}
|
||||
@@ -1376,7 +1415,8 @@ export function App() {
|
||||
)}
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
<span className="balance-chip-label">余额</span>
|
||||
<strong>{money(billing?.account.balance)}</strong>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: #0b1d4c;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: color .18s ease, background .18s ease, transform .18s ease;
|
||||
@@ -716,7 +716,7 @@
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -755,7 +755,7 @@
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bill-jump button:disabled {
|
||||
@@ -807,7 +807,7 @@
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 24px 70px rgba(18, 25, 38, 0.18);
|
||||
transform: translateY(10px) scale(.985);
|
||||
transition: transform .2s ease;
|
||||
@@ -840,7 +840,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.1);
|
||||
border-radius: 9px;
|
||||
color: #20242c;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.billing-contact-close:hover {
|
||||
|
||||
@@ -90,24 +90,6 @@
|
||||
border-top: 1px solid var(--st-line);
|
||||
background: rgba(28, 34, 43, 0.02);
|
||||
}
|
||||
.admin-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--st-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-back:hover { color: var(--st-text); background: var(--st-hover); }
|
||||
.admin-back svg { width: 16px; height: 16px; }
|
||||
|
||||
.admin-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -164,7 +146,7 @@
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
padding: 0 28px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||
}
|
||||
.admin-crumb {
|
||||
@@ -388,7 +370,7 @@
|
||||
|
||||
.admin-table-wrap { overflow-x: auto; }
|
||||
.admin-page table.t.admin-table {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-color: var(--st-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -644,7 +626,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--st-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -697,7 +679,7 @@
|
||||
.pt-foot .spacer { flex: 1; }
|
||||
|
||||
.admin-page .card-hard {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-color: var(--st-line);
|
||||
}
|
||||
.admin-page .empty-state {
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--af-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.asset-factory .af-search svg {
|
||||
width: 16px;
|
||||
@@ -229,7 +229,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -261,7 +261,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--af-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -280,7 +280,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -2746,7 +2746,7 @@
|
||||
place-items: center;
|
||||
border: 1px solid rgba(34, 42, 54, 0.18);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.yz-image .product-choice.active .choice-check,
|
||||
.yz-image .model-choice.active .choice-check,
|
||||
@@ -2761,7 +2761,7 @@
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.yz-image .product-library-choice,
|
||||
.yz-image .upload-choice {
|
||||
@@ -2909,7 +2909,7 @@
|
||||
}
|
||||
.yz-image .image-prompt:focus {
|
||||
border-color: rgba(0, 47, 167, 0.48);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
|
||||
}
|
||||
.yz-image .model-line {
|
||||
@@ -3310,7 +3310,7 @@
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
}
|
||||
.yz-image .platform-choice .p-logo.has-img { background: #fff; }
|
||||
.yz-image .platform-choice .p-logo.has-img { background: var(--surface); }
|
||||
|
||||
.yz-image .model-pill {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -323,9 +323,16 @@ export const api = {
|
||||
revokeInvitation(id: string) {
|
||||
return request<Invitation>(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" });
|
||||
},
|
||||
products(pageSize?: number) {
|
||||
const query = pageSize ? `?page_size=${pageSize}` : "";
|
||||
return request<Paginated<Product>>(`/api/products/${query}`);
|
||||
products(pageSizeOrParams?: number | { page?: number; pageSize?: number; q?: string }) {
|
||||
const params = typeof pageSizeOrParams === "number"
|
||||
? { pageSize: pageSizeOrParams }
|
||||
: (pageSizeOrParams || {});
|
||||
const qs = new URLSearchParams();
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
if (params.pageSize) qs.set("page_size", String(params.pageSize));
|
||||
if (params.q?.trim()) qs.set("search", params.q.trim());
|
||||
const query = qs.toString();
|
||||
return request<Paginated<Product>>(`/api/products/${query ? `?${query}` : ""}`);
|
||||
},
|
||||
product(id: string) {
|
||||
return request<Product>(`/api/products/${id}/`);
|
||||
@@ -875,14 +882,22 @@ export const api = {
|
||||
return request<Asset>(`/api/assets/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
},
|
||||
// 模特库:列出(本团队 ∪ 官方模板);tab=official 只看官方、mine 只看自建
|
||||
listModels(params: { tab?: "official" | "mine"; q?: string; portraitAsset?: string; pageSize?: number } = {}) {
|
||||
listModels(params: { tab?: "official" | "mine"; q?: string; tags?: string[]; portraitAsset?: string; page?: number; pageSize?: number } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.tab) qs.set("tab", params.tab);
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (params.portraitAsset) qs.set("portrait_asset", params.portraitAsset);
|
||||
for (const tag of params.tags || []) qs.append("tag", tag);
|
||||
if (params.page) qs.set("page", String(params.page));
|
||||
qs.set("page_size", String(params.pageSize ?? 200));
|
||||
return request<Paginated<ModelEntity>>(`/api/models/?${qs.toString()}`);
|
||||
},
|
||||
listModelTags(params: { tab?: "official" | "mine" } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.tab) qs.set("tab", params.tab);
|
||||
const q = qs.toString();
|
||||
return request<{ results: { name: string; label?: string; count: number }[] }>(`/api/models/tags/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
// 真人上传:一张人像图 → 建 model_portrait 资产 + Model 实体
|
||||
uploadModel(formData: FormData) {
|
||||
return request<ModelEntity>("/api/models/upload/", { method: "POST", body: formData });
|
||||
@@ -1216,6 +1231,12 @@ export const adminApi = {
|
||||
const q = qs.toString();
|
||||
return request<Paginated<AdminUser>>(`/api/admin/users/${q ? `?${q}` : ""}`);
|
||||
},
|
||||
createUser(payload: { username: string; display_name: string; password: string; initial_credits?: string }) {
|
||||
return request<AdminUser>("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
adjustUserCredit(id: string, payload: { amount: string; reason?: string }) {
|
||||
return request<AdminUser>(`/api/admin/users/${id}/credits/`, { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
toggleUser(id: string) {
|
||||
return request<AdminUser>(`/api/admin/users/${id}/toggle/`, { method: "POST" });
|
||||
},
|
||||
|
||||
@@ -584,8 +584,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
aria-hidden="true"
|
||||
style={{ transform: `translateY(${liquid.y}px)` }}
|
||||
>
|
||||
<svg className="liquid-shape" viewBox="0 0 132 76" preserveAspectRatio="none">
|
||||
<path fill="#101012" d="M0 11H103C120 11 130 22 130 38C130 54 120 65 103 65H0Z" />
|
||||
<svg className="liquid-shape" viewBox="0 0 152 76" preserveAspectRatio="none">
|
||||
<path fill="currentColor" d="M0 11H123C140 11 150 22 150 38C150 54 140 65 123 65H0Z" />
|
||||
</svg>
|
||||
{activeMajor && (
|
||||
<div className="indicator-content">
|
||||
|
||||
@@ -372,3 +372,12 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.omni-asset-picker-pager {
|
||||
margin-top: 14px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.omni-asset-picker-pager .list-pager {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { Pager } from "./pager";
|
||||
import type { CreationRef } from "../types";
|
||||
import "./asset-select-modal.css";
|
||||
|
||||
@@ -24,11 +25,7 @@ interface AssetSelectModalProps {
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}
|
||||
|
||||
const assetLibraryCache = new Map<string, CreationRef[]>();
|
||||
|
||||
function assetLibraryKey(type: AssetModalType, query: string) {
|
||||
return `${type}:${query.trim().toLocaleLowerCase("zh-CN")}`;
|
||||
}
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export function AssetSelectModal({
|
||||
open,
|
||||
@@ -40,7 +37,8 @@ export function AssetSelectModal({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [items, setItems] = useState<CreationRef[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState("");
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRef, setSelectedRef] = useState<CreationRef | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent");
|
||||
@@ -57,34 +55,31 @@ export function AssetSelectModal({
|
||||
|
||||
const titleText = type === "product" ? "商品" : "角色";
|
||||
const title = type === "product" ? "商品库" : "角色库";
|
||||
const subtitle = type === "product" ? "[ PRODUCT · SELECT ]" : "[ CHARACTER · SELECT ]";
|
||||
const requestKey = assetLibraryKey(type, debouncedQuery);
|
||||
const cachedItems = assetLibraryCache.get(requestKey);
|
||||
const currentItems = loadedKey === requestKey ? items : (cachedItems || []);
|
||||
const hasCurrentData = loadedKey === requestKey || Boolean(cachedItems);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (sortOrder === "recent") return currentItems;
|
||||
return [...currentItems].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}, [currentItems, sortOrder]);
|
||||
if (sortOrder === "recent") return items;
|
||||
return [...items].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}, [items, sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsAddingNew(false);
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedRef(null);
|
||||
setSortOrder("recent");
|
||||
setNewFile(null);
|
||||
setNewPreview("");
|
||||
setNewName("");
|
||||
setPage(1);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDebouncedQuery("");
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setDebouncedQuery("");
|
||||
@@ -94,45 +89,68 @@ export function AssetSelectModal({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, searchQuery]);
|
||||
|
||||
// 换库 / 搜索时回到第 1 页
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPage(1);
|
||||
setSelectedRef(null);
|
||||
}, [type, debouncedQuery]);
|
||||
|
||||
const cached = assetLibraryCache.get(requestKey);
|
||||
if (cached) {
|
||||
setItems(cached);
|
||||
setLoadedKey(requestKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open || isAddingNew) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setSelectedRef(null);
|
||||
const types: CreationRef["type"][] = type === "product" ? ["product"] : ["model", "character"];
|
||||
void api
|
||||
.searchMentions({ q: debouncedQuery, types, limit: 20 })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const results = response.results || [];
|
||||
assetLibraryCache.set(requestKey, results);
|
||||
setItems(results);
|
||||
setLoadedKey(requestKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
assetLibraryCache.set(requestKey, []);
|
||||
setItems([]);
|
||||
setLoadedKey(requestKey);
|
||||
notifyRef.current?.("error", (error as Error).message || "加载失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (type === "character") {
|
||||
const response = await api.listModels({
|
||||
q: debouncedQuery || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const mapped: CreationRef[] = (response.results || [])
|
||||
.filter((m) => m.portrait)
|
||||
.map((m) => ({
|
||||
type: "model",
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
cover: m.portrait,
|
||||
}));
|
||||
setItems(mapped);
|
||||
setTotal(response.count ?? mapped.length);
|
||||
} else {
|
||||
const response = await api.products({
|
||||
q: debouncedQuery || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const mapped: CreationRef[] = (response.results || []).map((p) => ({
|
||||
type: "product",
|
||||
id: p.id,
|
||||
name: p.title,
|
||||
cover: p.cover_preview_url || "",
|
||||
}));
|
||||
setItems(mapped);
|
||||
setTotal(response.count ?? mapped.length);
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
notifyRef.current?.("error", (error as Error).message || "加载失败");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedQuery, open, requestKey, type]);
|
||||
}, [debouncedQuery, open, page, type, isAddingNew]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -199,9 +217,6 @@ export function AssetSelectModal({
|
||||
}
|
||||
|
||||
notifyRef.current?.("success", `已新增${titleText}:${finalRef.name}`);
|
||||
for (const key of assetLibraryCache.keys()) {
|
||||
if (key.startsWith(`${type}:`)) assetLibraryCache.delete(key);
|
||||
}
|
||||
onSelect(finalRef);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -234,7 +249,6 @@ export function AssetSelectModal({
|
||||
</span>
|
||||
<div className="ti" id={titleId}>
|
||||
{isAddingNew ? `新增${titleText}` : title}
|
||||
<span>{isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle}</span>
|
||||
</div>
|
||||
<div className="omni-asset-picker-head-actions">
|
||||
{isAddingNew ? (
|
||||
@@ -328,8 +342,8 @@ export function AssetSelectModal({
|
||||
<option value="name">按名称</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="modal-b omni-asset-picker-body" aria-busy={loading || !hasCurrentData}>
|
||||
{!hasCurrentData ? (
|
||||
<div className="modal-b omni-asset-picker-body" aria-busy={loading}>
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="omni-asset-picker-grid is-loading" role="status" aria-label={`正在加载${titleText}库`}>
|
||||
{Array.from({ length: 10 }, (_, index) => (
|
||||
<div className="omni-asset-picker-skeleton" key={index} aria-hidden="true">
|
||||
@@ -353,7 +367,8 @@ export function AssetSelectModal({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-asset-picker-grid">
|
||||
<>
|
||||
<div className={`omni-asset-picker-grid${loading ? " is-loading" : ""}`}>
|
||||
{visibleItems.map((item) => {
|
||||
const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id;
|
||||
const displayName = item.name.split(" · ")[0];
|
||||
@@ -380,11 +395,14 @@ export function AssetSelectModal({
|
||||
{isSelected ? <span className="omni-asset-picker-check"><Check /></span> : null}
|
||||
</span>
|
||||
<span className="omni-asset-picker-card-name" title={displayName}>{displayName}</span>
|
||||
<span className="mono">// {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="omni-asset-picker-pager">
|
||||
<Pager page={page} total={total} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<footer className="modal-f">
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
||||
import { Check, CheckCircle2, ChevronLeft, CircleX, Clock3, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
||||
import { api } from "../../api";
|
||||
import { useFileDrop } from "../use-file-drop";
|
||||
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
||||
import { useBodyScrollLock } from "../overlays";
|
||||
|
||||
const STATUS_PILL: Record<FreeAssetItem["status"], { cls: string; label: string }> = {
|
||||
processing: { cls: "pill-info", label: "审核中" },
|
||||
active: { cls: "pill-ok", label: "可用" },
|
||||
failed: { cls: "pill-err", label: "未通过" }
|
||||
const STATUS_ICON: Record<FreeAssetItem["status"], { cls: string; label: string; Icon: typeof CheckCircle2 }> = {
|
||||
processing: { cls: "is-processing", label: "审核中,暂不可引用", Icon: Clock3 },
|
||||
active: { cls: "is-active", label: "审核通过,可被引用", Icon: CheckCircle2 },
|
||||
failed: { cls: "is-failed", label: "审核未通过,无法引用", Icon: CircleX }
|
||||
};
|
||||
|
||||
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
@@ -97,8 +97,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [open, activeGroup, assets]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const createGroup = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || creatingGroup) return;
|
||||
@@ -124,7 +122,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
form.append("file", file);
|
||||
const data = await api.uploadFreeAsset(activeGroup.id, form);
|
||||
setAssets((prev) => [data.asset, ...prev]);
|
||||
notify("success", "素材已上传,审核中");
|
||||
notify("info", "素材已提交审核,通过后即可引用");
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||
} finally {
|
||||
@@ -151,7 +149,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
const data = await api.quickUploadFreeAsset(form);
|
||||
setActiveGroup(data.group);
|
||||
setAssets([data.asset]);
|
||||
notify("success", "图片已上传到默认素材组,审核中");
|
||||
notify("info", "图片已提交审核,通过后即可引用");
|
||||
void loadGroupDetail(data.group);
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||
@@ -160,6 +158,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
}
|
||||
};
|
||||
|
||||
// 所有 Hook 必须在关闭态也保持同一调用顺序;不能在此之前提前 return。
|
||||
if (!open) return null;
|
||||
|
||||
const startRenameAsset = (item: FreeAssetItem) => {
|
||||
setConfirmDeleteAssetId(null);
|
||||
setConfirmDeleteGroupId(null);
|
||||
@@ -442,7 +443,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
{assets.map((item) => {
|
||||
const pill = STATUS_PILL[item.status];
|
||||
const statusIcon = STATUS_ICON[item.status];
|
||||
const StatusIcon = statusIcon.Icon;
|
||||
const isEditing = editingAssetId === item.id;
|
||||
const isDeleting = confirmDeleteAssetId === item.id;
|
||||
return (
|
||||
@@ -457,6 +459,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<div className="fc-lib-thumb">
|
||||
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
|
||||
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
|
||||
<span className={`fc-lib-status-icon ${statusIcon.cls}`} title={statusIcon.label} aria-label={statusIcon.label}>
|
||||
<StatusIcon size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
|
||||
@@ -488,7 +493,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
) : (
|
||||
<div className="fc-lib-name" title={item.name}>{item.name}</div>
|
||||
)}
|
||||
<span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
||||
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>{item.error_message}</div>}
|
||||
<div className="fc-lib-ops">
|
||||
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
100% { background-position: 480px 0; }
|
||||
}
|
||||
.skeleton-block {
|
||||
background: var(--background-base, #f1f1f1);
|
||||
background-image: linear-gradient(90deg, rgba(0,0,0,0.03) 0%, rgba(0,0,0,0.07) 40%, rgba(0,0,0,0.03) 80%);
|
||||
background: var(--black-alpha-4);
|
||||
background-image: linear-gradient(90deg, transparent 0%, var(--black-alpha-8) 40%, transparent 80%);
|
||||
background-size: 480px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: sk-shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -28,8 +28,7 @@ export function SystemLoading({
|
||||
<span className="system-loading-corner corner-br" aria-hidden="true">+</span>
|
||||
|
||||
<div className="system-loading-meta mono">
|
||||
<span>[ AIRSHELF / SYSTEM ]</span>
|
||||
<span>{state === "error" ? "[ CONNECTION ERROR ]" : "[ SYNCING ]"}</span>
|
||||
<span>{state === "error" ? "连接异常" : "同步中"}</span>
|
||||
</div>
|
||||
|
||||
<div className="system-loading-main">
|
||||
|
||||
@@ -18,7 +18,7 @@ const modelToAsset = (m: ModelEntity): Asset => ({
|
||||
source: m.source === "upload" ? "upload" : "ai_generated",
|
||||
category: "model_portrait",
|
||||
description: "",
|
||||
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official },
|
||||
metadata: { kind: "model", model_entity_id: m.id, is_official: m.is_official, tags: Array.isArray(m.metadata?.tags) ? m.metadata.tags : [] },
|
||||
files: m.portrait ? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }] : [],
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at
|
||||
@@ -61,20 +61,34 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
|
||||
// 模特选择器只拉 Model;不再把普通 person 资产并入可选列表。
|
||||
const [fetched, setFetched] = useState<Asset[]>([]);
|
||||
const [tagFilter, setTagFilter] = useState<string[]>([]);
|
||||
const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]);
|
||||
const reload = useCallback(async () => {
|
||||
const models = await api.listModels({ pageSize: 200 }).catch(() => null);
|
||||
const models = await api.listModels({ pageSize: 200, tags: tagFilter }).catch(() => null);
|
||||
const mapped = (models?.results ?? []).filter((m) => m.portrait_asset).map(modelToAsset);
|
||||
setFetched(mapped);
|
||||
}, []);
|
||||
}, [tagFilter]);
|
||||
useEffect(() => { if (open) void reload(); }, [open, reload]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
api.listModelTags()
|
||||
.then((r) => { if (alive) setTagOptions(r.results || []); })
|
||||
.catch(() => { if (alive) setTagOptions([]); });
|
||||
return () => { alive = false; };
|
||||
}, [open]);
|
||||
useEffect(() => { if (open) setTagFilter([]); }, [open]);
|
||||
|
||||
const list = useMemo(() => fetched.filter((a) => previewOf(a)), [fetched]);
|
||||
function toggleTag(name: string) {
|
||||
setTagFilter((prev) => prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]);
|
||||
}
|
||||
|
||||
// 模特库分页(客户端):一页 10 个(5 列 × 2 行)。
|
||||
const PAGE_SIZE = 10;
|
||||
// 模特库分页(客户端):一页 20 个。
|
||||
const PAGE_SIZE = 20;
|
||||
const [page, setPage] = useState(1);
|
||||
const pageCount = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
||||
useEffect(() => { setPage(1); }, [open]);
|
||||
useEffect(() => { setPage(1); }, [open, tagFilter]);
|
||||
useEffect(() => { setPage((p) => Math.min(p, pageCount)); }, [pageCount]);
|
||||
const pageList = list.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
@@ -222,6 +236,24 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
添加模特
|
||||
</button>
|
||||
</div>
|
||||
{tagOptions.length > 0 && (
|
||||
<div className="actorlib-tag-bar" role="toolbar" aria-label="按标签筛选" style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm${tagFilter.length === 0 ? " btn-primary" : ""}`}
|
||||
onClick={() => setTagFilter([])}
|
||||
>全部</button>
|
||||
{tagOptions.slice(0, 24).map((tag) => (
|
||||
<button
|
||||
key={tag.name}
|
||||
type="button"
|
||||
className={`btn btn-sm${tagFilter.includes(tag.name) ? " btn-primary" : ""}`}
|
||||
onClick={() => toggleTag(tag.name)}
|
||||
title={`${tag.count} 个`}
|
||||
>{tag.label || tag.name}<span className="mono" style={{ marginLeft: 4, opacity: 0.65 }}>{tag.count}</span></button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{list.length ? (
|
||||
<>
|
||||
<div className="actorlib-grid">
|
||||
|
||||
@@ -10,6 +10,8 @@ export const OMNI_VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0
|
||||
export const OMNI_IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
|
||||
export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"];
|
||||
export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
|
||||
/** 临时只开放 ≤60 秒;90/120/180 长视频入口先隐藏。 */
|
||||
export const OMNI_MAX_VIDEO_DURATION = 60;
|
||||
export const OMNI_VIDEO_DURATIONS = [
|
||||
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒",
|
||||
"11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", "45 秒", "60 秒",
|
||||
@@ -84,20 +86,22 @@ function catalogModelLabels(
|
||||
}
|
||||
|
||||
function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string): boolean {
|
||||
// 31–60 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这个总时长。
|
||||
// 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。
|
||||
return modelDurations(config).some((seconds) => seconds >= 30)
|
||||
|| /seedance\s*2\.5/i.test(model || "");
|
||||
}
|
||||
|
||||
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
|
||||
if (!isVideo) return [...OMNI_IMAGE_COUNTS];
|
||||
const seconds = modelDurations(config);
|
||||
const seconds = modelDurations(config).filter((n) => n <= OMNI_MAX_VIDEO_DURATION);
|
||||
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS];
|
||||
const segmented = canCreateSegmentedVideo(config, model) ? [45, 60] : [];
|
||||
const segmented = canCreateSegmentedVideo(config, model)
|
||||
? [45, 60].filter((n) => n <= OMNI_MAX_VIDEO_DURATION)
|
||||
: [];
|
||||
return ["智能时长", ...seconds, ...segmented]
|
||||
.filter((seconds, index, values) => values.indexOf(seconds) === index)
|
||||
.map((seconds) => typeof seconds === "number" ? `${seconds} 秒` : seconds);
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.map((value) => typeof value === "number" ? `${value} 秒` : value);
|
||||
}
|
||||
|
||||
export function OmniParamBar({
|
||||
@@ -198,8 +202,13 @@ export function OmniParamBar({
|
||||
}
|
||||
const nextDur = durationLabelsForModel(selected, model, true);
|
||||
const seconds = Number(String(duration || "").replace(/\D/g, ""));
|
||||
const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model);
|
||||
// 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。
|
||||
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
|
||||
if (seconds > OMNI_MAX_VIDEO_DURATION) {
|
||||
onDuration(`${OMNI_MAX_VIDEO_DURATION} 秒`);
|
||||
return;
|
||||
}
|
||||
const isPlannedSegmentedDuration = seconds > 30 && seconds <= OMNI_MAX_VIDEO_DURATION && canCreateSegmentedVideo(selected, model);
|
||||
// 31–60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就偷偷改回 8 秒/智能时长。
|
||||
if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) {
|
||||
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react";
|
||||
import { CheckCircle2, Download, Inbox, Music, Shield, Trash2, X } from "lucide-react";
|
||||
|
||||
// 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。
|
||||
// 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。
|
||||
@@ -55,12 +55,13 @@ export function useOverlayTransition(open: boolean, onClose?: () => void, durati
|
||||
|
||||
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
|
||||
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
|
||||
export function MediaLightbox({ open, src, kind, name, close }: {
|
||||
export function MediaLightbox({ open, src, kind, name, close, onDownload }: {
|
||||
open: boolean;
|
||||
src: string;
|
||||
kind?: "image" | "video" | "audio";
|
||||
name?: string;
|
||||
close: () => void;
|
||||
onDownload?: () => void;
|
||||
}) {
|
||||
useBodyScrollLock(open && !!src);
|
||||
useEffect(() => {
|
||||
@@ -73,7 +74,12 @@ export function MediaLightbox({ open, src, kind, name, close }: {
|
||||
// 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏
|
||||
return createPortal(
|
||||
<div className="np-lightbox show" onClick={close}>
|
||||
<div className="lb-actions" onClick={(event) => event.stopPropagation()}>
|
||||
{onDownload ? (
|
||||
<button className="lb-dl" type="button" aria-label="下载" title="下载" onClick={onDownload}><Download size={16} /></button>
|
||||
) : null}
|
||||
<button className="lb-x" type="button" aria-label="关闭" onClick={close}><X /></button>
|
||||
</div>
|
||||
{kind === "video" ? (
|
||||
<video
|
||||
src={src}
|
||||
|
||||
@@ -153,8 +153,228 @@
|
||||
--shadow-cta-active:
|
||||
0 1px 2px rgba(0, 47, 167, .18);
|
||||
--shadow-floating: 0 8px 28px rgba(17, 19, 24, .06);
|
||||
|
||||
/* 容器四角准星:用 mask 着色,让它跟随当前主题的边框层级。 */
|
||||
--corner-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21'%3E%3Cpath fill='%23000' 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");
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Dark theme · 同一套语义 token 反色
|
||||
由 html[data-theme="dark"] 驱动(见 theme.ts / settings 外观)
|
||||
============================================================ */
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background-base: #0f1115;
|
||||
--background-lighter: #14171c;
|
||||
--surface: #181b21;
|
||||
--surface-raised: #1e2229;
|
||||
|
||||
--border-faint: #2a2f38;
|
||||
--border-muted: #323843;
|
||||
--border-loud: #3c4450;
|
||||
|
||||
/* 主文字走近白;accent-white 保持真白(按钮/反色字) */
|
||||
--accent-black: #e8eaed;
|
||||
--accent-white: #ffffff;
|
||||
--nav-active: #e8eaed;
|
||||
|
||||
--forest-bg: rgba(66, 195, 102, .14);
|
||||
--forest-bd: rgba(66, 195, 102, .32);
|
||||
--crimson-bg: rgba(235, 52, 36, .14);
|
||||
--crimson-bd: rgba(235, 52, 36, .32);
|
||||
--honey-bg: rgba(236, 183, 48, .14);
|
||||
--honey-bd: rgba(236, 183, 48, .32);
|
||||
|
||||
/* 克莱因在深底上略提亮,避免发灰 */
|
||||
--klein: #3d6bff;
|
||||
--klein-hover: #5a82ff;
|
||||
--heat: var(--klein);
|
||||
--heat-hover: var(--klein-hover);
|
||||
|
||||
--black: var(--accent-black);
|
||||
--text: var(--accent-black);
|
||||
--heat-90: rgba(61, 107, 255, .90);
|
||||
--heat-40: rgba(61, 107, 255, .40);
|
||||
--heat-20: rgba(61, 107, 255, .20);
|
||||
--heat-16: rgba(61, 107, 255, .16);
|
||||
--heat-12: rgba(61, 107, 255, .14);
|
||||
--heat-8: rgba(61, 107, 255, .10);
|
||||
--heat-4: rgba(61, 107, 255, .06);
|
||||
|
||||
/* black-alpha → 白透明阶梯(同语义:轻微叠色) */
|
||||
--black-alpha-1: rgba(255, 255, 255, .015);
|
||||
--black-alpha-2: rgba(255, 255, 255, .025);
|
||||
--black-alpha-3: rgba(255, 255, 255, .035);
|
||||
--black-alpha-4: rgba(255, 255, 255, .05);
|
||||
--black-alpha-5: rgba(255, 255, 255, .06);
|
||||
--black-alpha-6: rgba(255, 255, 255, .07);
|
||||
--black-alpha-7: rgba(255, 255, 255, .08);
|
||||
--black-alpha-8: rgba(255, 255, 255, .09);
|
||||
--black-alpha-10: rgba(255, 255, 255, .11);
|
||||
--black-alpha-12: rgba(255, 255, 255, .13);
|
||||
--black-alpha-16: rgba(255, 255, 255, .16);
|
||||
--black-alpha-20: rgba(255, 255, 255, .20);
|
||||
--black-alpha-24: rgba(255, 255, 255, .24);
|
||||
--black-alpha-32: rgba(255, 255, 255, .32);
|
||||
--black-alpha-40: rgba(255, 255, 255, .40);
|
||||
--black-alpha-48: rgba(255, 255, 255, .48);
|
||||
--black-alpha-56: rgba(255, 255, 255, .56);
|
||||
--black-alpha-64: rgba(255, 255, 255, .64);
|
||||
--black-alpha-72: rgba(255, 255, 255, .72);
|
||||
--black-alpha-88: rgba(255, 255, 255, .88);
|
||||
|
||||
--shadow-cta:
|
||||
0 1px 2px rgba(61, 107, 255, .22),
|
||||
0 6px 16px rgba(61, 107, 255, .28);
|
||||
--shadow-cta-hover:
|
||||
0 2px 4px rgba(61, 107, 255, .26),
|
||||
0 10px 22px rgba(61, 107, 255, .34);
|
||||
--shadow-cta-active:
|
||||
0 1px 2px rgba(61, 107, 255, .22);
|
||||
--shadow-floating: 0 8px 28px rgba(0, 0, 0, .45);
|
||||
|
||||
/* styles.css legacy aliases */
|
||||
--bg: var(--background-base);
|
||||
--bg-soft: var(--background-lighter);
|
||||
--card: var(--surface);
|
||||
--border: var(--border-faint);
|
||||
--border-soft: var(--border-muted);
|
||||
--ink: var(--accent-black);
|
||||
--ink-2: rgba(232, 234, 237, .72);
|
||||
--ink-3: rgba(232, 234, 237, .52);
|
||||
--ink-4: rgba(232, 234, 237, .32);
|
||||
--orange: var(--klein);
|
||||
--orange-soft: var(--heat-40);
|
||||
--orange-tint: var(--heat-8);
|
||||
--orange-hover: var(--klein-hover);
|
||||
--green: #6dd88a;
|
||||
--green-bg: var(--forest-bg);
|
||||
--green-bd: var(--forest-bd);
|
||||
--red: #ff6b5c;
|
||||
--red-bg: var(--crimson-bg);
|
||||
--red-bd: var(--crimson-bd);
|
||||
}
|
||||
|
||||
/* 壳层硬编码浅色 → dark 对齐 */
|
||||
html[data-theme="dark"] aside.sidebar {
|
||||
/* 盖过 aside.sidebar 本地 --klein:#002fa7,否则子菜单 active 仍是深蓝 */
|
||||
--klein: #3d6bff;
|
||||
--klein-hover: #5a82ff;
|
||||
--st-black: #e8eaed;
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-submenu {
|
||||
border-left-color: rgba(61, 107, 255, 0.32);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem {
|
||||
color: rgba(232, 234, 237, 0.78);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem:hover {
|
||||
color: #a8bbff;
|
||||
background: rgba(61, 107, 255, 0.14);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem.active {
|
||||
color: #c5d2ff;
|
||||
background: rgba(61, 107, 255, 0.2);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem.active::before {
|
||||
box-shadow: 0 0 0 4px rgba(61, 107, 255, 0.22);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem .pill-mini {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #a8bbff;
|
||||
}
|
||||
html[data-theme="dark"] .sidebar-head {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
html[data-theme="dark"] .brand-logo {
|
||||
mix-blend-mode: normal;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.92;
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item {
|
||||
color: rgba(232, 234, 237, 0.78);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item .nav-desc,
|
||||
html[data-theme="dark"] aside.sidebar .nav-item .nav-sub {
|
||||
color: rgba(232, 234, 237, 0.42);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item.active,
|
||||
html[data-theme="dark"] aside.sidebar .nav-item[aria-current="page"] {
|
||||
color: #e8eaed;
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .org-switcher,
|
||||
html[data-theme="dark"] aside.sidebar .org-switcher span {
|
||||
color: rgba(232, 234, 237, 0.78);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .org-switcher svg {
|
||||
color: rgba(232, 234, 237, 0.48);
|
||||
}
|
||||
html[data-theme="dark"] .user .av {
|
||||
background: #3a4050;
|
||||
color: #fff;
|
||||
}
|
||||
html[data-theme="dark"] .grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||
}
|
||||
html[data-theme="dark"] .topbar {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
html[data-theme="dark"] .top-search {
|
||||
border-color: #3a4050;
|
||||
color: #e8eaed;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
html[data-theme="dark"] .top-search:hover {
|
||||
border-color: #4a5160;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
html[data-theme="dark"] .queue-chip,
|
||||
html[data-theme="dark"] .balance-chip {
|
||||
border-color: var(--border-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] aside.sidebar .nav-item .nav-chevron {
|
||||
color: rgba(232, 234, 237, 0.42);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item:hover {
|
||||
color: #e8eaed;
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .liquid-shape {
|
||||
/* 深色下不用纯黑胶囊,改用品牌蓝选中态 */
|
||||
color: #3d6bff;
|
||||
filter: drop-shadow(0 5px 10px rgba(61, 107, 255, 0.28));
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-liquid,
|
||||
html[data-theme="dark"] aside.sidebar .nav-capsule {
|
||||
background: rgba(61, 107, 255, 0.12);
|
||||
box-shadow: 0 0 0 4px rgba(61, 107, 255, 0.12);
|
||||
}
|
||||
html[data-theme="dark"] .main,
|
||||
html[data-theme="dark"] .content,
|
||||
html[data-theme="dark"] .page {
|
||||
background: var(--background-base);
|
||||
color: var(--accent-black);
|
||||
}
|
||||
html[data-theme="dark"] .icon-btn {
|
||||
background: var(--surface);
|
||||
border-color: var(--border-faint);
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
html[data-theme="dark"] .icon-btn:hover {
|
||||
background: var(--background-lighter);
|
||||
color: var(--accent-black);
|
||||
border-color: var(--border-loud);
|
||||
}
|
||||
|
||||
|
||||
::selection { background: var(--heat-20); color: var(--heat); }
|
||||
|
||||
html, body {
|
||||
@@ -268,7 +488,7 @@ aside.sidebar {
|
||||
min-height: var(--topbar-height);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.07);
|
||||
}
|
||||
.brand { display: flex; align-items: center; justify-content: center; min-width: 0; color: var(--accent-black); }
|
||||
@@ -607,7 +827,7 @@ aside.sidebar .nav-subitem .pill-mini {
|
||||
padding: 0 5px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--klein);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -619,7 +839,7 @@ aside.sidebar .liquid-indicator {
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 132px;
|
||||
width: 152px;
|
||||
height: 76px;
|
||||
pointer-events: none;
|
||||
transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -635,6 +855,7 @@ aside.sidebar .liquid-shape {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
color: #101012;
|
||||
filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.14));
|
||||
transform-origin: left center;
|
||||
}
|
||||
@@ -651,7 +872,7 @@ aside.sidebar .indicator-content {
|
||||
top: 0;
|
||||
left: 33px;
|
||||
height: 76px;
|
||||
width: 94px;
|
||||
width: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
@@ -776,7 +997,7 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: clip;
|
||||
@@ -827,7 +1048,7 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 clamp(36px, 3.2vw, 68px);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 4px 16px rgba(20, 27, 38, 0.035);
|
||||
position: sticky; top: 0; z-index: var(--z-topbar);
|
||||
align-self: start;
|
||||
@@ -1168,8 +1389,11 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
.with-corners .corner-tr, .with-corners .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;
|
||||
background-color: var(--border-muted);
|
||||
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||
mask: var(--corner-mark) center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-hard.with-corners::before { top: -7px; left: -7px; }
|
||||
@@ -1238,6 +1462,12 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
.field-label .req { color: var(--accent-crimson); margin-left: 2px; }
|
||||
.field-hint { font-size: 12px; color: var(--black-alpha-48); }
|
||||
.field-hint.is-error { color: var(--accent-crimson); }
|
||||
/* 标签内联注解(取值范围 / 单位 / 可留空):跟 .req 一样附在 .field-label 里,mono 灰弱化 */
|
||||
.field-label .lbl-note { color: var(--black-alpha-48); font-weight: 400; margin-left: 2px; font-family: var(--font-mono); }
|
||||
/* 数字输入:去掉浏览器上下箭头 + 等宽数字,避免输入时字宽跳动 */
|
||||
.num-input::-webkit-outer-spin-button,
|
||||
.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.num-input { -moz-appearance: textfield; font-variant-numeric: tabular-nums; }
|
||||
.input, .textarea, .select {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
@@ -2025,9 +2255,16 @@ select.duration-select:focus,
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.np-lightbox .lb-x {
|
||||
.np-lightbox .lb-actions {
|
||||
position: fixed;
|
||||
top: 24px; right: 24px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.np-lightbox .lb-x,
|
||||
.np-lightbox .lb-dl {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
@@ -2038,8 +2275,10 @@ select.duration-select:focus,
|
||||
place-items: center;
|
||||
transition: background var(--t-base);
|
||||
}
|
||||
.np-lightbox .lb-x:hover { background: rgba(255, 255, 255, .24); }
|
||||
.np-lightbox .lb-x svg { width: 18px; height: 18px; }
|
||||
.np-lightbox .lb-x:hover,
|
||||
.np-lightbox .lb-dl:hover { background: rgba(255, 255, 255, .24); }
|
||||
.np-lightbox .lb-x svg,
|
||||
.np-lightbox .lb-dl svg { width: 18px; height: 18px; }
|
||||
.np-lightbox .lb-name {
|
||||
position: fixed;
|
||||
bottom: 24px; left: 50%;
|
||||
@@ -2254,6 +2493,11 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
color: var(--black-alpha-56);
|
||||
font-weight: 500;
|
||||
}
|
||||
html[data-theme="dark"] .placeholder .ph-frame {
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--border-faint);
|
||||
color: var(--black-alpha-56);
|
||||
}
|
||||
.placeholder.has-mock-media {
|
||||
background-color: var(--background-lighter);
|
||||
background-image: var(--mock-media-url);
|
||||
@@ -2584,6 +2828,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
margin: 6px 4px;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Modal ─── */
|
||||
.modal-bg {
|
||||
position: fixed; inset: 0;
|
||||
@@ -2617,24 +2862,27 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
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;
|
||||
background-color: var(--border-muted);
|
||||
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||
mask: var(--corner-mark) center / contain no-repeat;
|
||||
pointer-events: none;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px; line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
.modal::before { top: -7px; left: -7px; }
|
||||
.modal::after { bottom: -7px; right: -7px; }
|
||||
.modal::before { top: 0; left: 0; }
|
||||
.modal::after { bottom: 0; right: 0; }
|
||||
.modal .corner-tr, .modal .corner-bl {
|
||||
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;
|
||||
background-color: var(--border-muted);
|
||||
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||
mask: var(--corner-mark) center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.modal .corner-tr { top: -7px; right: -7px; }
|
||||
.modal .corner-bl { bottom: -7px; left: -7px; }
|
||||
.modal .corner-tr { top: 0; right: 0; }
|
||||
.modal .corner-bl { bottom: 0; left: 0; }
|
||||
.modal-h {
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
@@ -2701,6 +2949,12 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
font-family: inherit;
|
||||
}
|
||||
.card-del-btn svg { width: 14px; height: 14px; }
|
||||
html[data-theme="dark"] .card-del-btn {
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--border-faint);
|
||||
color: var(--black-alpha-56);
|
||||
box-shadow: none;
|
||||
}
|
||||
.product-card:hover .card-del-btn,
|
||||
.asset-card:hover .card-del-btn,
|
||||
.project-card:hover .card-del-btn,
|
||||
@@ -2789,10 +3043,18 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
z-index: 1200;
|
||||
/* 手机上的侧栏是抽屉,必须遮住页面内容,不能沿用桌面端的半透明背景。 */
|
||||
background: var(--surface-raised);
|
||||
border-right-color: var(--border-faint);
|
||||
transform: translateX(-100%);
|
||||
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, .18);
|
||||
}
|
||||
/* 暗色桌面侧栏可保留轻透层;窄屏抽屉必须是实底,避免透出页面内容。 */
|
||||
html[data-theme="dark"] aside.sidebar {
|
||||
background: var(--surface-raised);
|
||||
border-right-color: var(--border-faint);
|
||||
}
|
||||
/* 抽屉里收窄态不适用,强制展开宽度,避免 body.sidebar-collapsed 把抽屉压成 96px */
|
||||
body.sidebar-collapsed aside.sidebar { width: var(--sidebar-width); }
|
||||
aside.sidebar.mobile-open { transform: translateX(0); }
|
||||
@@ -2807,8 +3069,22 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
}
|
||||
.mobile-nav-btn span { display: block; width: 16px; height: 1.6px; background: var(--accent-black); border-radius: 2px; }
|
||||
.mobile-nav-backdrop { position: fixed; inset: 0; z-index: 1150; background: rgba(21, 20, 15, .42); }
|
||||
/* 顶栏给汉堡键让出左侧空间 */
|
||||
.topbar { padding-left: 64px; }
|
||||
/* 顶栏给汉堡键让出左侧空间;搜索与余额压缩为单行工具,避免中文在窄窗逐字折行。 */
|
||||
.topbar { padding: 0 16px 0 64px; gap: 8px; }
|
||||
.topbar .right { gap: 8px; flex: 0 0 auto; }
|
||||
.top-search {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.top-search span { display: none; }
|
||||
.balance-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance-chip-label { display: none; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.stat:nth-child(2) { border-right: 0; }
|
||||
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
|
||||
@@ -2884,7 +3160,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
left: 0;
|
||||
width: 252px;
|
||||
padding: 11px 13px 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: var(--r-md);
|
||||
@@ -2906,7 +3182,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
position: absolute;
|
||||
top: -5px; left: 14px;
|
||||
width: 10px; height: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-top: 1px solid rgba(34, 42, 54, 0.10);
|
||||
transform: rotate(45deg);
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -598,7 +598,7 @@
|
||||
max-width: 280px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
@@ -708,7 +708,7 @@
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
min-width: 180px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
@@ -936,6 +936,23 @@
|
||||
.fc-lib-rename-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
|
||||
.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.fc-lib-status-icon {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
color: var(--black-alpha-56);
|
||||
}
|
||||
.fc-lib-status-icon.is-active { background: var(--forest-bg); color: var(--accent-forest); border-color: var(--forest-bd); }
|
||||
.fc-lib-status-icon.is-processing { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
|
||||
.fc-lib-status-icon.is-failed { background: var(--crimson-bg); color: var(--accent-crimson); border-color: var(--crimson-bd); }
|
||||
.fc-lib-status {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
@@ -966,7 +983,7 @@
|
||||
height: 7px;
|
||||
background: currentColor;
|
||||
}
|
||||
.fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
||||
.fc-lib-ops { position: absolute; top: 14px; left: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
||||
.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
|
||||
.fc-lib-ops button {
|
||||
width: 22px;
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--lib-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.library-page .lib-search svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--lib-muted); }
|
||||
.library-page .lib-search input {
|
||||
@@ -152,7 +152,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -175,7 +175,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--lib-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -197,7 +197,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyTheme, readStoredAppearance } from "./theme";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
@@ -22,5 +23,8 @@ import "./quick-create-page.css";
|
||||
import "./admin-page.css";
|
||||
import "./omni-create-page.css";
|
||||
import "./omni-session-page.css";
|
||||
import "./dark-mode-pages.css";
|
||||
|
||||
applyTheme(readStoredAppearance());
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--st-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -178,7 +178,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--st-muted);
|
||||
}
|
||||
.msg-search input {
|
||||
@@ -196,6 +196,8 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.msg-load-more {
|
||||
padding: 14px 16px 18px;
|
||||
@@ -274,6 +276,7 @@
|
||||
}
|
||||
|
||||
.msg-empty {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -113,9 +113,15 @@
|
||||
|
||||
.models-page .ml-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
}
|
||||
.models-page .ml-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@@ -198,6 +204,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.models-page .ml-check svg { width: 13px; height: 13px; }
|
||||
.models-page.manage-mode .ml-card:not(.selectable) { cursor: not-allowed; }
|
||||
.models-page.manage-mode .ml-card.selectable .ml-check { display: grid; }
|
||||
.models-page.manage-mode .ml-card.selected .ml-check {
|
||||
color: #fff;
|
||||
@@ -281,7 +288,10 @@
|
||||
.models-page .ml-sel-bar { left: 50%; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); }
|
||||
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.models-page .ml-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* 模特详情弹窗:参考视频项目角色详情结构 */
|
||||
@@ -318,9 +328,17 @@
|
||||
.model-detail-portrait[role="button"] { cursor: zoom-in; }
|
||||
.model-detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.model-detail-tags .pill {
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.model-detail-triview {
|
||||
width: 100%;
|
||||
@@ -351,3 +369,59 @@
|
||||
outline: 1.5px solid var(--heat);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.models-page .ml-tag-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.models-page .ml-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: #414750;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.models-page .ml-tag-chip i {
|
||||
font-style: normal;
|
||||
color: #9aa0a8;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
.models-page .ml-tag-chip:hover {
|
||||
border-color: rgba(0, 47, 167, .28);
|
||||
color: var(--klein);
|
||||
}
|
||||
.models-page .ml-tag-chip.is-on {
|
||||
border-color: transparent;
|
||||
background: var(--ml-black, #1b2028);
|
||||
color: #fff;
|
||||
}
|
||||
.models-page .ml-tag-chip.is-on i { color: rgba(255,255,255,.72); }
|
||||
|
||||
|
||||
.models-page .ml-tag-chip.ml-tag-scope {
|
||||
font-weight: 600;
|
||||
}
|
||||
.models-page .ml-tag-sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
min-height: 22px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.models-page .ml-pager {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.models-page .ml-pager .list-pager {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -36,24 +36,31 @@
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
border: 1.5px solid rgba(16, 16, 18, .22);
|
||||
border-radius: 12px;
|
||||
color: var(--black);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
box-shadow: 0 4px 12px rgba(16, 16, 18, .08);
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-home-history:hover {
|
||||
border-color: var(--black);
|
||||
background: #f7f7f8;
|
||||
border-color: var(--black-alpha-24);
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-home-history:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-home-history svg {
|
||||
@@ -378,7 +385,7 @@
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -447,7 +454,7 @@
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -582,7 +589,7 @@
|
||||
.omni-parameter .custom-select-trigger:hover,
|
||||
.omni-parameter .custom-select-shell.open .custom-select-trigger {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-parameter .custom-select-trigger svg {
|
||||
@@ -640,7 +647,7 @@
|
||||
.omni-duration-trigger:hover,
|
||||
.omni-duration-trigger[aria-expanded="true"] {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .07);
|
||||
}
|
||||
|
||||
@@ -664,7 +671,7 @@
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -738,7 +745,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #575e69;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
@@ -879,11 +886,27 @@
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.omni-case-visual img {
|
||||
transition: transform 260ms ease;
|
||||
}
|
||||
|
||||
/* 预览视频叠在封面上。播不起来时保持透明,封面继续可见。
|
||||
不要给 video 加 scale:Safari 里变换过的 video 会直接画成空白。 */
|
||||
.omni-case-visual video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.omni-case-visual video.is-playing {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.omni-case-card:hover .omni-case-visual img,
|
||||
.omni-case-card:hover .omni-case-visual video {
|
||||
.omni-case-card.active .omni-case-visual img {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
@@ -917,7 +940,7 @@
|
||||
|
||||
.omni-case-card[data-mode="image"] .omni-case-visual {
|
||||
height: auto;
|
||||
aspect-ratio: 4 / 3;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.omni-card-use {
|
||||
@@ -979,15 +1002,15 @@
|
||||
.omni-preset-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1040px, 100%);
|
||||
width: min(1180px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: 440px minmax(0, 1fr);
|
||||
gap: 42px;
|
||||
padding: 30px;
|
||||
grid-template-columns: 500px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-top: 3px solid var(--klein);
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 28px 70px rgba(10, 17, 30, .22);
|
||||
animation: omniMessageIn 180ms ease both;
|
||||
}
|
||||
@@ -1123,7 +1146,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 10px;
|
||||
color: #616873;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1215,7 +1238,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 10px;
|
||||
color: #414750;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1291,35 +1314,34 @@
|
||||
.omni-history-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-history-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr) 70px;
|
||||
grid-template-columns: 150px minmax(0, 1fr) 36px;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 17px;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .09);
|
||||
border-radius: 19px;
|
||||
border-radius: var(--r-md);
|
||||
background: rgba(255, 255, 255, .96);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 22px rgba(20, 27, 38, .05);
|
||||
transition: transform 170ms ease, border-color 170ms ease, box-shadow 170ms ease;
|
||||
box-shadow: none;
|
||||
transition: border-color 170ms ease, background-color 170ms ease;
|
||||
}
|
||||
|
||||
.omni-history-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 47, 167, .30);
|
||||
box-shadow: 0 13px 26px rgba(20, 27, 38, .09);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
|
||||
.omni-history-item > img {
|
||||
width: 150px;
|
||||
height: 112px;
|
||||
display: block;
|
||||
border-radius: 13px;
|
||||
border-radius: var(--r-md);
|
||||
object-fit: cover;
|
||||
background: #edf0f4;
|
||||
}
|
||||
@@ -1338,21 +1360,24 @@
|
||||
}
|
||||
|
||||
.omni-history-item h2 {
|
||||
margin: 8px 0 7px;
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
margin: 6px 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.omni-history-item p,
|
||||
.omni-history-item small {
|
||||
color: #858b94;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.omni-history-item small {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
margin-top: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.omni-history-actions {
|
||||
@@ -1373,14 +1398,16 @@
|
||||
}
|
||||
|
||||
.omni-history-actions button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: #858b94;
|
||||
background: #f4f5f7;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.omni-history-actions button:hover {
|
||||
@@ -1388,6 +1415,25 @@
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.omni-history-actions button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-history-item:hover .omni-history-actions button,
|
||||
.omni-history-item:focus-within .omni-history-actions button,
|
||||
.omni-history-actions button:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.omni-history-item:hover .omni-history-actions button {
|
||||
border-color: rgba(34, 42, 54, .09);
|
||||
background: #f4f5f7;
|
||||
}
|
||||
}
|
||||
|
||||
.omni-delete-confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1416,7 +1462,7 @@
|
||||
padding: 26px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
box-shadow: 0 26px 64px rgba(10, 17, 30, .22);
|
||||
animation: omniMessageIn 180ms ease both;
|
||||
@@ -1460,7 +1506,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 9px;
|
||||
color: #5e6570;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -1531,7 +1577,7 @@
|
||||
.omni-parameter .rs-select-btn:hover,
|
||||
.omni-parameter .rs-select.open .rs-select-btn {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-parameter .rs-select-btn svg {
|
||||
|
||||
@@ -71,12 +71,25 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 10px;
|
||||
color: #414750;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-session-back:hover {
|
||||
border-color: var(--black-alpha-24);
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-session-back:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-session-back svg {
|
||||
@@ -303,12 +316,21 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 小云雀式跟随追问:答案留在问题附近,尺寸不抢占对话空间。 */
|
||||
/* 小云雀式跟随追问:与上方选项同宽;与选项拉开一点距离。 */
|
||||
.omni-reply-guide:has(.omni-chat-choice-actions) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-chat-question-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: min(300px, 100%);
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.omni-chat-question-input .input {
|
||||
@@ -360,13 +382,34 @@
|
||||
|
||||
.omni-result-card,
|
||||
.omni-process-card {
|
||||
width: min(760px, calc(100% - 44px));
|
||||
box-sizing: border-box;
|
||||
width: min(320px, calc(100% - 44px));
|
||||
max-width: 320px;
|
||||
margin: 0 0 24px 44px;
|
||||
border: 1px solid rgba(34, 42, 54, .09);
|
||||
border-radius: 15px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
|
||||
animation: omniMessageIn 220ms ease both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.omni-result-card.has-multiple,
|
||||
.omni-process-card.has-multiple {
|
||||
width: min(520px, calc(100% - 44px));
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.omni-result-card.has-single.is-ratio-16-9,
|
||||
.omni-process-card.has-single.is-ratio-16-9 {
|
||||
width: min(480px, calc(100% - 44px));
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.omni-result-card.has-single.is-ratio-1-1,
|
||||
.omni-process-card.has-single.is-ratio-1-1 {
|
||||
width: min(340px, calc(100% - 44px));
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.omni-strategy-card,
|
||||
@@ -377,7 +420,7 @@
|
||||
margin: 0 0 24px 44px;
|
||||
border: 1px solid rgba(34, 42, 54, .09);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
||||
animation: omniMessageIn 220ms ease both;
|
||||
}
|
||||
@@ -564,7 +607,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .08);
|
||||
border-radius: 10px;
|
||||
color: #5b626d;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
@@ -659,7 +702,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 9px;
|
||||
color: #333944;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -682,7 +725,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, .20);
|
||||
border-radius: 9px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -784,7 +827,7 @@
|
||||
.omni-session-preset-trigger[aria-expanded="true"] {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-session-preset-trigger span {
|
||||
@@ -807,7 +850,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 24px 60px rgba(20, 27, 38, .18);
|
||||
transform: translateX(-52%);
|
||||
}
|
||||
@@ -1005,7 +1048,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 16px 36px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -1167,7 +1210,10 @@
|
||||
}
|
||||
|
||||
.omni-process-frame {
|
||||
height: 310px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -1178,6 +1224,14 @@
|
||||
animation: omniShimmer 1.2s ease infinite;
|
||||
}
|
||||
|
||||
.is-ratio-16-9 .omni-process-frame {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.is-ratio-1-1 .omni-process-frame {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.omni-process-frame strong {
|
||||
color: #5c6570;
|
||||
font-size: 14px;
|
||||
@@ -1211,6 +1265,12 @@
|
||||
|
||||
.omni-result-media.is-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.omni-result-card.has-many .omni-result-media.is-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-result-tile {
|
||||
@@ -1220,11 +1280,22 @@
|
||||
border-radius: var(--r-md, 12px);
|
||||
background: #edf0f4;
|
||||
box-shadow: 0 8px 20px rgba(20, 27, 38, .08);
|
||||
aspect-ratio: 9 / 16;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.is-ratio-16-9 .omni-result-tile {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.is-ratio-1-1 .omni-result-tile {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.omni-result-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -1233,18 +1304,19 @@
|
||||
|
||||
.omni-result-tile img {
|
||||
width: 100%;
|
||||
height: 310px;
|
||||
height: 100%;
|
||||
aspect-ratio: inherit;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: #edf0f4;
|
||||
}
|
||||
|
||||
.omni-result-media.is-grid .omni-result-tile img {
|
||||
height: 220px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.omni-result-media.is-grid .omni-process-frame {
|
||||
height: 220px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.omni-result-play {
|
||||
@@ -1387,6 +1459,10 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.omni-result-card.has-many .omni-result-media.is-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-direction-custom button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1644,7 +1720,7 @@
|
||||
padding: 7px 13px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: #575d66;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -1667,7 +1743,7 @@
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--black);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
@@ -1691,7 +1767,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1731,14 +1807,36 @@
|
||||
background: var(--klein);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 160ms ease;
|
||||
}
|
||||
|
||||
.omni-elicit-actions button.cancel-btn {
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text-secondary, #606a78);
|
||||
border: 1px solid var(--border-faint, #e2e8f0);
|
||||
}
|
||||
|
||||
.omni-elicit-actions button.cancel-btn:hover:not(:disabled) {
|
||||
background: var(--background-hover, #f8fafc);
|
||||
color: var(--accent-black, #1a202c);
|
||||
border-color: var(--border-subtle, #cbd5e1);
|
||||
}
|
||||
|
||||
.omni-elicit-actions button:disabled {
|
||||
background: rgba(34, 42, 54, .18);
|
||||
color: rgba(255, 255, 255, .7);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.omni-elicit-actions button.cancel-btn:disabled {
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
border-color: var(--border-faint, #e2e8f0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 已提交的追问卡变成只读回顾,不能再改 —— 重复提交后端会 409 */
|
||||
.omni-elicit-card.is-submitted .omni-elicit-options button,
|
||||
.omni-elicit-card.is-submitted .omni-elicit-assets button {
|
||||
@@ -1759,7 +1857,7 @@
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
||||
/* 不走入场 opacity 动画:poll 重渲染时避免「闪一下」 */
|
||||
animation: none;
|
||||
@@ -1820,7 +1918,7 @@
|
||||
}
|
||||
|
||||
.omni-step-confirm-actions button.is-secondary {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.14);
|
||||
color: #222a36;
|
||||
}
|
||||
@@ -1861,7 +1959,7 @@
|
||||
|
||||
.omni-step-confirm-revise-input:focus {
|
||||
border-color: rgba(0, 47, 167, .42);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .14);
|
||||
}
|
||||
|
||||
@@ -1888,7 +1986,7 @@
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
/* 时长/参数下拉要冒出卡片,不能被裁 */
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -2160,7 +2258,8 @@
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
/* 高于顶栏(--z-topbar:50),避免往上展开时被 sticky 顶栏盖住(同层仍可能被裁,配合 is-preview-below) */
|
||||
z-index: calc(var(--z-topbar) + 20);
|
||||
width: 244px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -2182,6 +2281,18 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 靠近顶栏时改为向下展开 */
|
||||
.omni-mention-chip.has-thumb.is-preview-below .omni-mention-hover-preview {
|
||||
top: calc(100% + 10px);
|
||||
bottom: auto;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.omni-mention-chip.has-thumb.is-preview-below:hover .omni-mention-hover-preview,
|
||||
.omni-mention-chip.has-thumb.is-preview-below:focus-within .omni-mention-hover-preview {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-hover-preview {
|
||||
right: 0;
|
||||
left: auto;
|
||||
@@ -2340,7 +2451,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
color: #5c6570;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-reasoning {
|
||||
@@ -2348,7 +2459,13 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
color: #5c6570;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */
|
||||
.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning {
|
||||
width: min(620px, calc(100% - 44px));
|
||||
max-width: min(620px, calc(100% - 44px));
|
||||
}
|
||||
|
||||
.omni-think-head {
|
||||
@@ -2356,20 +2473,48 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5c6570;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-think-text {
|
||||
.omni-think-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 248px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px 2px 0;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.omni-think-log-row {
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid var(--border-muted);
|
||||
}
|
||||
|
||||
.omni-think-log-row.is-current {
|
||||
position: relative;
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.omni-think-log-row.is-current::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -1px;
|
||||
width: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--klein);
|
||||
animation: omniCurrentStepPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-think-log-row p {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: var(--black-alpha-64);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #8a93a0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid rgba(15, 23, 42, 0.08);
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.omni-typing {
|
||||
@@ -2395,6 +2540,11 @@
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes omniCurrentStepPulse {
|
||||
0%, 100% { opacity: .32; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes omniSendPulse {
|
||||
0%, 100% {
|
||||
background: #5b6572;
|
||||
@@ -2459,14 +2609,15 @@
|
||||
box-shadow: -8px 12px 40px rgba(20, 27, 38, .16);
|
||||
}
|
||||
|
||||
.omni-prompt-drawer header {
|
||||
.omni-prompt-drawer header,
|
||||
.omni-prompt-drawer-head {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 32px;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 32px 32px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 12px;
|
||||
border-bottom: 1px solid rgba(34, 42, 54, .08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-prompt-drawer-file {
|
||||
@@ -2484,29 +2635,35 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.omni-prompt-drawer header div {
|
||||
.omni-prompt-drawer header div,
|
||||
.omni-prompt-drawer-head div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.omni-prompt-drawer header strong,
|
||||
.omni-prompt-drawer header small {
|
||||
.omni-prompt-drawer header small,
|
||||
.omni-prompt-drawer-head strong,
|
||||
.omni-prompt-drawer-head small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-prompt-drawer header strong {
|
||||
.omni-prompt-drawer header strong,
|
||||
.omni-prompt-drawer-head strong {
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-prompt-drawer header small {
|
||||
.omni-prompt-drawer header small,
|
||||
.omni-prompt-drawer-head small {
|
||||
margin-top: 2px;
|
||||
color: #8b919a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.omni-prompt-drawer header button {
|
||||
.omni-prompt-drawer header button,
|
||||
.omni-prompt-drawer-head button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
@@ -2540,7 +2697,7 @@
|
||||
padding: 14px 15px;
|
||||
border: 1px solid rgba(34, 42, 54, .06);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 6px 16px rgba(20, 27, 38, .04);
|
||||
}
|
||||
|
||||
@@ -2612,7 +2769,7 @@
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-prompt-table {
|
||||
@@ -2703,6 +2860,47 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.omni-person-generate-panel {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--background-lighter);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-person-generate-panel label {
|
||||
color: var(--accent-black);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-person-generate-panel .textarea {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.omni-person-generate-panel > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.omni-person-generate-panel > div > span {
|
||||
flex: 1 1 auto;
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.omni-person-generate-panel > div > button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */
|
||||
.omni-chat-choice-actions {
|
||||
width: 100%;
|
||||
@@ -3079,6 +3277,248 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 对话资源 / 文档卡下载 */
|
||||
.omni-doc-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.omni-doc-download {
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-doc-download:hover {
|
||||
border-color: var(--border-loud);
|
||||
background: var(--background-lighter);
|
||||
color: var(--klein);
|
||||
}
|
||||
|
||||
.omni-doc-download svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.omni-prompt-file-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-prompt-file-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.omni-prompt-file-actions button.is-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-faint);
|
||||
color: var(--accent-black);
|
||||
}
|
||||
|
||||
.omni-prompt-file-actions button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.omni-assets-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.omni-assets-card-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.omni-assets-card.is-doc .omni-assets-card-main,
|
||||
.omni-assets-grid.is-list .omni-assets-card-main {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.omni-assets-card-main:disabled {
|
||||
cursor: default;
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.omni-assets-dl {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 1;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: rgba(16, 16, 18, .72);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .16s ease;
|
||||
}
|
||||
|
||||
.omni-assets-card.is-doc .omni-assets-dl,
|
||||
.omni-assets-grid.is-list .omni-assets-dl {
|
||||
position: static;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--accent-black);
|
||||
background: var(--background-lighter);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.omni-assets-card:hover .omni-assets-dl,
|
||||
.omni-assets-dl:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.omni-assets-dl:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-assets-dl svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.omni-assets-dl {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.omni-model-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.omni-model-picker-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--t-base), box-shadow var(--t-base);
|
||||
}
|
||||
|
||||
.omni-model-picker-card:hover,
|
||||
.omni-model-picker-card.is-selected {
|
||||
background: var(--background-lighter);
|
||||
box-shadow: inset 0 0 0 1px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-model-picker-card.is-selected {
|
||||
color: var(--heat);
|
||||
}
|
||||
|
||||
.omni-model-picker-media {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: var(--background-base);
|
||||
color: var(--black-alpha-48);
|
||||
}
|
||||
|
||||
.omni-model-picker-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.omni-model-picker-media svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.omni-model-picker-copy {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0 2px 2px;
|
||||
}
|
||||
|
||||
.omni-model-picker-copy strong,
|
||||
.omni-model-picker-copy small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-model-picker-copy strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-model-picker-copy small {
|
||||
margin-top: 2px;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.omni-model-picker-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.omni-model-picker-selection {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1101px) {
|
||||
.omni-session-page.has-assets-panel {
|
||||
box-sizing: border-box;
|
||||
@@ -3130,4 +3570,13 @@
|
||||
.omni-assets-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.omni-person-generate-panel > div {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.omni-person-generate-panel > div > button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pipeline-page .pl-rail-summary > span { color: var(--pl-muted); font-size: 10px; }
|
||||
.pipeline-page .pl-rail-track {
|
||||
@@ -139,7 +139,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.085);
|
||||
border-radius: 12px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
@@ -156,7 +156,7 @@
|
||||
}
|
||||
.pipeline-page .pl-step.static:hover {
|
||||
border-color: rgba(34, 42, 54, 0.085);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pipeline-page .pl-step.static:hover .pl-step-chev { color: var(--pl-muted); }
|
||||
.pipeline-page .pl-step.current {
|
||||
@@ -250,7 +250,7 @@
|
||||
.pipeline-page .pl-ghost {
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pipeline-page .pl-next {
|
||||
min-width: 210px;
|
||||
@@ -328,7 +328,7 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
.chat-msg.ai .chat-bubble {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||
border-radius: 8px 8px 8px 2px;
|
||||
}
|
||||
@@ -465,7 +465,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 11px 26px rgba(20, 27, 38, 0.07);
|
||||
}
|
||||
.shot-head {
|
||||
@@ -504,7 +504,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||
border-radius: 8px;
|
||||
color: var(--pl-muted);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.shot-icon-btn svg { width: 13px; height: 13px; }
|
||||
@@ -542,7 +542,7 @@
|
||||
.shot-area:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 47, 167, 0.38);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
|
||||
}
|
||||
.shot-dialogue { min-height: 42px; }
|
||||
@@ -564,7 +564,7 @@
|
||||
.icon-mini-btn.armed { width: auto; padding: 0 8px; font-size: 12px; color: var(--accent-crimson); border-color: var(--accent-crimson); background: var(--surface); }
|
||||
.shot-insert-gap { flex: 0 0 auto; height: 10px; position: relative; display: flex; align-items: center; justify-content: center; padding: 0; transition: height .24s cubic-bezier(.18,.72,.28,1), padding .24s cubic-bezier(.18,.72,.28,1); }
|
||||
.shot-insert-gap:hover { height: 72px; padding: 14px 0; }
|
||||
.shot-insert-gap .add-shot-btn { opacity: 0; transform: translateY(4px) scale(.96); height: 28px; padding: 0 14px; background: #fff; color: var(--klein); border: 1px dashed rgba(0, 47, 167, 0.35); border-radius: 8px; font-size: 13px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base); display: inline-flex; align-items: center; gap: 6px; pointer-events: none; white-space: nowrap; }
|
||||
.shot-insert-gap .add-shot-btn { opacity: 0; transform: translateY(4px) scale(.96); height: 28px; padding: 0 14px; background: var(--surface); color: var(--klein); border: 1px dashed rgba(0, 47, 167, 0.35); border-radius: 8px; font-size: 13px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base); display: inline-flex; align-items: center; gap: 6px; pointer-events: none; white-space: nowrap; }
|
||||
.shot-insert-gap .add-shot-btn svg { width: 12px; height: 12px; }
|
||||
.shot-insert-gap:hover .add-shot-btn { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
|
||||
.shot-insert-gap .add-shot-btn:hover { background: rgba(0, 47, 167, 0.06); border-style: solid; border-color: var(--klein); }
|
||||
@@ -613,7 +613,7 @@
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
@@ -638,7 +638,7 @@
|
||||
display: grid;
|
||||
grid-template-rows: 72px minmax(0, 1fr) 74px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.as-head {
|
||||
display: flex;
|
||||
@@ -677,7 +677,7 @@
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.075);
|
||||
}
|
||||
.as-row::before {
|
||||
@@ -755,7 +755,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 7px 18px rgba(20, 27, 38, 0.06);
|
||||
}
|
||||
.as-preview.ready {
|
||||
@@ -782,7 +782,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
background-color: var(--surface);
|
||||
background-image:
|
||||
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
|
||||
radial-gradient(ellipse 1px 4px at center, rgba(0, 47, 167, 0.28) 98%, transparent);
|
||||
@@ -807,7 +807,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.26);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -911,7 +911,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.085);
|
||||
}
|
||||
.as-gen-preview {
|
||||
@@ -923,7 +923,7 @@
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.as-gen-preview.ready {
|
||||
background-image: var(--mock-media-url);
|
||||
@@ -1001,7 +1001,7 @@
|
||||
}
|
||||
.as-gen-prompt:focus {
|
||||
border-color: rgba(0, 47, 167, 0.38);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.06);
|
||||
}
|
||||
.as-gen-actions {
|
||||
@@ -1088,7 +1088,7 @@
|
||||
.as-ai-btn svg, .as-ghost-btn svg { width: 14px; height: 14px; }
|
||||
.as-ai-btn:disabled, .as-ghost-btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
|
||||
.as-ai-btn-lg { min-height: 44px; margin-top: 18px; padding: 0 18px; font-size: 14px; }
|
||||
.as-ghost-btn { border: 1px solid rgba(0, 47, 167, 0.19); color: var(--klein); background: #fff; }
|
||||
.as-ghost-btn { border: 1px solid rgba(0, 47, 167, 0.19); color: var(--klein); background: var(--surface); }
|
||||
.as-ghost-btn:hover { transform: translateY(-1px); border-color: rgba(0, 47, 167, 0.38); background: #eef5ff; }
|
||||
.as-ghost-btn-sm { min-height: 30px; font-size: 12px; }
|
||||
@media (max-width: 1180px) {
|
||||
@@ -1264,14 +1264,14 @@
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.video-thumb.pending::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
background-color: var(--surface);
|
||||
background-image:
|
||||
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
|
||||
radial-gradient(ellipse 1px 4px at center, rgba(0, 47, 167, 0.28) 98%, transparent);
|
||||
@@ -1296,7 +1296,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.26);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: -28px 0 70px rgba(10, 15, 24, 0.22);
|
||||
transform: translateX(100%);
|
||||
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -422,7 +422,7 @@
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -445,7 +445,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -635,7 +635,7 @@
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
@@ -766,7 +766,7 @@
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.12);
|
||||
}
|
||||
.pcd-toast-ic {
|
||||
@@ -816,7 +816,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 11px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -845,7 +845,7 @@
|
||||
background: rgba(255, 255, 255, 0.90);
|
||||
box-shadow: 0 10px 28px rgba(20, 27, 38, 0.07);
|
||||
}
|
||||
.pcd-gallery-slot img { width: 100%; height: 100%; display: block; object-fit: contain; background: #fff; }
|
||||
.pcd-gallery-slot img { width: 100%; height: 100%; display: block; object-fit: contain; background: var(--surface); }
|
||||
.pcd-gallery-slot.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--pl-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pl-search svg {
|
||||
width: 16px;
|
||||
@@ -148,7 +148,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -181,7 +181,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--pl-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -203,7 +203,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -316,6 +316,35 @@
|
||||
border-color: var(--klein);
|
||||
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--pl-shadow);
|
||||
}
|
||||
.pl-skeleton-card {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
.pl-skeleton-card:hover { transform: none; }
|
||||
.pl-skeleton-thumb {
|
||||
aspect-ratio: 4 / 3;
|
||||
margin: 8px 8px 0;
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.pl-skeleton-meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.pl-skeleton-line { display: block; height: 12px; }
|
||||
.pl-skeleton-line--title { width: 52%; }
|
||||
.pl-skeleton-line--copy { width: 76%; height: 10px; }
|
||||
.pl-skeleton-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.pl-skeleton-pill {
|
||||
display: block;
|
||||
width: 58px;
|
||||
height: 22px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.pl-skeleton-pill--short { width: 46px; }
|
||||
|
||||
.pl-thumb {
|
||||
position: relative;
|
||||
@@ -323,7 +352,8 @@
|
||||
overflow: hidden;
|
||||
background: #e6e7ea;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-radius: 10px;
|
||||
margin: 8px 8px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.pl-thumb img {
|
||||
@@ -331,6 +361,7 @@
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.pl-thumb.has-mock-media {
|
||||
background-size: cover;
|
||||
@@ -339,7 +370,7 @@
|
||||
}
|
||||
.pl-thumb.placeholder {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pl-thumb .ph-frame { display: none; }
|
||||
|
||||
@@ -421,9 +452,10 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
.pl-grid.list .pl-thumb {
|
||||
width: 148px;
|
||||
width: 132px;
|
||||
height: 100px;
|
||||
aspect-ratio: auto;
|
||||
margin: 8px;
|
||||
}
|
||||
.pl-grid.list .pl-meta {
|
||||
display: flex;
|
||||
@@ -489,7 +521,15 @@
|
||||
border-color: var(--klein, #002fa7);
|
||||
background: var(--klein, #002fa7);
|
||||
}
|
||||
.products-page.manage-mode .pl-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
|
||||
/* 删除是管理动作:普通浏览态不因 hover 露出,进入管理态才显示。 */
|
||||
.products-page:not(.manage-mode) .product-card:hover .card-del-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.products-page.manage-mode .pl-card .card-del-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.products-page.manage-mode .pl-card button.pl-tag { pointer-events: none; }
|
||||
|
||||
/* 影擎吸底选择条 · 管理模式即出现 */
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.project-wizard-page .nw-rail-summary > span { color: var(--nw-muted); font-size: 10px; }
|
||||
.project-wizard-page .nw-rail-track {
|
||||
@@ -148,7 +148,7 @@
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.085);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.project-wizard-page .nw-step[hidden] {
|
||||
display: none;
|
||||
@@ -270,7 +270,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.project-wizard-page .nw-search svg { width: 16px; height: 16px; color: var(--nw-muted); }
|
||||
.project-wizard-page .nw-search input {
|
||||
@@ -297,7 +297,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -317,7 +317,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -334,7 +334,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -513,7 +513,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--accent-black);
|
||||
@@ -549,7 +549,7 @@
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--vc-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.projects-page .vc-search svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--vc-muted); }
|
||||
.projects-page .vc-search input {
|
||||
@@ -220,7 +220,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -243,7 +243,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--vc-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -262,7 +262,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
.quick-create-page .quick-field-label { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: 14px; font-weight: 700; }
|
||||
.quick-create-page .quick-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; }
|
||||
.quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: #fff; box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: var(--surface); box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); }
|
||||
.quick-create-page .quick-upload input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; border: 0; color: inherit; background: transparent; font: inherit; text-align: center; cursor: pointer; }
|
||||
@@ -52,7 +52,7 @@
|
||||
.quick-create-page .quick-image-tile button { position: absolute; top: 3px; right: 3px; width: 19px; height: 19px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; color: #fff; background: rgba(25,28,34,.72); cursor: pointer; }.quick-create-page .quick-image-tile button svg { width: 12px; height: 12px; }
|
||||
.quick-create-page .quick-upload-more { min-height: 240px; display: grid; place-items: center; align-content: center; gap: 8px; border: 1px dashed rgba(0,47,167,.35); border-radius: 13px; background: rgba(243,247,255,.78); }
|
||||
.quick-create-page .quick-upload-trigger { display: grid; justify-items: center; gap: 8px; padding: 0; border: 0; color: #303746; background: transparent; font: inherit; cursor: pointer; }.quick-create-page .quick-upload-trigger strong { font-size: 14px; font-weight: 700; }.quick-create-page .quick-upload-trigger small { color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: #fff; box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: var(--surface); box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-clear-all { padding: 5px 9px; border: 0; border-radius: 7px; color: var(--quick-muted); background: rgba(255,255,255,.72); font: inherit; font-size: 11px; cursor: pointer; }
|
||||
.quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; }
|
||||
.quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); }
|
||||
@@ -61,7 +61,7 @@
|
||||
.quick-create-page .quick-parameter-field .rs-select-btn { height: 38px; font-size: 12px; }
|
||||
.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; }
|
||||
.quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: var(--surface); font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; }
|
||||
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
@@ -85,7 +85,7 @@
|
||||
.quick-create-page .quick-state-ready { position: relative; top: -10px; }
|
||||
.quick-create-page .quick-state-ready h2 { margin: 0 0 9px; font-size: 23px; }.quick-create-page .quick-state-ready > p { max-width: 430px; margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-ready-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 20px; }.quick-create-page .quick-ready-tags span { padding: 7px 10px; border: 1px solid rgba(34,42,54,.08); border-radius: 999px; color: #505762; background: rgba(255,255,255,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-state-generating { width: min(460px,100%); justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; }
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: var(--surface); }
|
||||
.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; }
|
||||
.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }
|
||||
@@ -104,7 +104,7 @@
|
||||
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
|
||||
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
.quick-create-page .quick-video-result-grid.is-single { grid-template-columns: minmax(0, 1fr); width: min(320px,100%); justify-self: center; }
|
||||
.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: #fff; font: inherit; text-align: left; cursor: pointer; }
|
||||
.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: var(--surface); font: inherit; text-align: left; cursor: pointer; }
|
||||
.quick-create-page .quick-video-result-thumb { position: relative; display: block; aspect-ratio: 16/9; overflow: hidden; border-radius: 8px; background: #eef1f5; }
|
||||
.quick-create-page .quick-video-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; pointer-events: none; }
|
||||
.quick-create-page .quick-video-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); }
|
||||
@@ -130,7 +130,7 @@
|
||||
.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-head span { color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: var(--surface); box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; }
|
||||
.quick-create-page .quick-history-thumb:disabled { cursor: default; }
|
||||
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
@@ -144,7 +144,7 @@
|
||||
.quick-create-page .quick-history-badge.is-wait { color: var(--quick-muted); background: rgba(34,42,54,.06); }
|
||||
.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: var(--surface); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
|
||||
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
|
||||
|
||||