Compare commits
11
Commits
b1ef04ee30
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84431c9c5 | ||
|
|
5dbb240b59 | ||
|
|
ceb5163881 | ||
|
|
cfac6006ef | ||
|
|
e7afcd0449 | ||
|
|
2a4743db09 | ||
|
|
10c4aa4437 | ||
|
|
b821e334f6 | ||
|
|
a477177bab | ||
|
|
806d6bf62a | ||
|
|
a6a3d88624 |
+11
-285
@@ -1,294 +1,20 @@
|
|||||||
name: Build and Deploy
|
# push → Gitea Actions(本机 Act Runner)→ docker compose 部署
|
||||||
|
name: Deploy dev
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
# 与服务器 act_runner 注册的 label 一致(host = 直接在宿主机跑,能用本机 docker)
|
||||||
|
runs-on: yingqing
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Deploy on server
|
||||||
run: |
|
run: |
|
||||||
git clone --depth=1 --branch=${{ github.ref_name }} https://x:${{ secrets.GITHUB_TOKEN }}@gitea.airlabs.art/${{ github.repository }}.git .
|
set -euo pipefail
|
||||||
|
test -x /opt/AirShelf/scripts/deploy-dev.sh
|
||||||
- name: Set environment by branch
|
/opt/AirShelf/scripts/deploy-dev.sh
|
||||||
run: |
|
echo "deploy finished"
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
cd /opt/AirShelf && git log -1 --oneline && docker compose ps
|
||||||
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="assets/restraint.css">
|
<link rel="stylesheet" href="assets/restraint.css">
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+19
-19
@@ -1,24 +1,24 @@
|
|||||||
DJANGO_SETTINGS_MODULE=airshelf.settings.development
|
DJANGO_SETTINGS_MODULE=airshelf.settings.production
|
||||||
DJANGO_SECRET_KEY=S2GYXa8YC21lmnFfVwC+6cyFNhCCSoclhpylOmSAm16vKflUgQi398VQQSM+Rbit
|
DJANGO_SECRET_KEY=S2GYXa8YC21lmnFfVwC+6cyFNhCCSoclhpylOmSAm16vKflUgQi398VQQSM+Rbit
|
||||||
DJANGO_DEBUG=true
|
DJANGO_DEBUG=false
|
||||||
DJANGO_ALLOWED_HOSTS=airshelf-web.airlabs.art,airshelf-web.test.airlabs.art,localhost,127.0.0.1,192.168.124.86
|
DJANGO_ALLOWED_HOSTS=yingqing.yueqing.online,localhost,127.0.0.1
|
||||||
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
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://yingqing.yueqing.online
|
||||||
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
|
CORS_ALLOWED_ORIGINS=https://yingqing.yueqing.online
|
||||||
DB_ENGINE=mysql
|
DB_ENGINE=mysql
|
||||||
DB_NAME=airshelf_test
|
DB_NAME=yingqing_app
|
||||||
DB_USER=airshelf_app
|
DB_USER=yingqing_app
|
||||||
DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85
|
DB_PASSWORD=NWXa5nDABKxcS64A
|
||||||
DB_HOST=14.103.27.192
|
DB_HOST=8.163.33.160
|
||||||
DB_PORT=3306
|
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
|
# 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
|
REDIS_CACHE_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/0
|
||||||
CELERY_BROKER_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/1
|
CELERY_BROKER_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/1
|
||||||
CELERY_RESULT_BACKEND=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/2
|
CELERY_RESULT_BACKEND=redis://:redis_WFSzWJ@yingqing-redis:6379/2
|
||||||
REDIS_LOCK_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/3
|
REDIS_LOCK_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/3
|
||||||
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
|
TOS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
|
||||||
TOS_BUCKET=airshelf
|
TOS_BUCKET=yingqing-media
|
||||||
TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY
|
TOS_ACCESS_KEY_ID=LTAI5t62RF5Swgs2uwopvuZz
|
||||||
TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ==
|
TOS_SECRET_ACCESS_KEY=CMmQNGiG2ieESRrhD1ORwuB6FYBmxE
|
||||||
VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26
|
VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26
|
||||||
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
# 2026-07-09 视频(Seedance)ARK key 换成新账号,与新账号素材库审核同账号 → 真人 asset:// 可解析。EP=ep-20260707204223-wkcn9(备用,代码按模型名调不用它)。
|
# 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=
|
MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS=
|
||||||
|
|
||||||
# 全能创作 Agent:本机专用队列,避免远程 K8s worker 偷走未注册任务
|
# 全能创作 Agent:本机专用队列,避免远程 K8s worker 偷走未注册任务
|
||||||
CREATION_AGENT_TASK_QUEUE=airshelf.local
|
CREATION_AGENT_TASK_QUEUE=celery
|
||||||
# 可选:API 线程内联跑 turn(不入 Celery)。队列方案足够时保持 false
|
# 可选:API 线程内联跑 turn(不入 Celery)。队列方案足够时保持 false
|
||||||
CREATION_AGENT_INLINE=true
|
CREATION_AGENT_INLINE=false
|
||||||
|
|||||||
+12
-11
@@ -1,27 +1,28 @@
|
|||||||
DJANGO_SETTINGS_MODULE=airshelf.settings.development
|
DJANGO_SETTINGS_MODULE=airshelf.settings.production
|
||||||
DJANGO_SECRET_KEY=change-me
|
DJANGO_SECRET_KEY=change-me
|
||||||
DJANGO_DEBUG=true
|
DJANGO_DEBUG=false
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=yingqing.yueqing.online,localhost,127.0.0.1
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://yingqing.yueqing.online
|
||||||
|
CORS_ALLOWED_ORIGINS=https://yingqing.yueqing.online
|
||||||
|
|
||||||
DB_ENGINE=mysql
|
DB_ENGINE=mysql
|
||||||
DB_NAME=airshelf_dev
|
DB_NAME=yingqing_app
|
||||||
DB_USER=airshelf
|
DB_USER=yingqing_app
|
||||||
DB_PASSWORD=change-me
|
DB_PASSWORD=change-me
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=yingqing-mysql
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_BIND_ADDRESS=
|
DB_BIND_ADDRESS=
|
||||||
|
|
||||||
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
|
REDIS_CACHE_URL=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/0
|
||||||
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
|
CELERY_BROKER_URL=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/1
|
||||||
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
|
CELERY_RESULT_BACKEND=redis://:YOUR_REDIS_PASSWORD@yingqing-redis:6379/2
|
||||||
|
|
||||||
# 全能创作 Agent turn 队列。生产保持 celery(K8s 已部署该任务)。
|
# 全能创作 Agent turn 队列。生产保持 celery(K8s 已部署该任务)。
|
||||||
# 本机若与远程 worker 共用 broker,设为 airshelf.local,并让本机 worker -Q 含该队列。
|
# 本机若与远程 worker 共用 broker,设为 airshelf.local,并让本机 worker -Q 含该队列。
|
||||||
CREATION_AGENT_TASK_QUEUE=celery
|
CREATION_AGENT_TASK_QUEUE=celery
|
||||||
# true / AIRSHELF_CREATION_AGENT_INLINE=1:API 后台线程直接跑,不入 Celery(仅本机联调)
|
# true / AIRSHELF_CREATION_AGENT_INLINE=1:API 后台线程直接跑,不入 Celery(仅本机联调)
|
||||||
CREATION_AGENT_INLINE=false
|
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_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
|
||||||
TOS_BUCKET=airshelf
|
TOS_BUCKET=airshelf
|
||||||
|
|||||||
@@ -238,11 +238,26 @@ CREATION_AGENT_THINKING_MODE = (env("CREATION_AGENT_THINKING_MODE", "disabled")
|
|||||||
|
|
||||||
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
|
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 = {
|
TOS = {
|
||||||
"endpoint": env("TOS_ENDPOINT"),
|
"endpoint": env("TOS_ENDPOINT"),
|
||||||
"bucket": env("TOS_BUCKET"),
|
"bucket": env("TOS_BUCKET"),
|
||||||
|
"region": _tos_region(),
|
||||||
"access_key_id": env("TOS_ACCESS_KEY_ID"),
|
"access_key_id": env("TOS_ACCESS_KEY_ID"),
|
||||||
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
|
"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 = {
|
VOLCANO = {
|
||||||
|
|||||||
@@ -3,5 +3,16 @@ from .base import * # noqa: F403
|
|||||||
|
|
||||||
DEBUG = True
|
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,
|
# MySQL 连接复用(CONN_MAX_AGE / 健康检查 / connect_timeout)已上移到 base.py,
|
||||||
# development 与 production 共用,此处不再重复设置。
|
# development 与 production 共用,此处不再重复设置。
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -103,6 +105,21 @@ class SingleDeviceLoginTests(TestCase):
|
|||||||
self.assertEqual(active.count(), 1)
|
self.assertEqual(active.count(), 1)
|
||||||
self.assertEqual(active.get().user_agent, "Device B")
|
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):
|
class InvitationFlowTests(TestCase):
|
||||||
def _register(self, client, username, **extra):
|
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())
|
LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||||
Token.objects.filter(user=locked_user).delete()
|
Token.objects.filter(user=locked_user).delete()
|
||||||
token = Token.objects.create(user=locked_user)
|
token = Token.objects.create(user=locked_user)
|
||||||
|
# 设备记录只用于设置页展示和审计,不属于 Token 签发的关键路径。此前它在
|
||||||
|
# 同一事务里即使被 record_login_session 捕获,数据库事务仍可能已被标记为
|
||||||
|
# rollback,离开 atomic 后登录照样变成 500。
|
||||||
record_login_session(request, locked_user)
|
record_login_session(request, locked_user)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|||||||
@@ -193,15 +193,20 @@ def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None
|
|||||||
if state == "allowed":
|
if state == "allowed":
|
||||||
return
|
return
|
||||||
name = label or asset.name or "未命名"
|
name = label or asset.name or "未命名"
|
||||||
|
if state == "unavailable":
|
||||||
|
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试")
|
||||||
if state == "failed":
|
if state == "failed":
|
||||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
||||||
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
||||||
waited = wait_upload_review(asset, timeout_s=20.0)
|
waited = wait_upload_review(asset, timeout_s=20.0)
|
||||||
if waited in ("active", "allowed"):
|
if waited == "active":
|
||||||
return
|
return
|
||||||
if waited == "failed":
|
if waited == "failed":
|
||||||
err = (asset.review_error or "").strip()
|
err = (asset.review_error or "").strip()
|
||||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"({err})" if err else ""))
|
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}」还在审核中,请稍后再试")
|
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2991,7 +2991,7 @@ def notify_generation_failure(
|
|||||||
|
|
||||||
|
|
||||||
def _asset_preview_url(asset) -> str:
|
def _asset_preview_url(asset) -> str:
|
||||||
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
|
"""资产主文件的公读直链。写进会话的地址不能带签名,否则 1 小时后图片会消失。"""
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return ""
|
return ""
|
||||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||||
@@ -2999,16 +2999,16 @@ def _asset_preview_url(asset) -> str:
|
|||||||
return ""
|
return ""
|
||||||
if primary.preview_url:
|
if primary.preview_url:
|
||||||
return primary.preview_url
|
return primary.preview_url
|
||||||
|
if not primary.object_key:
|
||||||
|
return ""
|
||||||
try:
|
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:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def asset_stable_url(asset) -> tuple[str, str]:
|
def asset_stable_url(asset) -> tuple[str, str]:
|
||||||
"""资产的「长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个:
|
"""资产的长期直链 (主文件, 封面图)。公读地址不过期,结果卡隔天还能打开。"""
|
||||||
预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。
|
|
||||||
顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。"""
|
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return "", ""
|
return "", ""
|
||||||
files = list(asset.files.all())
|
files = list(asset.files.all())
|
||||||
@@ -3031,10 +3031,6 @@ def asset_stable_url(asset) -> tuple[str, str]:
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None)
|
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
|
except Exception: # noqa: BLE001
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ def _asset(team, *, source, review_status="", review_remote_id="", category=Asse
|
|||||||
|
|
||||||
|
|
||||||
class ReferenceReviewStateTests(TestCase):
|
class ReferenceReviewStateTests(TestCase):
|
||||||
"""审核判定四态。审核服务开着时才生效,关着一律放行(不能拿没配置的机制拦人)。"""
|
"""审核判定四态。平台生成素材可免责,用户上传绝不能因审核停用而放行。"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="refowner", password="p")
|
self.user = User.objects.create_user(username="refowner", password="p")
|
||||||
@@ -83,9 +83,9 @@ class ReferenceReviewStateTests(TestCase):
|
|||||||
asset.save(update_fields=["in_library"])
|
asset.save(update_fields=["in_library"])
|
||||||
self.assertEqual(reference_review_state(asset), "unsubmitted")
|
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()
|
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):
|
class VideoSeedRangeTests(TestCase):
|
||||||
|
|||||||
@@ -1715,8 +1715,14 @@ class FreeVideoUploadView(APIView):
|
|||||||
thumb_url = storage.public_url(object_key=poster_key)
|
thumb_url = storage.public_url(object_key=poster_key)
|
||||||
|
|
||||||
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
||||||
|
from apps.assets import assets_client
|
||||||
from apps.assets.review import wait_upload_review
|
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)
|
review_status = wait_upload_review(asset)
|
||||||
asset.refresh_from_db()
|
asset.refresh_from_db()
|
||||||
if review_status == "failed":
|
if review_status == "failed":
|
||||||
@@ -1727,7 +1733,13 @@ class FreeVideoUploadView(APIView):
|
|||||||
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
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:
|
if not asset.in_library:
|
||||||
asset.in_library = True
|
asset.in_library = True
|
||||||
asset.save(update_fields=["in_library", "updated_at"])
|
asset.save(update_fields=["in_library", "updated_at"])
|
||||||
@@ -1742,7 +1754,7 @@ class FreeVideoUploadView(APIView):
|
|||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
"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,
|
"in_library": True,
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
@@ -1780,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}"
|
cache_key = MODEL_CATALOG_CACHE_KEY if page_size in {"", "200"} else f"{MODEL_CATALOG_CACHE_KEY}:{page_size}"
|
||||||
if is_plain:
|
if is_plain:
|
||||||
|
# 模型目录缓存只是加速层;开发机或 Redis 短暂不可用时仍应正常返回目录。
|
||||||
|
try:
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("模型目录缓存不可用,改为直接读取", exc_info=True)
|
||||||
|
cached = None
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return Response(cached)
|
return Response(cached)
|
||||||
response = super().list(request, *args, **kwargs)
|
response = super().list(request, *args, **kwargs)
|
||||||
if is_plain and response.status_code == 200:
|
if is_plain and response.status_code == 200:
|
||||||
|
try:
|
||||||
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
|
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("模型目录缓存写入失败,已返回直读结果", exc_info=True)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,12 +37,15 @@ OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||||
|
# 火山仍在审核的素材不能把 TOS 原始链接回传给浏览器。否则前端虽已禁选,
|
||||||
|
# 仍可能把未审核直链带入其它生成入口。审核通过后 poll 接口会再返回链接。
|
||||||
|
available = fa.status == FreeAsset.Status.ACTIVE
|
||||||
return {
|
return {
|
||||||
"id": str(fa.id),
|
"id": str(fa.id),
|
||||||
"name": fa.name,
|
"name": fa.name,
|
||||||
"url": fa.url,
|
"url": fa.url if available else "",
|
||||||
"type": fa.asset_type.lower(),
|
"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,
|
"duration": fa.duration,
|
||||||
"status": fa.status,
|
"status": fa.status,
|
||||||
"error_message": fa.error_message,
|
"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:
|
if not group.thumbnail_url and fa.thumbnail_url:
|
||||||
group.thumbnail_url = fa.thumbnail_url
|
group.thumbnail_url = fa.thumbnail_url
|
||||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
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):
|
class FreeAssetGroupListView(APIView):
|
||||||
@@ -263,7 +268,7 @@ class FreeAssetUploadView(APIView):
|
|||||||
if group is None:
|
if group is None:
|
||||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||||
response = _upload_asset_to_group(request, team=team, group=group)
|
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)
|
response.data.pop("group", None)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ def reference_review_state(asset: Asset, *, require_registered: bool = False) ->
|
|||||||
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
||||||
"""
|
"""
|
||||||
if not assets_client.is_enabled():
|
if not assets_client.is_enabled():
|
||||||
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
|
# 平台自生成资产不依赖用户上传审核;用户上传则必须阻止,不能因为审核服务
|
||||||
|
# 短暂不可用而把历史未审素材带进 Seedance。
|
||||||
|
if asset.source == Asset.Source.UPLOAD:
|
||||||
|
return "unavailable"
|
||||||
return "allowed"
|
return "allowed"
|
||||||
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
||||||
return "allowed"
|
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:
|
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
|
||||||
"""上传素材:立刻送审并等到终态。
|
"""上传素材:立刻送审并等到终态。
|
||||||
|
|
||||||
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)。
|
返回 active / failed / processing(超时仍在审) / unavailable(审核未启用或送审失败)。
|
||||||
审核未配置时不能拦上传,直接当 allowed。
|
|
||||||
|
用户上传的素材不能因为审核服务暂时不可用而被当作已通过。调用方应把
|
||||||
|
unavailable 当作上传未完成处理,既不返回可引用链接,也不允许进入生成链路。
|
||||||
"""
|
"""
|
||||||
if not assets_client.is_enabled():
|
if not assets_client.is_enabled():
|
||||||
return "allowed"
|
return "unavailable"
|
||||||
submit_asset_for_review(asset, force=True)
|
submitted = submit_asset_for_review(asset, force=True)
|
||||||
asset.refresh_from_db()
|
asset.refresh_from_db()
|
||||||
status = asset.review_status or ""
|
status = asset.review_status or ""
|
||||||
if status in ("active", "failed"):
|
if status in ("active", "failed"):
|
||||||
return status
|
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))
|
deadline = time.monotonic() + max(3.0, float(timeout_s))
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
status = poll_asset_review(asset) or ""
|
status = poll_asset_review(asset) or ""
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ class AssetFileSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_preview_url(self, obj):
|
def get_preview_url(self, obj):
|
||||||
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。
|
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼公读直链。
|
||||||
# TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL
|
# 桶公读,直链不过期、可缓存。不再签发 1 小时就失效的预签名 URL。
|
||||||
# (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。
|
|
||||||
if obj.preview_url:
|
if obj.preview_url:
|
||||||
return obj.preview_url
|
return obj.preview_url
|
||||||
if not obj.object_key or not settings.TOS.get("endpoint"):
|
if not obj.object_key or not settings.TOS.get("endpoint"):
|
||||||
|
|||||||
@@ -19,13 +19,25 @@ class TosStorage:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
tos = settings.TOS
|
tos = settings.TOS
|
||||||
self.bucket = tos["bucket"]
|
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(
|
self.client = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=tos["endpoint"],
|
endpoint_url=tos["endpoint"],
|
||||||
aws_access_key_id=tos["access_key_id"],
|
aws_access_key_id=tos["access_key_id"],
|
||||||
aws_secret_access_key=tos["secret_access_key"],
|
aws_secret_access_key=tos["secret_access_key"],
|
||||||
region_name="cn-shanghai",
|
region_name=tos.get("region") or "cn-shanghai",
|
||||||
config=Config(s3={"addressing_style": "virtual"}),
|
config=client_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
|
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:
|
def public_url(self, *, object_key: str, bucket: str | None = None) -> str:
|
||||||
"""桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。
|
"""桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。
|
||||||
相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存
|
相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存
|
||||||
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。"""
|
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。
|
||||||
host = urlparse(settings.TOS["endpoint"]).netloc
|
历史文件的 bucket 仍是火山引擎旧桶,必须用旧域名,不能拼到当前阿里云地址上。"""
|
||||||
return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}"
|
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
|
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):
|
class ModelLibraryApiTests(TestCase):
|
||||||
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
||||||
|
|
||||||
@@ -421,7 +489,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
|||||||
storage.public_url.return_value = "http://tos/a.png"
|
storage.public_url.return_value = "http://tos/a.png"
|
||||||
|
|
||||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
|
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)
|
group = FreeAssetGroup.objects.get(team=self.team)
|
||||||
self.assertEqual(group.name, "默认素材")
|
self.assertEqual(group.name, "默认素材")
|
||||||
self.assertEqual(group.remote_group_id, "Group-1")
|
self.assertEqual(group.remote_group_id, "Group-1")
|
||||||
@@ -429,6 +497,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
|||||||
asset = group.assets.get()
|
asset = group.assets.get()
|
||||||
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
||||||
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
|
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_group.assert_called_once()
|
||||||
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
|
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))
|
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")
|
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")
|
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(first.status_code, 202)
|
||||||
self.assertEqual(second.status_code, 201)
|
self.assertEqual(second.status_code, 202)
|
||||||
self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
|
self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
|
||||||
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
|
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
|
||||||
create_group.assert_called_once()
|
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")
|
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())
|
self.assertFalse(FreeAsset.objects.filter(group=old_group).exists())
|
||||||
new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team)
|
new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team)
|
||||||
self.assertEqual(new_group.remote_group_id, "Group-2")
|
self.assertEqual(new_group.remote_group_id, "Group-2")
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
|
|
||||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||||
|
|
||||||
|
from . import assets_client
|
||||||
from .models import Asset, AssetFile, Model
|
from .models import Asset, AssetFile, Model
|
||||||
|
from .review import wait_upload_review
|
||||||
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
||||||
from .storage import TosStorage
|
from .storage import TosStorage
|
||||||
|
|
||||||
@@ -778,7 +780,6 @@ class ModelLibraryViewSet(ModelViewSet):
|
|||||||
class AssetUploadView(APIView):
|
class AssetUploadView(APIView):
|
||||||
parser_classes = [MultiPartParser, FormParser]
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = AssetUploadSerializer(data=request.data)
|
serializer = AssetUploadSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
@@ -793,6 +794,9 @@ class AssetUploadView(APIView):
|
|||||||
object_key=object_key,
|
object_key=object_key,
|
||||||
content_type=upload.content_type or "application/octet-stream",
|
content_type=upload.content_type or "application/octet-stream",
|
||||||
)
|
)
|
||||||
|
# 先持久化,审核服务才能从 TOS 拉取文件;网络审核绝不能放在数据库事务里,
|
||||||
|
# 否则几十秒的轮询会长期锁住请求事务。
|
||||||
|
with transaction.atomic():
|
||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
id=asset_id,
|
id=asset_id,
|
||||||
team=team,
|
team=team,
|
||||||
@@ -811,4 +815,31 @@ class AssetUploadView(APIView):
|
|||||||
size_bytes=stored.size_bytes,
|
size_bytes=stored.size_bytes,
|
||||||
is_primary=True,
|
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)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -25,6 +26,9 @@ from .models import Notification
|
|||||||
from .serializers import NotificationSerializer
|
from .serializers import NotificationSerializer
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def project_stage_label(project):
|
def project_stage_label(project):
|
||||||
return {
|
return {
|
||||||
"script": "Stage 1 · 脚本",
|
"script": "Stage 1 · 脚本",
|
||||||
@@ -288,7 +292,13 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
if not Notification.objects.filter(team=team).exists():
|
if not Notification.objects.filter(team=team).exists():
|
||||||
ensure_team_notifications(team, request.user)
|
ensure_team_notifications(team, request.user)
|
||||||
return
|
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
|
from .tasks import ensure_team_notifications_task
|
||||||
|
|
||||||
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
||||||
|
|||||||
@@ -6,6 +6,20 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<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" />
|
<link rel="icon" href="/assets/yz/logo.png" />
|
||||||
<title>影擎</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"be": "npm --prefix ../.. run be",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+54
-17
@@ -21,6 +21,7 @@ import type {
|
|||||||
UserPreference
|
UserPreference
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { publicModelDisplayName } from "./model-display";
|
import { publicModelDisplayName } from "./model-display";
|
||||||
|
import { applyTheme, normalizeAppearance, readStoredAppearance } from "./theme";
|
||||||
import { generationErrorText } from "./generation-error";
|
import { generationErrorText } from "./generation-error";
|
||||||
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
||||||
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
||||||
@@ -143,6 +144,22 @@ export function App() {
|
|||||||
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
|
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
|
||||||
const exportingRef = useRef(false);
|
const exportingRef = useRef(false);
|
||||||
const [preferences, setPreferences] = useState<UserPreference | null>(null);
|
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 [sessions, setSessions] = useState<LoginSession[]>([]);
|
||||||
|
|
||||||
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
||||||
@@ -211,19 +228,25 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
|
// 设置页数据:偏好 + 登录会话。settingsLoadSeq 挡住过期 GET 覆盖更新的 PUT 结果。
|
||||||
|
const settingsLoadSeq = useRef(0);
|
||||||
const loadSettingsData = useCallback(async () => {
|
const loadSettingsData = useCallback(async () => {
|
||||||
|
const seq = ++settingsLoadSeq.current;
|
||||||
const [pref, sess] = await Promise.all([
|
const [pref, sess] = await Promise.all([
|
||||||
api.preferences().catch(() => null),
|
api.preferences().catch(() => null),
|
||||||
api.loginSessions().catch(() => [])
|
api.loginSessions().catch(() => [])
|
||||||
]);
|
]);
|
||||||
|
if (seq !== settingsLoadSeq.current) return;
|
||||||
if (pref) setPreferences(pref);
|
if (pref) setPreferences(pref);
|
||||||
setSessions(sess);
|
setSessions(sess);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function savePreferences(payload: Partial<UserPreference>) {
|
async function savePreferences(payload: Partial<UserPreference>) {
|
||||||
const next = await api.updatePreferences(payload).catch(() => null);
|
// 失败必须抛出,不能吞成 null —— 否则 settings 会误以为已落盘并推进 baseline
|
||||||
if (next) setPreferences(next);
|
const next = await api.updatePreferences(payload);
|
||||||
|
// 使在途的 preferences GET 失效,避免刚保存的外观被旧响应打回 system
|
||||||
|
settingsLoadSeq.current += 1;
|
||||||
|
setPreferences(next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,16 +422,30 @@ export function App() {
|
|||||||
// Load preferences + sessions when entering settings.
|
// Load preferences + sessions when entering settings.
|
||||||
// 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存
|
// 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed || !dataLoaded) return;
|
// 模型目录是创作页的必要数据,不能被商品/项目等全局数据的加载状态卡住。
|
||||||
|
// 否则任一非关键列表短暂失败时,模型按钮仍显示默认名却展开空菜单。
|
||||||
|
if (!authed) return;
|
||||||
if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return;
|
if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return;
|
||||||
refreshModelConfigs();
|
refreshModelConfigs();
|
||||||
}, [authed, page, dataLoaded, refreshModelConfigs]);
|
}, [authed, page, refreshModelConfigs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||||
loadSettingsData();
|
loadSettingsData();
|
||||||
}, [authed, page, 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 静默轮询(生成是慢任务,出图后角标自动亮)
|
// YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed || !user) return;
|
if (!authed || !user) return;
|
||||||
@@ -901,16 +938,15 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate("omniCreate", { replace: true });
|
navigate("omniCreate", { replace: true });
|
||||||
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
// 登录凭证已验证即可进入工作台。此前等待商品/项目等整套数据(包含重试)才
|
||||||
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
// setAuthed,任一慢接口都会让登录页长时间卡在「正在进入工作台」;而刷新后的
|
||||||
try {
|
// boot 路径本来就是先展示外壳、后台补数据。首次登录也保持同一体验。
|
||||||
await loadDataWithRetry();
|
setDataLoaded(false);
|
||||||
} catch (error) {
|
|
||||||
console.error("[login] data hydrate failed:", error);
|
|
||||||
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
|
|
||||||
} finally {
|
|
||||||
setAuthed(true);
|
setAuthed(true);
|
||||||
}
|
void loadDataWithRetry().catch((error) => {
|
||||||
|
console.error("[login] data hydrate failed:", error);
|
||||||
|
setNotice({ type: "error", text: "部分数据加载失败,页面会自动重试" });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
@@ -1216,9 +1252,9 @@ export function App() {
|
|||||||
case "modelPhotoDemoB":
|
case "modelPhotoDemoB":
|
||||||
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
|
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
|
||||||
case "settings":
|
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":
|
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:
|
default:
|
||||||
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||||
}
|
}
|
||||||
@@ -1379,7 +1415,8 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||||
<IconKitSvg name="creditCard" />
|
<IconKitSvg name="creditCard" />
|
||||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
<span className="balance-chip-label">余额</span>
|
||||||
|
<strong>{money(billing?.account.balance)}</strong>
|
||||||
</span>
|
</span>
|
||||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||||
<IconKitSvg name="bell" />
|
<IconKitSvg name="bell" />
|
||||||
|
|||||||
@@ -347,7 +347,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #0b1d4c;
|
color: #0b1d4c;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color .18s ease, background .18s ease, transform .18s ease;
|
transition: color .18s ease, background .18s ease, transform .18s ease;
|
||||||
@@ -716,7 +716,7 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -755,7 +755,7 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.bill-jump button:disabled {
|
.bill-jump button:disabled {
|
||||||
@@ -807,7 +807,7 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 24px 70px rgba(18, 25, 38, 0.18);
|
box-shadow: 0 24px 70px rgba(18, 25, 38, 0.18);
|
||||||
transform: translateY(10px) scale(.985);
|
transform: translateY(10px) scale(.985);
|
||||||
transition: transform .2s ease;
|
transition: transform .2s ease;
|
||||||
@@ -840,7 +840,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.1);
|
border: 1px solid rgba(34, 42, 54, 0.1);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #20242c;
|
color: #20242c;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.billing-contact-close:hover {
|
.billing-contact-close:hover {
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
height: var(--topbar-height);
|
height: var(--topbar-height);
|
||||||
min-height: var(--topbar-height);
|
min-height: var(--topbar-height);
|
||||||
padding: 0 28px;
|
padding: 0 28px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||||
}
|
}
|
||||||
.admin-crumb {
|
.admin-crumb {
|
||||||
@@ -370,7 +370,7 @@
|
|||||||
|
|
||||||
.admin-table-wrap { overflow-x: auto; }
|
.admin-table-wrap { overflow-x: auto; }
|
||||||
.admin-page table.t.admin-table {
|
.admin-page table.t.admin-table {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border-color: var(--st-line);
|
border-color: var(--st-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -626,7 +626,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid var(--st-line);
|
border: 1px solid var(--st-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
.pt-foot .spacer { flex: 1; }
|
.pt-foot .spacer { flex: 1; }
|
||||||
|
|
||||||
.admin-page .card-hard {
|
.admin-page .card-hard {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border-color: var(--st-line);
|
border-color: var(--st-line);
|
||||||
}
|
}
|
||||||
.admin-page .empty-state {
|
.admin-page .empty-state {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid var(--af-line);
|
border: 1px solid var(--af-line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.asset-factory .af-search svg {
|
.asset-factory .af-search svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--af-line);
|
border: 1px solid var(--af-line);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -280,7 +280,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -2746,7 +2746,7 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.18);
|
border: 1px solid rgba(34, 42, 54, 0.18);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.yz-image .product-choice.active .choice-check,
|
.yz-image .product-choice.active .choice-check,
|
||||||
.yz-image .model-choice.active .choice-check,
|
.yz-image .model-choice.active .choice-check,
|
||||||
@@ -2761,7 +2761,7 @@
|
|||||||
width: 5px;
|
width: 5px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.yz-image .product-library-choice,
|
.yz-image .product-library-choice,
|
||||||
.yz-image .upload-choice {
|
.yz-image .upload-choice {
|
||||||
@@ -2909,7 +2909,7 @@
|
|||||||
}
|
}
|
||||||
.yz-image .image-prompt:focus {
|
.yz-image .image-prompt:focus {
|
||||||
border-color: rgba(0, 47, 167, 0.48);
|
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);
|
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
|
||||||
}
|
}
|
||||||
.yz-image .model-line {
|
.yz-image .model-line {
|
||||||
@@ -3310,7 +3310,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #fff;
|
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 {
|
.yz-image .model-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -584,8 +584,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ transform: `translateY(${liquid.y}px)` }}
|
style={{ transform: `translateY(${liquid.y}px)` }}
|
||||||
>
|
>
|
||||||
<svg className="liquid-shape" viewBox="0 0 132 76" preserveAspectRatio="none">
|
<svg className="liquid-shape" viewBox="0 0 152 76" preserveAspectRatio="none">
|
||||||
<path fill="#101012" d="M0 11H103C120 11 130 22 130 38C130 54 120 65 103 65H0Z" />
|
<path fill="currentColor" d="M0 11H123C140 11 150 22 150 38C150 54 140 65 123 65H0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{activeMajor && (
|
{activeMajor && (
|
||||||
<div className="indicator-content">
|
<div className="indicator-content">
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
|
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 { api } from "../../api";
|
||||||
import { useFileDrop } from "../use-file-drop";
|
import { useFileDrop } from "../use-file-drop";
|
||||||
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
||||||
import { useBodyScrollLock } from "../overlays";
|
import { useBodyScrollLock } from "../overlays";
|
||||||
|
|
||||||
const STATUS_PILL: Record<FreeAssetItem["status"], { cls: string; label: string }> = {
|
const STATUS_ICON: Record<FreeAssetItem["status"], { cls: string; label: string; Icon: typeof CheckCircle2 }> = {
|
||||||
processing: { cls: "pill-info", label: "审核中" },
|
processing: { cls: "is-processing", label: "审核中,暂不可引用", Icon: Clock3 },
|
||||||
active: { cls: "pill-ok", label: "可用" },
|
active: { cls: "is-active", label: "审核通过,可被引用", Icon: CheckCircle2 },
|
||||||
failed: { cls: "pill-err", label: "未通过" }
|
failed: { cls: "is-failed", label: "审核未通过,无法引用", Icon: CircleX }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||||
@@ -97,8 +97,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [open, activeGroup, assets]);
|
}, [open, activeGroup, assets]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const createGroup = async () => {
|
const createGroup = async () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (!name || creatingGroup) return;
|
if (!name || creatingGroup) return;
|
||||||
@@ -124,7 +122,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
const data = await api.uploadFreeAsset(activeGroup.id, form);
|
const data = await api.uploadFreeAsset(activeGroup.id, form);
|
||||||
setAssets((prev) => [data.asset, ...prev]);
|
setAssets((prev) => [data.asset, ...prev]);
|
||||||
notify("success", "素材已上传,审核中");
|
notify("info", "素材已提交审核,通过后即可引用");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,7 +149,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
const data = await api.quickUploadFreeAsset(form);
|
const data = await api.quickUploadFreeAsset(form);
|
||||||
setActiveGroup(data.group);
|
setActiveGroup(data.group);
|
||||||
setAssets([data.asset]);
|
setAssets([data.asset]);
|
||||||
notify("success", "图片已上传到默认素材组,审核中");
|
notify("info", "图片已提交审核,通过后即可引用");
|
||||||
void loadGroupDetail(data.group);
|
void loadGroupDetail(data.group);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
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) => {
|
const startRenameAsset = (item: FreeAssetItem) => {
|
||||||
setConfirmDeleteAssetId(null);
|
setConfirmDeleteAssetId(null);
|
||||||
setConfirmDeleteGroupId(null);
|
setConfirmDeleteGroupId(null);
|
||||||
@@ -442,7 +443,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="fc-lib-assets">
|
<div className="fc-lib-assets">
|
||||||
{assets.map((item) => {
|
{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 isEditing = editingAssetId === item.id;
|
||||||
const isDeleting = confirmDeleteAssetId === item.id;
|
const isDeleting = confirmDeleteAssetId === item.id;
|
||||||
return (
|
return (
|
||||||
@@ -457,6 +459,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
|||||||
<div className="fc-lib-thumb">
|
<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.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}
|
{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>
|
</div>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
|
<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>
|
<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>}
|
{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">
|
<div className="fc-lib-ops">
|
||||||
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
100% { background-position: 480px 0; }
|
100% { background-position: 480px 0; }
|
||||||
}
|
}
|
||||||
.skeleton-block {
|
.skeleton-block {
|
||||||
background: var(--background-base, #f1f1f1);
|
background: var(--black-alpha-4);
|
||||||
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-image: linear-gradient(90deg, transparent 0%, var(--black-alpha-8) 40%, transparent 80%);
|
||||||
background-size: 480px 100%;
|
background-size: 480px 100%;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
animation: sk-shimmer 1.2s ease-in-out infinite;
|
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 {
|
.skeleton-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -153,8 +153,228 @@
|
|||||||
--shadow-cta-active:
|
--shadow-cta-active:
|
||||||
0 1px 2px rgba(0, 47, 167, .18);
|
0 1px 2px rgba(0, 47, 167, .18);
|
||||||
--shadow-floating: 0 8px 28px rgba(17, 19, 24, .06);
|
--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); }
|
::selection { background: var(--heat-20); color: var(--heat); }
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
@@ -268,7 +488,7 @@ aside.sidebar {
|
|||||||
min-height: var(--topbar-height);
|
min-height: var(--topbar-height);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border-bottom: 1px solid rgba(27, 32, 40, 0.07);
|
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); }
|
.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;
|
padding: 0 5px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -619,7 +839,7 @@ aside.sidebar .liquid-indicator {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 132px;
|
width: 152px;
|
||||||
height: 76px;
|
height: 76px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -635,6 +855,7 @@ aside.sidebar .liquid-shape {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
color: #101012;
|
||||||
filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.14));
|
filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.14));
|
||||||
transform-origin: left center;
|
transform-origin: left center;
|
||||||
}
|
}
|
||||||
@@ -651,7 +872,7 @@ aside.sidebar .indicator-content {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 33px;
|
left: 33px;
|
||||||
height: 76px;
|
height: 76px;
|
||||||
width: 94px;
|
width: 112px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
@@ -776,7 +997,7 @@ body.sidebar-collapsed .user::after { display: none; }
|
|||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
@@ -827,7 +1048,7 @@ body.sidebar-collapsed .user::after { display: none; }
|
|||||||
display: flex; align-items: center; gap: 16px;
|
display: flex; align-items: center; gap: 16px;
|
||||||
padding: 0 clamp(36px, 3.2vw, 68px);
|
padding: 0 clamp(36px, 3.2vw, 68px);
|
||||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
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);
|
box-shadow: 0 4px 16px rgba(20, 27, 38, 0.035);
|
||||||
position: sticky; top: 0; z-index: var(--z-topbar);
|
position: sticky; top: 0; z-index: var(--z-topbar);
|
||||||
align-self: start;
|
align-self: start;
|
||||||
@@ -1168,8 +1389,11 @@ body.sidebar-collapsed .user::after { display: none; }
|
|||||||
.with-corners .corner-tr, .with-corners .corner-bl {
|
.with-corners .corner-tr, .with-corners .corner-bl {
|
||||||
content: ''; position: absolute;
|
content: ''; position: absolute;
|
||||||
width: 14px; height: 14px;
|
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-color: var(--border-muted);
|
||||||
background-size: contain;
|
-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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.card-hard.with-corners::before { top: -7px; left: -7px; }
|
.card-hard.with-corners::before { top: -7px; left: -7px; }
|
||||||
@@ -2269,6 +2493,11 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
color: var(--black-alpha-56);
|
color: var(--black-alpha-56);
|
||||||
font-weight: 500;
|
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 {
|
.placeholder.has-mock-media {
|
||||||
background-color: var(--background-lighter);
|
background-color: var(--background-lighter);
|
||||||
background-image: var(--mock-media-url);
|
background-image: var(--mock-media-url);
|
||||||
@@ -2599,6 +2828,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
margin: 6px 4px;
|
margin: 6px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ─── Modal ─── */
|
/* ─── Modal ─── */
|
||||||
.modal-bg {
|
.modal-bg {
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
@@ -2632,24 +2862,27 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 14px; height: 14px;
|
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-color: var(--border-muted);
|
||||||
background-size: contain;
|
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||||
|
mask: var(--corner-mark) center / contain no-repeat;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: var(--black-alpha-48);
|
z-index: 1;
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 13px; line-height: 1;
|
|
||||||
}
|
}
|
||||||
.modal::before { top: -7px; left: -7px; }
|
.modal::before { top: 0; left: 0; }
|
||||||
.modal::after { bottom: -7px; right: -7px; }
|
.modal::after { bottom: 0; right: 0; }
|
||||||
.modal .corner-tr, .modal .corner-bl {
|
.modal .corner-tr, .modal .corner-bl {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 14px; height: 14px;
|
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-color: var(--border-muted);
|
||||||
background-size: contain;
|
-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;
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.modal .corner-tr { top: -7px; right: -7px; }
|
.modal .corner-tr { top: 0; right: 0; }
|
||||||
.modal .corner-bl { bottom: -7px; left: -7px; }
|
.modal .corner-bl { bottom: 0; left: 0; }
|
||||||
.modal-h {
|
.modal-h {
|
||||||
padding: 22px 24px 16px;
|
padding: 22px 24px 16px;
|
||||||
border-bottom: 1px solid var(--border-faint);
|
border-bottom: 1px solid var(--border-faint);
|
||||||
@@ -2716,6 +2949,12 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.card-del-btn svg { width: 14px; height: 14px; }
|
.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,
|
.product-card:hover .card-del-btn,
|
||||||
.asset-card:hover .card-del-btn,
|
.asset-card:hover .card-del-btn,
|
||||||
.project-card:hover .card-del-btn,
|
.project-card:hover .card-del-btn,
|
||||||
@@ -2804,10 +3043,18 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
position: fixed; top: 0; left: 0; bottom: 0;
|
position: fixed; top: 0; left: 0; bottom: 0;
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
|
/* 手机上的侧栏是抽屉,必须遮住页面内容,不能沿用桌面端的半透明背景。 */
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-right-color: var(--border-faint);
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
|
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
|
||||||
box-shadow: 0 0 40px rgba(0, 0, 0, .18);
|
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 把抽屉压成 96px */
|
||||||
body.sidebar-collapsed aside.sidebar { width: var(--sidebar-width); }
|
body.sidebar-collapsed aside.sidebar { width: var(--sidebar-width); }
|
||||||
aside.sidebar.mobile-open { transform: translateX(0); }
|
aside.sidebar.mobile-open { transform: translateX(0); }
|
||||||
@@ -2822,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-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); }
|
.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); }
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
.stat:nth-child(2) { border-right: 0; }
|
.stat:nth-child(2) { border-right: 0; }
|
||||||
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
|
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
|
||||||
@@ -2899,7 +3160,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 252px;
|
width: 252px;
|
||||||
padding: 11px 13px 14px;
|
padding: 11px 13px 14px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
@@ -2921,7 +3182,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px; left: 14px;
|
top: -5px; left: 14px;
|
||||||
width: 10px; height: 10px;
|
width: 10px; height: 10px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border-left: 1px solid rgba(34, 42, 54, 0.10);
|
border-left: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-top: 1px solid rgba(34, 42, 54, 0.10);
|
border-top: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
|
|||||||
@@ -334,7 +334,7 @@
|
|||||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -598,7 +598,7 @@
|
|||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||||
@@ -708,7 +708,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
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-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-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-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 {
|
.fc-lib-status {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
@@ -966,7 +983,7 @@
|
|||||||
height: 7px;
|
height: 7px;
|
||||||
background: currentColor;
|
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-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
|
||||||
.fc-lib-ops button {
|
.fc-lib-ops button {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid var(--lib-line);
|
border: 1px solid var(--lib-line);
|
||||||
border-radius: 10px;
|
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 svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--lib-muted); }
|
||||||
.library-page .lib-search input {
|
.library-page .lib-search input {
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--lib-line);
|
border: 1px solid var(--lib-line);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { applyTheme, readStoredAppearance } from "./theme";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
@@ -22,5 +23,8 @@ import "./quick-create-page.css";
|
|||||||
import "./admin-page.css";
|
import "./admin-page.css";
|
||||||
import "./omni-create-page.css";
|
import "./omni-create-page.css";
|
||||||
import "./omni-session-page.css";
|
import "./omni-session-page.css";
|
||||||
|
import "./dark-mode-pages.css";
|
||||||
|
|
||||||
|
applyTheme(readStoredAppearance());
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
createRoot(document.getElementById("root")!).render(<App />);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--st-text);
|
color: var(--st-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--st-muted);
|
color: var(--st-muted);
|
||||||
}
|
}
|
||||||
.msg-search input {
|
.msg-search input {
|
||||||
@@ -196,6 +196,8 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.msg-load-more {
|
.msg-load-more {
|
||||||
padding: 14px 16px 18px;
|
padding: 14px 16px 18px;
|
||||||
@@ -274,6 +276,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.msg-empty {
|
.msg-empty {
|
||||||
|
flex: 1;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -204,6 +204,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.models-page .ml-check svg { width: 13px; height: 13px; }
|
.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.selectable .ml-check { display: grid; }
|
||||||
.models-page.manage-mode .ml-card.selected .ml-check {
|
.models-page.manage-mode .ml-card.selected .ml-check {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -383,7 +384,7 @@
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: #414750;
|
color: #414750;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -36,24 +36,31 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
border: 1.5px solid rgba(16, 16, 18, .22);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: 12px;
|
border-radius: var(--r-md);
|
||||||
color: var(--black);
|
color: var(--black-alpha-56);
|
||||||
background: #fff;
|
background: transparent;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 500;
|
||||||
box-shadow: 0 4px 12px rgba(16, 16, 18, .08);
|
box-shadow: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-home-history:hover {
|
.omni-home-history:hover {
|
||||||
border-color: var(--black);
|
border-color: var(--black-alpha-24);
|
||||||
background: #f7f7f8;
|
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 {
|
.omni-home-history svg {
|
||||||
@@ -378,7 +385,7 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +454,7 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +589,7 @@
|
|||||||
.omni-parameter .custom-select-trigger:hover,
|
.omni-parameter .custom-select-trigger:hover,
|
||||||
.omni-parameter .custom-select-shell.open .custom-select-trigger {
|
.omni-parameter .custom-select-shell.open .custom-select-trigger {
|
||||||
border-color: rgba(0, 47, 167, .36);
|
border-color: rgba(0, 47, 167, .36);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-parameter .custom-select-trigger svg {
|
.omni-parameter .custom-select-trigger svg {
|
||||||
@@ -640,7 +647,7 @@
|
|||||||
.omni-duration-trigger:hover,
|
.omni-duration-trigger:hover,
|
||||||
.omni-duration-trigger[aria-expanded="true"] {
|
.omni-duration-trigger[aria-expanded="true"] {
|
||||||
border-color: rgba(0, 47, 167, .36);
|
border-color: rgba(0, 47, 167, .36);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .07);
|
box-shadow: 0 0 0 3px rgba(0, 47, 167, .07);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,7 +671,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,7 +745,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #575e69;
|
color: #575e69;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -933,7 +940,7 @@
|
|||||||
|
|
||||||
.omni-case-card[data-mode="image"] .omni-case-visual {
|
.omni-case-card[data-mode="image"] .omni-case-visual {
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-card-use {
|
.omni-card-use {
|
||||||
@@ -995,15 +1002,15 @@
|
|||||||
.omni-preset-dialog {
|
.omni-preset-dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: min(1040px, 100%);
|
width: min(1180px, 100%);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 440px minmax(0, 1fr);
|
grid-template-columns: 500px minmax(0, 1fr);
|
||||||
gap: 42px;
|
gap: 32px;
|
||||||
padding: 30px;
|
padding: 28px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-top: 3px solid var(--klein);
|
border-top: 3px solid var(--klein);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 28px 70px rgba(10, 17, 30, .22);
|
box-shadow: 0 28px 70px rgba(10, 17, 30, .22);
|
||||||
animation: omniMessageIn 180ms ease both;
|
animation: omniMessageIn 180ms ease both;
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1146,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #616873;
|
color: #616873;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1231,7 +1238,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #414750;
|
color: #414750;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,35 +1314,34 @@
|
|||||||
.omni-history-list {
|
.omni-history-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-item {
|
.omni-history-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 150px minmax(0, 1fr) 70px;
|
grid-template-columns: 150px minmax(0, 1fr) 36px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 16px;
|
||||||
padding: 17px;
|
padding: 16px;
|
||||||
border: 1px solid rgba(34, 42, 54, .09);
|
border: 1px solid rgba(34, 42, 54, .09);
|
||||||
border-radius: 19px;
|
border-radius: var(--r-md);
|
||||||
background: rgba(255, 255, 255, .96);
|
background: rgba(255, 255, 255, .96);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 8px 22px rgba(20, 27, 38, .05);
|
box-shadow: none;
|
||||||
transition: transform 170ms ease, border-color 170ms ease, box-shadow 170ms ease;
|
transition: border-color 170ms ease, background-color 170ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-item:hover {
|
.omni-history-item:hover {
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: rgba(0, 47, 167, .30);
|
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 {
|
.omni-history-item > img {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
height: 112px;
|
height: 112px;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 13px;
|
border-radius: var(--r-md);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #edf0f4;
|
background: #edf0f4;
|
||||||
}
|
}
|
||||||
@@ -1354,21 +1360,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-item h2 {
|
.omni-history-item h2 {
|
||||||
margin: 8px 0 7px;
|
margin: 6px 0 5px;
|
||||||
font-size: 17px;
|
font-size: 14px;
|
||||||
line-height: 1.35;
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-item p,
|
.omni-history-item p,
|
||||||
.omni-history-item small {
|
.omni-history-item small {
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
line-height: 1.45;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-item small {
|
.omni-history-item small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 9px;
|
margin-top: 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-actions {
|
.omni-history-actions {
|
||||||
@@ -1389,14 +1398,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-actions button {
|
.omni-history-actions button {
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #858b94;
|
color: #858b94;
|
||||||
background: #f4f5f7;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-history-actions button:hover {
|
.omni-history-actions button:hover {
|
||||||
@@ -1404,6 +1415,25 @@
|
|||||||
background: #fff0f0;
|
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 {
|
.omni-delete-confirm {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1432,7 +1462,7 @@
|
|||||||
padding: 26px;
|
padding: 26px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 17px;
|
border-radius: 17px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 26px 64px rgba(10, 17, 30, .22);
|
box-shadow: 0 26px 64px rgba(10, 17, 30, .22);
|
||||||
animation: omniMessageIn 180ms ease both;
|
animation: omniMessageIn 180ms ease both;
|
||||||
@@ -1476,7 +1506,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #5e6570;
|
color: #5e6570;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1547,7 +1577,7 @@
|
|||||||
.omni-parameter .rs-select-btn:hover,
|
.omni-parameter .rs-select-btn:hover,
|
||||||
.omni-parameter .rs-select.open .rs-select-btn {
|
.omni-parameter .rs-select.open .rs-select-btn {
|
||||||
border-color: rgba(0, 47, 167, .36);
|
border-color: rgba(0, 47, 167, .36);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-parameter .rs-select-btn svg {
|
.omni-parameter .rs-select-btn svg {
|
||||||
|
|||||||
@@ -71,12 +71,25 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: 10px;
|
border-radius: var(--r-md);
|
||||||
color: #414750;
|
color: var(--black-alpha-56);
|
||||||
background: #fff;
|
background: transparent;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
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 {
|
.omni-session-back svg {
|
||||||
@@ -375,7 +388,7 @@
|
|||||||
margin: 0 0 24px 44px;
|
margin: 0 0 24px 44px;
|
||||||
border: 1px solid rgba(34, 42, 54, .09);
|
border: 1px solid rgba(34, 42, 54, .09);
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
|
box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
|
||||||
animation: omniMessageIn 220ms ease both;
|
animation: omniMessageIn 220ms ease both;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -407,7 +420,7 @@
|
|||||||
margin: 0 0 24px 44px;
|
margin: 0 0 24px 44px;
|
||||||
border: 1px solid rgba(34, 42, 54, .09);
|
border: 1px solid rgba(34, 42, 54, .09);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
||||||
animation: omniMessageIn 220ms ease both;
|
animation: omniMessageIn 220ms ease both;
|
||||||
}
|
}
|
||||||
@@ -594,7 +607,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, .08);
|
border: 1px solid rgba(34, 42, 54, .08);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #5b626d;
|
color: #5b626d;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
@@ -689,7 +702,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #333944;
|
color: #333944;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -712,7 +725,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, .20);
|
border: 1px solid rgba(0, 47, 167, .20);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -814,7 +827,7 @@
|
|||||||
.omni-session-preset-trigger[aria-expanded="true"] {
|
.omni-session-preset-trigger[aria-expanded="true"] {
|
||||||
border-color: rgba(0, 47, 167, .36);
|
border-color: rgba(0, 47, 167, .36);
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-session-preset-trigger span {
|
.omni-session-preset-trigger span {
|
||||||
@@ -837,7 +850,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 17px;
|
border-radius: 17px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 24px 60px rgba(20, 27, 38, .18);
|
box-shadow: 0 24px 60px rgba(20, 27, 38, .18);
|
||||||
transform: translateX(-52%);
|
transform: translateX(-52%);
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1048,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 16px 36px rgba(20, 27, 38, .15);
|
box-shadow: 0 16px 36px rgba(20, 27, 38, .15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,7 +1720,7 @@
|
|||||||
padding: 7px 13px;
|
padding: 7px 13px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: #575d66;
|
color: #575d66;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1730,7 +1743,7 @@
|
|||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--black);
|
color: var(--black);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -1754,7 +1767,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -1844,7 +1857,7 @@
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
||||||
/* 不走入场 opacity 动画:poll 重渲染时避免「闪一下」 */
|
/* 不走入场 opacity 动画:poll 重渲染时避免「闪一下」 */
|
||||||
animation: none;
|
animation: none;
|
||||||
@@ -1905,7 +1918,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.omni-step-confirm-actions button.is-secondary {
|
.omni-step-confirm-actions button.is-secondary {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid rgba(34, 42, 54, 0.14);
|
border: 1px solid rgba(34, 42, 54, 0.14);
|
||||||
color: #222a36;
|
color: #222a36;
|
||||||
}
|
}
|
||||||
@@ -1946,7 +1959,7 @@
|
|||||||
|
|
||||||
.omni-step-confirm-revise-input:focus {
|
.omni-step-confirm-revise-input:focus {
|
||||||
border-color: rgba(0, 47, 167, .42);
|
border-color: rgba(0, 47, 167, .42);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .14);
|
box-shadow: 0 0 0 3px rgba(0, 47, 167, .14);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1973,7 +1986,7 @@
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid rgba(34, 42, 54, .10);
|
border: 1px solid rgba(34, 42, 54, .10);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
/* 时长/参数下拉要冒出卡片,不能被裁 */
|
/* 时长/参数下拉要冒出卡片,不能被裁 */
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
@@ -2604,7 +2617,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 14px 14px 12px;
|
padding: 14px 14px 12px;
|
||||||
border-bottom: 1px solid rgba(34, 42, 54, .08);
|
border-bottom: 1px solid rgba(34, 42, 54, .08);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-drawer-file {
|
.omni-prompt-drawer-file {
|
||||||
@@ -2684,7 +2697,7 @@
|
|||||||
padding: 14px 15px;
|
padding: 14px 15px;
|
||||||
border: 1px solid rgba(34, 42, 54, .06);
|
border: 1px solid rgba(34, 42, 54, .06);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 6px 16px rgba(20, 27, 38, .04);
|
box-shadow: 0 6px 16px rgba(20, 27, 38, .04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2756,7 +2769,7 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(34, 42, 54, .08);
|
border: 1px solid rgba(34, 42, 54, .08);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.omni-prompt-table {
|
.omni-prompt-table {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||||
border-radius: 12px;
|
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-summary > span { color: var(--pl-muted); font-size: 10px; }
|
||||||
.pipeline-page .pl-rail-track {
|
.pipeline-page .pl-rail-track {
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.085);
|
border: 1px solid rgba(34, 42, 54, 0.085);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
}
|
}
|
||||||
.pipeline-page .pl-step.static:hover {
|
.pipeline-page .pl-step.static:hover {
|
||||||
border-color: rgba(34, 42, 54, 0.085);
|
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.static:hover .pl-step-chev { color: var(--pl-muted); }
|
||||||
.pipeline-page .pl-step.current {
|
.pipeline-page .pl-step.current {
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
.pipeline-page .pl-ghost {
|
.pipeline-page .pl-ghost {
|
||||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.pipeline-page .pl-next {
|
.pipeline-page .pl-next {
|
||||||
min-width: 210px;
|
min-width: 210px;
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.chat-msg.ai .chat-bubble {
|
.chat-msg.ai .chat-bubble {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||||
border-radius: 8px 8px 8px 2px;
|
border-radius: 8px 8px 8px 2px;
|
||||||
}
|
}
|
||||||
@@ -465,7 +465,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 11px 26px rgba(20, 27, 38, 0.07);
|
box-shadow: 0 11px 26px rgba(20, 27, 38, 0.07);
|
||||||
}
|
}
|
||||||
.shot-head {
|
.shot-head {
|
||||||
@@ -504,7 +504,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--pl-muted);
|
color: var(--pl-muted);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.shot-icon-btn svg { width: 13px; height: 13px; }
|
.shot-icon-btn svg { width: 13px; height: 13px; }
|
||||||
@@ -542,7 +542,7 @@
|
|||||||
.shot-area:focus {
|
.shot-area:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(0, 47, 167, 0.38);
|
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);
|
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
|
||||||
}
|
}
|
||||||
.shot-dialogue { min-height: 42px; }
|
.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); }
|
.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 { 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: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 .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: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); }
|
.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%;
|
width: 100%;
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -638,7 +638,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 72px minmax(0, 1fr) 74px;
|
grid-template-rows: 72px minmax(0, 1fr) 74px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.as-head {
|
.as-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -677,7 +677,7 @@
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.075);
|
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.075);
|
||||||
}
|
}
|
||||||
.as-row::before {
|
.as-row::before {
|
||||||
@@ -755,7 +755,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 7px 18px rgba(20, 27, 38, 0.06);
|
box-shadow: 0 7px 18px rgba(20, 27, 38, 0.06);
|
||||||
}
|
}
|
||||||
.as-preview.ready {
|
.as-preview.ready {
|
||||||
@@ -782,7 +782,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: #fff;
|
background-color: var(--surface);
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
|
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);
|
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: 1px solid rgba(0, 47, 167, 0.26);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -911,7 +911,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.085);
|
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.085);
|
||||||
}
|
}
|
||||||
.as-gen-preview {
|
.as-gen-preview {
|
||||||
@@ -923,7 +923,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
|
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.as-gen-preview.ready {
|
.as-gen-preview.ready {
|
||||||
background-image: var(--mock-media-url);
|
background-image: var(--mock-media-url);
|
||||||
@@ -1001,7 +1001,7 @@
|
|||||||
}
|
}
|
||||||
.as-gen-prompt:focus {
|
.as-gen-prompt:focus {
|
||||||
border-color: rgba(0, 47, 167, 0.38);
|
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);
|
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.06);
|
||||||
}
|
}
|
||||||
.as-gen-actions {
|
.as-gen-actions {
|
||||||
@@ -1088,7 +1088,7 @@
|
|||||||
.as-ai-btn svg, .as-ghost-btn svg { width: 14px; height: 14px; }
|
.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: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-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:hover { transform: translateY(-1px); border-color: rgba(0, 47, 167, 0.38); background: #eef5ff; }
|
||||||
.as-ghost-btn-sm { min-height: 30px; font-size: 12px; }
|
.as-ghost-btn-sm { min-height: 30px; font-size: 12px; }
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
@@ -1264,14 +1264,14 @@
|
|||||||
aspect-ratio: 9 / 16;
|
aspect-ratio: 9 / 16;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.video-thumb.pending::before {
|
.video-thumb.pending::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: #fff;
|
background-color: var(--surface);
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
|
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);
|
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: 1px solid rgba(0, 47, 167, 0.26);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -310,7 +310,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: -28px 0 70px rgba(10, 15, 24, 0.22);
|
box-shadow: -28px 0 70px rgba(10, 15, 24, 0.22);
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -445,7 +445,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -635,7 +635,7 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -766,7 +766,7 @@
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.12);
|
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.12);
|
||||||
}
|
}
|
||||||
.pcd-toast-ic {
|
.pcd-toast-ic {
|
||||||
@@ -816,7 +816,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -845,7 +845,7 @@
|
|||||||
background: rgba(255, 255, 255, 0.90);
|
background: rgba(255, 255, 255, 0.90);
|
||||||
box-shadow: 0 10px 28px rgba(20, 27, 38, 0.07);
|
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 {
|
.pcd-gallery-slot.empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid var(--pl-line);
|
border: 1px solid var(--pl-line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.pl-search svg {
|
.pl-search svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--pl-line);
|
border: 1px solid var(--pl-line);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -316,6 +316,35 @@
|
|||||||
border-color: var(--klein);
|
border-color: var(--klein);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--pl-shadow);
|
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 {
|
.pl-thumb {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -323,7 +352,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #e6e7ea;
|
background: #e6e7ea;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 10px;
|
||||||
|
margin: 8px 8px 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.pl-thumb img {
|
.pl-thumb img {
|
||||||
@@ -331,6 +361,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
.pl-thumb.has-mock-media {
|
.pl-thumb.has-mock-media {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
@@ -339,7 +370,7 @@
|
|||||||
}
|
}
|
||||||
.pl-thumb.placeholder {
|
.pl-thumb.placeholder {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.pl-thumb .ph-frame { display: none; }
|
.pl-thumb .ph-frame { display: none; }
|
||||||
|
|
||||||
@@ -421,9 +452,10 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.pl-grid.list .pl-thumb {
|
.pl-grid.list .pl-thumb {
|
||||||
width: 148px;
|
width: 132px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
|
margin: 8px;
|
||||||
}
|
}
|
||||||
.pl-grid.list .pl-meta {
|
.pl-grid.list .pl-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -489,7 +521,15 @@
|
|||||||
border-color: var(--klein, #002fa7);
|
border-color: var(--klein, #002fa7);
|
||||||
background: 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; }
|
.products-page.manage-mode .pl-card button.pl-tag { pointer-events: none; }
|
||||||
|
|
||||||
/* 影擎吸底选择条 · 管理模式即出现 */
|
/* 影擎吸底选择条 · 管理模式即出现 */
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||||
border-radius: 12px;
|
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-summary > span { color: var(--nw-muted); font-size: 10px; }
|
||||||
.project-wizard-page .nw-rail-track {
|
.project-wizard-page .nw-rail-track {
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.085);
|
border: 1px solid rgba(34, 42, 54, 0.085);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.project-wizard-page .nw-step[hidden] {
|
.project-wizard-page .nw-step[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid var(--nw-line);
|
border: 1px solid var(--nw-line);
|
||||||
border-radius: 10px;
|
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 svg { width: 16px; height: 16px; color: var(--nw-muted); }
|
||||||
.project-wizard-page .nw-search input {
|
.project-wizard-page .nw-search input {
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -317,7 +317,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--nw-line);
|
border: 1px solid var(--nw-line);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -513,7 +513,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid var(--nw-line);
|
border: 1px solid var(--nw-line);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
@@ -549,7 +549,7 @@
|
|||||||
border: 1px solid var(--nw-line);
|
border: 1px solid var(--nw-line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -192,7 +192,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid var(--vc-line);
|
border: 1px solid var(--vc-line);
|
||||||
border-radius: 10px;
|
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 svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--vc-muted); }
|
||||||
.projects-page .vc-search input {
|
.projects-page .vc-search input {
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid var(--vc-line);
|
border: 1px solid var(--vc-line);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--accent-black);
|
color: var(--accent-black);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: left;
|
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 { 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-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 { 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 { 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 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; }
|
.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-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-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-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-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-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); }
|
.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-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-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-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-panel { opacity: .82; }
|
||||||
.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; }
|
.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; }
|
.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 { 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-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-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-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-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; }
|
.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-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 { 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-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 { 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-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); }
|
.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 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-head span { color: var(--quick-muted); font-size: 13px; }
|
||||||
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
|
.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 { 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:disabled { cursor: default; }
|
||||||
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
.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-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 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-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:hover { background: rgba(0,47,167,.05); }
|
||||||
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
|
.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; }
|
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const TABS: { k: Tab; label: string; title: string; note: string }[] = [
|
|||||||
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
|
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
type Preview = { src: string; kind: "image"; name: string };
|
type Preview = { src: string; kind: "image"; name: string };
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ function modelTags(m: ModelEntity): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ModelsPage({ onNotify, onBillingChanged }: {
|
export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||||
onNotify?: (type: "success" | "error", text: string) => void;
|
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||||
onBillingChanged?: () => void;
|
onBillingChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("all");
|
const [tab, setTab] = useState<Tab>("all");
|
||||||
@@ -313,6 +313,14 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
|||||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
|
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
|
||||||
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
|
||||||
|
const enterEdit = () => {
|
||||||
|
setEditMode(true);
|
||||||
|
// 全部列表把官方模板排在前面,第一页往往一张都删不了,底栏会一直停在 0。
|
||||||
|
if (items.length > 0 && items.every((m) => m.is_official) && tab !== "mine") {
|
||||||
|
setTab("mine");
|
||||||
|
onNotify?.("info", "官方模特不能删除,已切换到我的模特");
|
||||||
|
}
|
||||||
|
};
|
||||||
const toggleSelect = (id: string) => setSelected((prev) => {
|
const toggleSelect = (id: string) => setSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
@@ -415,7 +423,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
|||||||
<p>维护可复用的品牌模特与人物参考资产</p>
|
<p>维护可复用的品牌模特与人物参考资产</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-actions">
|
<div className="ml-actions">
|
||||||
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
|
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : enterEdit())}>
|
||||||
<Settings2 />
|
<Settings2 />
|
||||||
<span>{editMode ? "完成" : "管理模特"}</span>
|
<span>{editMode ? "完成" : "管理模特"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -483,6 +491,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
|||||||
const onCardClick = () => {
|
const onCardClick = () => {
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
if (selectable) toggleSelect(m.id);
|
if (selectable) toggleSelect(m.id);
|
||||||
|
else onNotify?.("info", "官方模特不能删除");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDetail(m);
|
setDetail(m);
|
||||||
|
|||||||
@@ -941,7 +941,7 @@ export function OmniHistoryPage({
|
|||||||
<div className="omni-history-actions">
|
<div className="omni-history-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="删除项目"
|
aria-label="删除会话"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setDeleteTarget(item);
|
setDeleteTarget(item);
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && products.length === 0 ? (
|
{loading && products.length === 0 ? (
|
||||||
<SkeletonGrid count={8} />
|
<ProductGridSkeleton />
|
||||||
) : pageItems.length === 0 ? (
|
) : pageItems.length === 0 ? (
|
||||||
<div className="pl-empty" role="status">
|
<div className="pl-empty" role="status">
|
||||||
<PackageX />
|
<PackageX />
|
||||||
@@ -381,6 +381,26 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProductGridSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="pl-grid pl-skeleton-grid" aria-busy="true" aria-label="正在加载商品">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<article className="pl-card pl-skeleton-card" key={index} aria-hidden="true">
|
||||||
|
<div className="pl-skeleton-thumb skeleton-block" />
|
||||||
|
<div className="pl-meta pl-skeleton-meta">
|
||||||
|
<span className="pl-skeleton-line pl-skeleton-line--title skeleton-block" />
|
||||||
|
<span className="pl-skeleton-line pl-skeleton-line--copy skeleton-block" />
|
||||||
|
<div className="pl-skeleton-tags">
|
||||||
|
<span className="pl-skeleton-pill skeleton-block" />
|
||||||
|
<span className="pl-skeleton-pill pl-skeleton-pill--short skeleton-block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, onOpenVideos, editMode = false, selected = false, onDelete }: { product: Product; coverUrl?: string; videoCount?: number; onOpen: () => void; onOpenVideos?: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) {
|
export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, onOpenVideos, editMode = false, selected = false, onDelete }: { product: Product; coverUrl?: string; videoCount?: number; onOpen: () => void; onOpenVideos?: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) {
|
||||||
const mock = productCover(product.title);
|
const mock = productCover(product.title);
|
||||||
const assetCount = product.images?.length || 0;
|
const assetCount = product.images?.length || 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Monitor,
|
||||||
Shield,
|
Shield,
|
||||||
Upload,
|
Upload,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
@@ -12,16 +13,18 @@ import type { LoginSession, Team, User, UserPreference } from "../types";
|
|||||||
import { TeamModal } from "../components/overlays";
|
import { TeamModal } from "../components/overlays";
|
||||||
import { CustomSelect } from "../components/custom-select";
|
import { CustomSelect } from "../components/custom-select";
|
||||||
import { useFileDrop } from "../components/use-file-drop";
|
import { useFileDrop } from "../components/use-file-drop";
|
||||||
|
import { applyTheme, normalizeAppearance, readStoredAppearance } from "../theme";
|
||||||
|
|
||||||
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
||||||
|
|
||||||
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
|
// 可导航分区。pref(创作默认)仍隐藏;display(显示)开放外观切换。
|
||||||
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
|
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "display"];
|
||||||
|
|
||||||
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
|
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
|
||||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||||
{ key: "security", label: "安全", icon: <Shield /> },
|
{ key: "security", label: "安全", icon: <Shield /> },
|
||||||
{ key: "notify", label: "通知", icon: <Bell /> },
|
{ key: "notify", label: "通知", icon: <Bell /> },
|
||||||
|
{ key: "display", label: "显示", icon: <Monitor /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
function SettingRow({ title, hint, stack, children }: { title: string; hint?: string; stack?: boolean; children: ReactNode }) {
|
function SettingRow({ title, hint, stack, children }: { title: string; hint?: string; stack?: boolean; children: ReactNode }) {
|
||||||
@@ -174,7 +177,7 @@ export function SettingsPage({
|
|||||||
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
|
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
|
||||||
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
|
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
|
||||||
onResetAvatar?: () => void | Promise<unknown>;
|
onResetAvatar?: () => void | Promise<unknown>;
|
||||||
onNotify?: (text: string) => void;
|
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||||
onLogout?: () => void | Promise<void>;
|
onLogout?: () => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection)
|
const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection)
|
||||||
@@ -199,7 +202,10 @@ export function SettingsPage({
|
|||||||
transition: cd?.transition ?? DEFAULT_PREFS.transition,
|
transition: cd?.transition ?? DEFAULT_PREFS.transition,
|
||||||
twoFactor: !!preferences?.two_factor_enabled,
|
twoFactor: !!preferences?.two_factor_enabled,
|
||||||
notify: { ...DEFAULT_PREFS.notify, ...(preferences?.notify || {}) },
|
notify: { ...DEFAULT_PREFS.notify, ...(preferences?.notify || {}) },
|
||||||
appearance: dp?.appearance ?? DEFAULT_PREFS.appearance,
|
// preferences 尚未加载时用本地已选外观,避免一进设置就被默认 system 盖掉
|
||||||
|
appearance: !preferences
|
||||||
|
? readStoredAppearance()
|
||||||
|
: (dp?.appearance ?? DEFAULT_PREFS.appearance),
|
||||||
language: dp?.language ?? DEFAULT_PREFS.language,
|
language: dp?.language ?? DEFAULT_PREFS.language,
|
||||||
density: dp?.density ?? DEFAULT_PREFS.density,
|
density: dp?.density ?? DEFAULT_PREFS.density,
|
||||||
};
|
};
|
||||||
@@ -210,18 +216,19 @@ export function SettingsPage({
|
|||||||
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
|
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端)
|
// 外观即时生效(含后端 preferences 到达 / 放弃修改回滚)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBaseline(baselineFromProps);
|
applyTheme(normalizeAppearance(draft.appearance));
|
||||||
setDraft(baselineFromProps);
|
}, [draft.appearance]);
|
||||||
}, [baselineFromProps]);
|
|
||||||
|
|
||||||
// 单字段 draft 更新器
|
// 已保存基线的 ref,自动保存时避免连点用到过期 baseline
|
||||||
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
|
const baselineRef = useRef(baseline);
|
||||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
baselineRef.current = baseline;
|
||||||
}, []);
|
const draftRef = useRef(draft);
|
||||||
|
draftRef.current = draft;
|
||||||
|
const saveSeq = useRef(0);
|
||||||
|
|
||||||
// ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动) ───
|
// ─── 聚合脏字段集(文本框编辑中仍可能短暂脏;选项类会立即落盘) ───
|
||||||
const dirtyFields = useMemo<Array<keyof TrackedState>>(() => {
|
const dirtyFields = useMemo<Array<keyof TrackedState>>(() => {
|
||||||
const keys = Object.keys(FIELD_SECTION) as Array<keyof TrackedState>;
|
const keys = Object.keys(FIELD_SECTION) as Array<keyof TrackedState>;
|
||||||
return keys.filter((key) => {
|
return keys.filter((key) => {
|
||||||
@@ -239,6 +246,97 @@ export function SettingsPage({
|
|||||||
const dirtyCount = dirtyFields.length;
|
const dirtyCount = dirtyFields.length;
|
||||||
const isDirty = dirtyCount > 0;
|
const isDirty = dirtyCount > 0;
|
||||||
|
|
||||||
|
// 后端 preferences / user 到达或刷新时同步;保存中或本地有未落盘编辑时不覆盖 draft
|
||||||
|
useEffect(() => {
|
||||||
|
if (saving || isDirty) return;
|
||||||
|
setBaseline(baselineFromProps);
|
||||||
|
setDraft(baselineFromProps);
|
||||||
|
}, [baselineFromProps, saving, isDirty]);
|
||||||
|
|
||||||
|
// 把 next 相对 baseline 的变更拆成 profile / preferences 提交;成功后升基线
|
||||||
|
const persist = useCallback(async (next: TrackedState) => {
|
||||||
|
const base = baselineRef.current;
|
||||||
|
const profilePayload: { name?: string; email?: string; phone?: string } = {};
|
||||||
|
if (next.name !== base.name) profilePayload.name = next.name.trim();
|
||||||
|
if (next.email !== base.email) profilePayload.email = next.email.trim();
|
||||||
|
if (next.phone !== base.phone) profilePayload.phone = next.phone.trim();
|
||||||
|
|
||||||
|
const prefPayload: Partial<UserPreference> = {};
|
||||||
|
const creationKeys: Array<keyof TrackedState> = ["template", "duration", "subtitle", "bgm", "transition"];
|
||||||
|
if (creationKeys.some((key) => next[key] !== base[key])) {
|
||||||
|
prefPayload.creation_defaults = {
|
||||||
|
template: next.template,
|
||||||
|
duration: next.duration,
|
||||||
|
subtitle: next.subtitle,
|
||||||
|
bgm: next.bgm,
|
||||||
|
transition: next.transition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const displayKeys: Array<keyof TrackedState> = ["appearance", "language", "density"];
|
||||||
|
if (displayKeys.some((key) => next[key] !== base[key])) {
|
||||||
|
prefPayload.display = { appearance: next.appearance, language: next.language, density: next.density };
|
||||||
|
}
|
||||||
|
if (!notifyEqual(next.notify, base.notify)) prefPayload.notify = { ...next.notify };
|
||||||
|
if (next.twoFactor !== base.twoFactor) prefPayload.two_factor_enabled = next.twoFactor;
|
||||||
|
|
||||||
|
const hasProfile = Object.keys(profilePayload).length > 0;
|
||||||
|
const hasPref = Object.keys(prefPayload).length > 0;
|
||||||
|
if (!hasProfile && !hasPref) {
|
||||||
|
setBaseline(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seq = ++saveSeq.current;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const tasks: Array<Promise<unknown>> = [];
|
||||||
|
if (hasProfile) tasks.push(Promise.resolve(onSaveProfile(profilePayload)));
|
||||||
|
if (hasPref && onSavePreferences) {
|
||||||
|
tasks.push(
|
||||||
|
Promise.resolve(onSavePreferences(prefPayload)).then((result) => {
|
||||||
|
if (result == null) throw new Error("偏好设置保存失败,请重试");
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
if (seq !== saveSeq.current) return;
|
||||||
|
baselineRef.current = next;
|
||||||
|
setBaseline(next);
|
||||||
|
} catch (error) {
|
||||||
|
if (seq !== saveSeq.current) return;
|
||||||
|
// 失败不推进 baseline,回滚 draft 并恢复主题
|
||||||
|
const restored = baselineRef.current;
|
||||||
|
draftRef.current = restored;
|
||||||
|
setDraft(restored);
|
||||||
|
applyTheme(normalizeAppearance(restored.appearance));
|
||||||
|
onNotify?.(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error.message : "设置保存失败,请重试",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (seq === saveSeq.current) setSaving(false);
|
||||||
|
}
|
||||||
|
}, [onSaveProfile, onSavePreferences, onNotify]);
|
||||||
|
|
||||||
|
// 选项类:改完立即存
|
||||||
|
const commitField = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
|
||||||
|
const next = { ...draftRef.current, [key]: value };
|
||||||
|
draftRef.current = next;
|
||||||
|
setDraft(next);
|
||||||
|
if (key === "appearance") applyTheme(normalizeAppearance(value as string));
|
||||||
|
void persist(next);
|
||||||
|
}, [persist]);
|
||||||
|
|
||||||
|
// 文本框:只改 draft,失焦再存
|
||||||
|
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
|
||||||
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveDraftNow = useCallback(() => {
|
||||||
|
void persist(draftRef.current);
|
||||||
|
}, [persist]);
|
||||||
|
|
||||||
// 个人信息 · 受控输入(draft 派生)
|
// 个人信息 · 受控输入(draft 派生)
|
||||||
const name = draft.name;
|
const name = draft.name;
|
||||||
const email = draft.email;
|
const email = draft.email;
|
||||||
@@ -271,55 +369,6 @@ export function SettingsPage({
|
|||||||
[notifyOnCount, sessions.length],
|
[notifyOnCount, sessions.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── 取消:draft 回退到 baseline(放弃所有未保存改动) ───
|
|
||||||
function discardChanges() {
|
|
||||||
if (!isDirty) return;
|
|
||||||
setDraft(baseline);
|
|
||||||
onNotify?.("已放弃未保存的改动");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ───
|
|
||||||
async function handleSaveAll() {
|
|
||||||
if (!isDirty || saving) return;
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const profilePayload: { name?: string; email?: string; phone?: string } = {};
|
|
||||||
if (draft.name !== baseline.name) profilePayload.name = draft.name.trim();
|
|
||||||
if (draft.email !== baseline.email) profilePayload.email = draft.email.trim();
|
|
||||||
if (draft.phone !== baseline.phone) profilePayload.phone = draft.phone.trim();
|
|
||||||
|
|
||||||
const prefPayload: Partial<UserPreference> = {};
|
|
||||||
const creationKeys: Array<keyof TrackedState> = ["template", "duration", "subtitle", "bgm", "transition"];
|
|
||||||
if (creationKeys.some((key) => draft[key] !== baseline[key])) {
|
|
||||||
prefPayload.creation_defaults = {
|
|
||||||
template: draft.template,
|
|
||||||
duration: draft.duration,
|
|
||||||
subtitle: draft.subtitle,
|
|
||||||
bgm: draft.bgm,
|
|
||||||
transition: draft.transition,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const displayKeys: Array<keyof TrackedState> = ["appearance", "language", "density"];
|
|
||||||
if (displayKeys.some((key) => draft[key] !== baseline[key])) {
|
|
||||||
prefPayload.display = { appearance: draft.appearance, language: draft.language, density: draft.density };
|
|
||||||
}
|
|
||||||
if (!notifyEqual(draft.notify, baseline.notify)) prefPayload.notify = { ...draft.notify };
|
|
||||||
if (draft.twoFactor !== baseline.twoFactor) prefPayload.two_factor_enabled = draft.twoFactor;
|
|
||||||
|
|
||||||
const tasks: Array<Promise<unknown>> = [];
|
|
||||||
if (Object.keys(profilePayload).length > 0) tasks.push(Promise.resolve(onSaveProfile(profilePayload)));
|
|
||||||
if (Object.keys(prefPayload).length > 0 && onSavePreferences) tasks.push(Promise.resolve(onSavePreferences(prefPayload)));
|
|
||||||
await Promise.all(tasks);
|
|
||||||
|
|
||||||
// 提交成功 → draft 升为新基线,脏集清空
|
|
||||||
const savedCount = dirtyCount;
|
|
||||||
setBaseline(draft);
|
|
||||||
onNotify?.(`${savedCount} 项已保存`);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPasswordModal() {
|
function openPasswordModal() {
|
||||||
setOldPassword("");
|
setOldPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
@@ -381,32 +430,13 @@ export function SettingsPage({
|
|||||||
return () => URL.revokeObjectURL(avatarPreview);
|
return () => URL.revokeObjectURL(avatarPreview);
|
||||||
}, [avatarPreview]);
|
}, [avatarPreview]);
|
||||||
|
|
||||||
// ─── 离开页面前提醒:有未保存改动时弹浏览器原生确认 ───
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDirty) return;
|
|
||||||
const handler = (event: BeforeUnloadEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "";
|
|
||||||
};
|
|
||||||
window.addEventListener("beforeunload", handler);
|
|
||||||
return () => window.removeEventListener("beforeunload", handler);
|
|
||||||
}, [isDirty]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="settings-page">
|
<section className="settings-page">
|
||||||
<div className="settings-inner">
|
<div className="settings-inner">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>设置</h1>
|
<h1>设置</h1>
|
||||||
<div className="sub">个人信息、偏好、通知与安全</div>
|
<div className="sub">个人信息、偏好、通知与安全 · 修改后自动保存</div>
|
||||||
</div>
|
|
||||||
<div className="actions">
|
|
||||||
<button className="btn" type="button" onClick={discardChanges} disabled={!isDirty || saving}>取消</button>
|
|
||||||
<button className="btn btn-primary" type="button" onClick={handleSaveAll} disabled={!isDirty || saving}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
|
|
||||||
保存所有更改
|
|
||||||
{isDirty ? <span className="save-count">· {dirtyCount} 项</span> : null}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -449,7 +479,7 @@ export function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow title="显示名称" hint="用于团队和项目协作中展示">
|
<SettingRow title="显示名称" hint="用于团队和项目协作中展示">
|
||||||
<input className="st-input" value={name} onChange={(event) => patchDraft("name", event.target.value)} />
|
<input className="st-input" value={name} onChange={(event) => patchDraft("name", event.target.value)} onBlur={saveDraftNow} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow title="登录邮箱" hint="仅做记录用,不用于登录">
|
<SettingRow title="登录邮箱" hint="仅做记录用,不用于登录">
|
||||||
<input
|
<input
|
||||||
@@ -458,13 +488,15 @@ export function SettingsPage({
|
|||||||
value={email}
|
value={email}
|
||||||
placeholder="邮箱验证未启用"
|
placeholder="邮箱验证未启用"
|
||||||
onChange={(event) => patchDraft("email", event.target.value)}
|
onChange={(event) => patchDraft("email", event.target.value)}
|
||||||
|
onBlur={saveDraftNow}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow title="手机号" hint="保存所有更改时一并提交">
|
<SettingRow title="手机号" hint="修改后自动保存">
|
||||||
<input
|
<input
|
||||||
className="st-input"
|
className="st-input"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(event) => patchDraft("phone", event.target.value)}
|
onChange={(event) => patchDraft("phone", event.target.value)}
|
||||||
|
onBlur={saveDraftNow}
|
||||||
placeholder="138****8000"
|
placeholder="138****8000"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
@@ -499,7 +531,7 @@ export function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
|
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
|
||||||
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
|
<Switch checked={draft.twoFactor} onChange={(v) => commitField("twoFactor", v)} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow stack title="在用设备" hint="真实登录会话 · 每次登录记录设备 UA / IP">
|
<SettingRow stack title="在用设备" hint="真实登录会话 · 每次登录记录设备 UA / IP">
|
||||||
<div className="device-list">
|
<div className="device-list">
|
||||||
@@ -541,7 +573,7 @@ export function SettingsPage({
|
|||||||
<section className="pane" aria-label="通知">
|
<section className="pane" aria-label="通知">
|
||||||
{NOTIFY_ROWS.map((row) => (
|
{NOTIFY_ROWS.map((row) => (
|
||||||
<SettingRow key={row.key} title={row.title} hint={row.sub?.replace(/^\/\/\s*/, "")}>
|
<SettingRow key={row.key} title={row.title} hint={row.sub?.replace(/^\/\/\s*/, "")}>
|
||||||
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
|
<Switch checked={!!draft.notify[row.key]} onChange={(next) => commitField("notify", { ...draft.notify, [row.key]: next })} />
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
))}
|
))}
|
||||||
<div className="pane-desc" style={{ margin: "18px 0 4px" }}>消息中心分类</div>
|
<div className="pane-desc" style={{ margin: "18px 0 4px" }}>消息中心分类</div>
|
||||||
@@ -549,7 +581,7 @@ export function SettingsPage({
|
|||||||
<SettingRow key={row.key} title={row.title} hint={row.hint}>
|
<SettingRow key={row.key} title={row.title} hint={row.hint}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!draft.notify[`mute-${row.key}`]}
|
checked={!draft.notify[`mute-${row.key}`]}
|
||||||
onChange={(show) => patchDraft("notify", { ...draft.notify, [`mute-${row.key}`]: !show })}
|
onChange={(show) => commitField("notify", { ...draft.notify, [`mute-${row.key}`]: !show })}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
))}
|
))}
|
||||||
@@ -571,7 +603,7 @@ export function SettingsPage({
|
|||||||
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
|
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => patchDraft("template", choice.v)}
|
onClick={() => commitField("template", choice.v)}
|
||||||
>
|
>
|
||||||
<div className="t">{choice.t}</div>
|
<div className="t">{choice.t}</div>
|
||||||
<div className="d">{choice.d}</div>
|
<div className="d">{choice.d}</div>
|
||||||
@@ -590,7 +622,7 @@ export function SettingsPage({
|
|||||||
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
|
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => patchDraft("duration", d)}
|
onClick={() => commitField("duration", d)}
|
||||||
>
|
>
|
||||||
{d}s
|
{d}s
|
||||||
</span>
|
</span>
|
||||||
@@ -609,7 +641,7 @@ export function SettingsPage({
|
|||||||
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
|
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => patchDraft("subtitle", choice.v)}
|
onClick={() => commitField("subtitle", choice.v)}
|
||||||
>
|
>
|
||||||
<div className="t">{choice.t}</div>
|
<div className="t">{choice.t}</div>
|
||||||
<div className="d">{choice.d}</div>
|
<div className="d">{choice.d}</div>
|
||||||
@@ -621,7 +653,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">默认 BGM 库</div>
|
<div className="lbl">默认 BGM 库</div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<CustomSelect fill value={draft.bgm} onChange={(next) => patchDraft("bgm", next)} options={[
|
<CustomSelect fill value={draft.bgm} onChange={(next) => commitField("bgm", next)} options={[
|
||||||
{ value: "kapian", label: "抖音 Top10 卡点曲库" },
|
{ value: "kapian", label: "抖音 Top10 卡点曲库" },
|
||||||
{ value: "emotion", label: "情绪向 · 治愈/悬念" },
|
{ value: "emotion", label: "情绪向 · 治愈/悬念" },
|
||||||
{ value: "urban", label: "都市电子 · 通勤场景" },
|
{ value: "urban", label: "都市电子 · 通勤场景" },
|
||||||
@@ -632,7 +664,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">默认转场</div>
|
<div className="lbl">默认转场</div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<CustomSelect fill value={draft.transition} onChange={(next) => patchDraft("transition", next)} options={[
|
<CustomSelect fill value={draft.transition} onChange={(next) => commitField("transition", next)} options={[
|
||||||
{ value: "none", label: "无转场" },
|
{ value: "none", label: "无转场" },
|
||||||
{ value: "fade", label: "淡入淡出 · 0.3s" },
|
{ value: "fade", label: "淡入淡出 · 0.3s" },
|
||||||
{ value: "slide", label: "滑动 · 0.3s" },
|
{ value: "slide", label: "滑动 · 0.3s" },
|
||||||
@@ -651,6 +683,8 @@ export function SettingsPage({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{section === "display" && (
|
{section === "display" && (
|
||||||
<section className="pane" aria-label="显示">
|
<section className="pane" aria-label="显示">
|
||||||
<h3>显示</h3>
|
<h3>显示</h3>
|
||||||
@@ -659,17 +693,17 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">外观</div>
|
<div className="lbl">外观</div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<CustomSelect fill value={draft.appearance} onChange={(next) => patchDraft("appearance", next)} options={[
|
<CustomSelect fill value={draft.appearance} onChange={(next) => commitField("appearance", next)} options={[
|
||||||
{ value: "system", label: "跟随系统" },
|
{ value: "system", label: "跟随系统" },
|
||||||
{ value: "light", label: "浅色" },
|
{ value: "light", label: "浅色" },
|
||||||
{ value: "dark", label: "深色(V2)", disabled: true },
|
{ value: "dark", label: "深色" },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">语言</div>
|
<div className="lbl">语言</div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<CustomSelect fill value={draft.language} onChange={(next) => patchDraft("language", next)} options={[
|
<CustomSelect fill value={draft.language} onChange={(next) => commitField("language", next)} options={[
|
||||||
{ value: "zh", label: "简体中文" },
|
{ value: "zh", label: "简体中文" },
|
||||||
{ value: "en", label: "English(V2)", disabled: true },
|
{ value: "en", label: "English(V2)", disabled: true },
|
||||||
]} />
|
]} />
|
||||||
@@ -678,7 +712,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">表格密度</div>
|
<div className="lbl">表格密度</div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<CustomSelect fill value={draft.density} onChange={(next) => patchDraft("density", next)} options={[
|
<CustomSelect fill value={draft.density} onChange={(next) => commitField("density", next)} options={[
|
||||||
{ value: "compact", label: "紧凑" },
|
{ value: "compact", label: "紧凑" },
|
||||||
{ value: "standard", label: "标准" },
|
{ value: "standard", label: "标准" },
|
||||||
{ value: "loose", label: "宽松" },
|
{ value: "loose", label: "宽松" },
|
||||||
@@ -813,7 +847,7 @@ export function SettingsPage({
|
|||||||
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||||
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||||
</div>
|
</div>
|
||||||
{isDirty ? <div className="logout-unsaved-note">当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。</div> : null}
|
{isDirty ? <div className="logout-unsaved-note">还有 {dirtyCount} 项未落盘的修改(通常是正在编辑的文本),退出后不会保存。</div> : null}
|
||||||
</TeamModal>
|
</TeamModal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 210px minmax(0, 1fr);
|
grid-template-columns: 210px minmax(0, 1fr);
|
||||||
gap: 22px;
|
gap: 18px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
border: 1px solid var(--st-line);
|
border: 1px solid var(--st-line);
|
||||||
border-radius: 13px;
|
border-radius: 20px;
|
||||||
background: var(--st-wash);
|
background: var(--st-wash);
|
||||||
}
|
}
|
||||||
.settings-nav .nav-h {
|
.settings-nav .nav-h {
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.st-tab.has-changes .nav-dot { display: block; }
|
.st-tab.has-changes .nav-dot { display: block; }
|
||||||
.st-tab.active .nav-dot { background: #fff; }
|
.st-tab.active .nav-dot { background: var(--surface); }
|
||||||
.st-tab:not(:has(.nav-badge)) .nav-dot { margin-left: auto; }
|
.st-tab:not(:has(.nav-badge)) .nav-dot { margin-left: auto; }
|
||||||
|
|
||||||
.logout-pill {
|
.logout-pill {
|
||||||
@@ -132,24 +132,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-content {
|
.settings-content {
|
||||||
padding: 8px 22px;
|
padding: 22px 28px 24px;
|
||||||
border: 1px solid var(--st-line);
|
border: 1px solid var(--st-line);
|
||||||
border-radius: 13px;
|
border-radius: 20px;
|
||||||
background: rgba(34, 42, 54, 0.04);
|
background: rgba(34, 42, 54, 0.035);
|
||||||
box-shadow: var(--st-shadow);
|
box-shadow: var(--st-shadow);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pane { background: transparent; border: 0; border-radius: 0; padding: 0; margin: 0; }
|
/* 内容区本身是卡,pane 不再叠第二层底/阴影 */
|
||||||
|
.pane {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
||||||
.pane .pane-desc { font-size: 12px; color: var(--st-muted); margin-bottom: 18px; }
|
.pane .pane-desc { font-size: 12px; color: var(--st-muted); margin-bottom: 18px; }
|
||||||
|
|
||||||
.st-row {
|
.st-row {
|
||||||
min-height: 82px;
|
min-height: 76px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
padding: 4px 0;
|
||||||
border-bottom: 1px solid rgba(34, 42, 54, 0.075);
|
border-bottom: 1px solid rgba(34, 42, 54, 0.075);
|
||||||
}
|
}
|
||||||
.st-row:last-child { border-bottom: 0; }
|
.st-row:last-child { border-bottom: 0; }
|
||||||
@@ -196,7 +206,7 @@
|
|||||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--st-text);
|
color: var(--st-text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -283,7 +293,7 @@
|
|||||||
.switch { position: relative; width: 46px; height: 26px; flex: 0 0 46px; display: inline-block; }
|
.switch { position: relative; width: 46px; height: 26px; flex: 0 0 46px; display: inline-block; }
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.switch .slider { position: absolute; inset: 0; background: #cfd3da; border-radius: 999px; cursor: pointer; transition: background 170ms ease; }
|
.switch .slider { position: absolute; inset: 0; background: #cfd3da; border-radius: 999px; cursor: pointer; transition: background 170ms ease; }
|
||||||
.switch .slider::before { content: ''; position: absolute; left: 4px; top: 4px; width: 18px; height: 18px; background: #fff; border-radius: 50%; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); transition: transform 170ms ease; }
|
.switch .slider::before { content: ''; position: absolute; left: 4px; top: 4px; width: 18px; height: 18px; background: var(--surface); border-radius: 50%; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); transition: transform 170ms ease; }
|
||||||
.switch input:checked + .slider { background: var(--klein); }
|
.switch input:checked + .slider { background: var(--klein); }
|
||||||
.switch input:checked + .slider::before { transform: translateX(20px); }
|
.switch input:checked + .slider::before { transform: translateX(20px); }
|
||||||
.switch input:disabled + .slider { cursor: not-allowed; opacity: .55; }
|
.switch input:disabled + .slider { cursor: not-allowed; opacity: .55; }
|
||||||
|
|||||||
@@ -68,6 +68,31 @@
|
|||||||
--red-bd: #F2D6CE;
|
--red-bd: #F2D6CE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0f1115;
|
||||||
|
--bg-soft: #14171c;
|
||||||
|
--card: #181b21;
|
||||||
|
--border: #2a2f38;
|
||||||
|
--border-soft: #323843;
|
||||||
|
--ink: #e8eaed;
|
||||||
|
--ink-2: rgba(232, 234, 237, .72);
|
||||||
|
--ink-3: rgba(232, 234, 237, .52);
|
||||||
|
--ink-4: rgba(232, 234, 237, .32);
|
||||||
|
--orange: #3d6bff;
|
||||||
|
--orange-soft: rgba(61, 107, 255, .28);
|
||||||
|
--orange-tint: rgba(61, 107, 255, .10);
|
||||||
|
--orange-hover: #5a82ff;
|
||||||
|
--green: #6dd88a;
|
||||||
|
--green-bg: rgba(66, 195, 102, .14);
|
||||||
|
--green-bd: rgba(66, 195, 102, .32);
|
||||||
|
--red: #ff6b5c;
|
||||||
|
--red-bg: rgba(235, 52, 36, .14);
|
||||||
|
--red-bd: rgba(235, 52, 36, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -177,7 +202,7 @@ nav a.disabled:hover { background: transparent; color: var(--ink-4); }
|
|||||||
.user .em { font-size: 13px; }
|
.user .em { font-size: 13px; }
|
||||||
|
|
||||||
/* ─── Main + grid background ─── */
|
/* ─── Main + grid background ─── */
|
||||||
main { position: relative; background: #fff; }
|
main { position: relative; background: var(--surface); }
|
||||||
.grid-bg {
|
.grid-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1529,7 +1554,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
|||||||
.topup-modal .topup-amt { text-align: center; font-size: 34px; font-weight: 600; color: var(--ink); margin: 4px 0; }
|
.topup-modal .topup-amt { text-align: center; font-size: 34px; font-weight: 600; color: var(--ink); margin: 4px 0; }
|
||||||
.topup-note { text-align: center; color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
.topup-note { text-align: center; color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||||
.topup-qr { width: 180px; height: 180px; margin: 18px auto; background: repeating-linear-gradient(45deg, var(--ink) 0 4px, #fff 4px 8px); border: 8px solid #fff; display: grid; place-items: center; }
|
.topup-qr { width: 180px; height: 180px; margin: 18px auto; background: repeating-linear-gradient(45deg, var(--ink) 0 4px, #fff 4px 8px); border: 8px solid #fff; display: grid; place-items: center; }
|
||||||
.topup-qr .center { background: #fff; padding: 12px; text-align: center; font-weight: 600; }
|
.topup-qr .center { background: var(--surface); padding: 12px; text-align: center; font-weight: 600; }
|
||||||
.topup-qr .center span { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
.topup-qr .center span { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||||
|
|
||||||
/* 团队页旧全局块已移除 · 详见 src/team-page.css 的 .team-page 作用域。保留通用 .av(app-shell/account/dashboard 在用)。 */
|
/* 团队页旧全局块已移除 · 详见 src/team-page.css 的 .team-page 作用域。保留通用 .av(app-shell/account/dashboard 在用)。 */
|
||||||
@@ -1597,7 +1622,7 @@ table.t tbody tr:hover { background: var(--bg-soft); }
|
|||||||
.switch { position: relative; display: inline-flex; width: 42px; height: 24px; }
|
.switch { position: relative; display: inline-flex; width: 42px; height: 24px; }
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
.slider { position: absolute; inset: 0; border-radius: 999px; background: var(--border); transition: .15s; }
|
.slider { position: absolute; inset: 0; border-radius: 999px; background: var(--border); transition: .15s; }
|
||||||
.slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%; background: #fff; transition: .15s; }
|
.slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; border-radius: 50%; background: var(--surface); transition: .15s; }
|
||||||
.switch input:checked + .slider { background: var(--orange); }
|
.switch input:checked + .slider { background: var(--orange); }
|
||||||
.switch input:checked + .slider::before { transform: translateX(18px); }
|
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||||
|
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
padding: 0 13px;
|
padding: 0 13px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
color: var(--st-muted);
|
color: var(--st-muted);
|
||||||
}
|
}
|
||||||
.tc-search input {
|
.tc-search input {
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
line-height: 0;
|
line-height: 0;
|
||||||
border: 1px solid var(--st-line);
|
border: 1px solid var(--st-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--st-muted);
|
color: var(--st-muted);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/** 外观主题:浅色 / 深色 / 跟随系统。写到 html[data-theme] + localStorage,避免首屏闪白。 */
|
||||||
|
|
||||||
|
export type Appearance = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
export const THEME_STORAGE_KEY = "yingqing-appearance";
|
||||||
|
|
||||||
|
export function normalizeAppearance(value: unknown): Appearance {
|
||||||
|
if (value === "light" || value === "dark" || value === "system") return value;
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(appearance: Appearance): "light" | "dark" {
|
||||||
|
if (appearance === "light" || appearance === "dark") return appearance;
|
||||||
|
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(appearance: Appearance | string | null | undefined) {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const normalized = normalizeAppearance(appearance);
|
||||||
|
const theme = resolveTheme(normalized);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute("data-theme", theme);
|
||||||
|
root.style.colorScheme = theme;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, normalized);
|
||||||
|
} catch {
|
||||||
|
/* ignore quota / private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredAppearance(): Appearance {
|
||||||
|
try {
|
||||||
|
return normalizeAppearance(localStorage.getItem(THEME_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -810,7 +810,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, 0.16);
|
border: 1px solid rgba(0, 47, 167, 0.16);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: #78849a;
|
color: #78849a;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||||
@@ -903,7 +903,7 @@
|
|||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 8px 20px rgba(0, 47, 167, 0.08);
|
box-shadow: 0 8px 20px rgba(0, 47, 167, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,7 +1015,7 @@
|
|||||||
|
|
||||||
.vr-page .remix-page .remix-prompt-panel .analysis-prompt {
|
.vr-page .remix-page .remix-prompt-panel .analysis-prompt {
|
||||||
border-left: 3px solid var(--klein);
|
border-left: 3px solid var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: inset 0 1px 3px rgba(27, 45, 83, 0.03), 0 8px 22px rgba(22, 45, 92, 0.045);
|
box-shadow: inset 0 1px 3px rgba(27, 45, 83, 0.03), 0 8px 22px rgba(22, 45, 92, 0.045);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, 0.24);
|
border: 1px solid rgba(0, 47, 167, 0.24);
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1312,7 +1312,7 @@
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: #27334a;
|
color: #27334a;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
@@ -1327,7 +1327,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, 0.2);
|
border: 1px solid rgba(0, 47, 167, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
|
|
||||||
.vrep-page .replace-mode-button.active {
|
.vrep-page .replace-mode-button.active {
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 7px 18px rgba(22, 45, 92, 0.09), inset 0 0 0 1px rgba(0, 47, 167, 0.11);
|
box-shadow: 0 7px 18px rgba(22, 45, 92, 0.09), inset 0 0 0 1px rgba(0, 47, 167, 0.11);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +553,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||||
border-radius: 13px;
|
border-radius: 13px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 8px 18px rgba(0, 47, 167, 0.07);
|
box-shadow: 0 8px 18px rgba(0, 47, 167, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vrep-page .replace-temporary-cell {
|
.vrep-page .replace-temporary-cell {
|
||||||
@@ -686,7 +686,7 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vrep-page .replace-temporary-more svg {
|
.vrep-page .replace-temporary-more svg {
|
||||||
@@ -1130,7 +1130,7 @@
|
|||||||
border: 1px solid rgba(0, 47, 167, 0.18);
|
border: 1px solid rgba(0, 47, 167, 0.18);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--klein);
|
color: var(--klein);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1232,7 +1232,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: -22px 0 48px rgba(7, 12, 22, 0.2);
|
box-shadow: -22px 0 48px rgba(7, 12, 22, 0.2);
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -1363,7 +1363,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
border-top: 1px solid rgba(34, 42, 54, 0.09);
|
border-top: 1px solid rgba(34, 42, 54, 0.09);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 -10px 24px rgba(20, 27, 38, 0.045);
|
box-shadow: 0 -10px 24px rgba(20, 27, 38, 0.045);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# yingqing / AirShelf — 1Panel + Docker Compose
|
||||||
|
# MySQL/Redis 由 1Panel 安装,容器名:yingqing-mysql、yingqing-redis
|
||||||
|
# 应用加入 1panel-network,反代到本机 :8080
|
||||||
|
|
||||||
|
services:
|
||||||
|
airshelf-core-api:
|
||||||
|
build:
|
||||||
|
context: ./core/backend
|
||||||
|
image: airshelf-core-api:local
|
||||||
|
container_name: airshelf-core-api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./core/backend/.env
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: airshelf.settings.production
|
||||||
|
# entrypoint 在 gunicorn 时跑 migrate + collectstatic
|
||||||
|
command: ["gunicorn", "airshelf.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--threads", "4", "--timeout", "300"]
|
||||||
|
networks:
|
||||||
|
- 1panel-network
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
|
||||||
|
airshelf-core-worker:
|
||||||
|
image: airshelf-core-api:local
|
||||||
|
container_name: airshelf-core-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./core/backend/.env
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: airshelf.settings.production
|
||||||
|
command: ["celery", "-A", "airshelf.celery:app", "worker", "-l", "info", "-Q", "celery", "--concurrency", "4"]
|
||||||
|
depends_on:
|
||||||
|
- airshelf-core-api
|
||||||
|
networks:
|
||||||
|
- 1panel-network
|
||||||
|
|
||||||
|
airshelf-core-worker-quick:
|
||||||
|
image: airshelf-core-api:local
|
||||||
|
container_name: airshelf-core-worker-quick
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./core/backend/.env
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: airshelf.settings.production
|
||||||
|
command: ["celery", "-A", "airshelf.celery:app", "worker", "-l", "info", "-Q", "airshelf.quick", "--concurrency", "4"]
|
||||||
|
depends_on:
|
||||||
|
- airshelf-core-api
|
||||||
|
networks:
|
||||||
|
- 1panel-network
|
||||||
|
|
||||||
|
yingqing-web:
|
||||||
|
build:
|
||||||
|
context: ./core/frontend
|
||||||
|
image: airshelf-yingqing-web:local
|
||||||
|
container_name: yingqing-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- airshelf-core-api
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
networks:
|
||||||
|
- 1panel-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
1panel-network:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "yingqing",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"be": "bash scripts/dev.sh be",
|
||||||
|
"fe": "bash scripts/dev.sh fe",
|
||||||
|
"dev": "bash scripts/dev.sh dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 部署 dev 到本机 Docker Compose(可由 Gitea Actions 或 Webhook 调用)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-/opt/AirShelf}"
|
||||||
|
BRANCH="${BRANCH:-dev}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
ENV_FILE="${ENV_FILE:-core/backend/.env}"
|
||||||
|
LOG_DIR="${LOG_DIR:-/var/log/airshelf}"
|
||||||
|
LOCK_FILE="${LOCK_FILE:-/tmp/airshelf-deploy-dev.lock}"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG_FILE="${LOG_DIR}/deploy-dev.log"
|
||||||
|
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
echo "$(date '+%F %T') another deploy is running, skip" | tee -a "$LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "======== $(date '+%F %T') deploy start ========"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
if [[ ! -d .git ]]; then
|
||||||
|
echo "ERROR: $APP_DIR is not a git repo. Clone from your Gitea first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .env 在仓库里被跟踪,pull 会覆盖服务器生产配置;先备份再还原
|
||||||
|
ENV_BACKUP=""
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
ENV_BACKUP="$(mktemp)"
|
||||||
|
cp -a "$ENV_FILE" "$ENV_BACKUP"
|
||||||
|
echo "backed up $ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git fetch --prune origin
|
||||||
|
git checkout "$BRANCH"
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
|
||||||
|
if [[ -n "$ENV_BACKUP" ]]; then
|
||||||
|
cp -a "$ENV_BACKUP" "$ENV_FILE"
|
||||||
|
rm -f "$ENV_BACKUP"
|
||||||
|
echo "restored $ENV_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||||
|
|
||||||
|
echo "======== $(date '+%F %T') deploy ok ========"
|
||||||
|
} >>"$LOG_FILE" 2>&1
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在仓库任意目录记这三条就够:
|
||||||
|
# npm run be 只开后端 http://127.0.0.1:8010
|
||||||
|
# npm run fe 只开前端 http://127.0.0.1:5173
|
||||||
|
# npm run dev 两个一起开(在项目根目录)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
MODE="${1:-all}"
|
||||||
|
PY="$ROOT/core/backend/.venv/bin/python"
|
||||||
|
|
||||||
|
listening() {
|
||||||
|
lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
need_python() {
|
||||||
|
if [[ -x "$PY" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "后端环境还没装好。在项目里执行一次:"
|
||||||
|
echo " cd core/backend && python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start_backend() {
|
||||||
|
need_python
|
||||||
|
if listening 8010; then
|
||||||
|
echo "后端已经在跑: http://127.0.0.1:8010"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "后端 → http://127.0.0.1:8010"
|
||||||
|
cd "$ROOT/core/backend"
|
||||||
|
exec "$PY" manage.py runserver 0.0.0.0:8010
|
||||||
|
}
|
||||||
|
|
||||||
|
start_frontend() {
|
||||||
|
if listening 5173; then
|
||||||
|
echo "前端已经在跑: http://127.0.0.1:5173"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$ROOT/core/frontend/node_modules" ]]; then
|
||||||
|
echo "第一次开前端,先装依赖…"
|
||||||
|
npm --prefix "$ROOT/core/frontend" install
|
||||||
|
fi
|
||||||
|
echo "前端 → http://127.0.0.1:5173"
|
||||||
|
cd "$ROOT/core/frontend"
|
||||||
|
exec npm run dev
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
be|backend)
|
||||||
|
start_backend
|
||||||
|
;;
|
||||||
|
fe|frontend)
|
||||||
|
start_frontend
|
||||||
|
;;
|
||||||
|
all|dev)
|
||||||
|
need_python
|
||||||
|
trap 'kill 0' INT TERM
|
||||||
|
if listening 8010; then
|
||||||
|
echo "后端已经在跑: http://127.0.0.1:8010"
|
||||||
|
else
|
||||||
|
echo "后端 → http://127.0.0.1:8010"
|
||||||
|
(cd "$ROOT/core/backend" && "$PY" manage.py runserver 0.0.0.0:8010) &
|
||||||
|
fi
|
||||||
|
if listening 5173; then
|
||||||
|
echo "前端已经在跑: http://127.0.0.1:5173"
|
||||||
|
else
|
||||||
|
echo "前端 → http://127.0.0.1:5173"
|
||||||
|
(cd "$ROOT/core/frontend" && npm run dev) &
|
||||||
|
fi
|
||||||
|
wait
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "用法: npm run be | npm run fe | npm run dev"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在部署机上一键安装 Gitea Act Runner(仅需执行一次)
|
||||||
|
# 用法:
|
||||||
|
# export GITEA_URL=https://gitea.yueqing.online
|
||||||
|
# export RUNNER_TOKEN=从Gitea复制的注册Token
|
||||||
|
# bash scripts/install-act-runner.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:?请设置 GITEA_URL,例如 https://gitea.yueqing.online}"
|
||||||
|
RUNNER_TOKEN="${RUNNER_TOKEN:?请设置 RUNNER_TOKEN(Gitea → 仓库/站点 Actions → Runners → 创建)}"
|
||||||
|
RUNNER_NAME="${RUNNER_NAME:-yingqing-ecs}"
|
||||||
|
LABELS="${LABELS:-yingqing:host}"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/act_runner}"
|
||||||
|
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
cd "$INSTALL_DIR"
|
||||||
|
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) ARCH=amd64 ;;
|
||||||
|
aarch64|arm64) ARCH=arm64 ;;
|
||||||
|
*) echo "不支持的架构: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
VER="${ACT_RUNNER_VERSION:-0.2.11}"
|
||||||
|
BIN_URL="https://gitea.com/gitea/act_runner/releases/download/v${VER}/act_runner-${VER}-linux-${ARCH}"
|
||||||
|
|
||||||
|
echo "下载 act_runner v${VER} ..."
|
||||||
|
curl -fL "$BIN_URL" -o act_runner
|
||||||
|
chmod +x act_runner
|
||||||
|
|
||||||
|
if [[ ! -f .runner ]]; then
|
||||||
|
echo "注册 Runner ..."
|
||||||
|
./act_runner register --no-interactive \
|
||||||
|
--instance "$GITEA_URL" \
|
||||||
|
--token "$RUNNER_TOKEN" \
|
||||||
|
--name "$RUNNER_NAME" \
|
||||||
|
--labels "$LABELS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >/etc/systemd/system/act_runner.service <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Act Runner
|
||||||
|
After=network.target docker.service
|
||||||
|
Wants=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
ExecStart=${INSTALL_DIR}/act_runner daemon
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now act_runner
|
||||||
|
systemctl --no-pager --full status act_runner || true
|
||||||
|
echo
|
||||||
|
echo "完成。在 Gitea → Actions → Runners 应看到「${RUNNER_NAME}」在线,label: ${LABELS}"
|
||||||
|
echo "可停掉旧的 webhook(避免重复部署):systemctl disable --now airshelf-webhook"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AirShelf Gitea deploy webhook
|
||||||
|
After=network.target docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Environment=WEBHOOK_SECRET=CHANGE_ME
|
||||||
|
Environment=WEBHOOK_HOST=127.0.0.1
|
||||||
|
Environment=WEBHOOK_PORT=9000
|
||||||
|
Environment=DEPLOY_SH=/opt/AirShelf/scripts/deploy-dev.sh
|
||||||
|
ExecStart=/usr/bin/python3 /opt/AirShelf/scripts/webhook/receiver.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+76
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gitea push webhook → only refs/heads/dev → scripts/deploy-dev.sh"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
|
||||||
|
DEPLOY_SH = os.environ.get("DEPLOY_SH", "/opt/AirShelf/scripts/deploy-dev.sh")
|
||||||
|
HOST = os.environ.get("WEBHOOK_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.environ.get("WEBHOOK_PORT", "9000"))
|
||||||
|
|
||||||
|
|
||||||
|
def verify(sig: str | None, body: bytes) -> bool:
|
||||||
|
if not SECRET or not sig:
|
||||||
|
return False
|
||||||
|
digest = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(digest, sig.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def run_deploy() -> None:
|
||||||
|
subprocess.run([DEPLOY_SH], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
|
print(f"[webhook] {self.address_string()} - {fmt % args}")
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path not in ("/hooks/deploy-dev", "/deploy-dev", "/"):
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
sig = self.headers.get("X-Gitea-Signature") or self.headers.get("X-Hub-Signature-256", "")
|
||||||
|
if sig.startswith("sha256="):
|
||||||
|
sig = sig[7:]
|
||||||
|
if not verify(sig, body):
|
||||||
|
self.send_response(401)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"invalid signature")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
ref = payload.get("ref", "")
|
||||||
|
if ref != "refs/heads/dev":
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(f"ignored ref={ref}".encode())
|
||||||
|
return
|
||||||
|
threading.Thread(target=run_deploy, daemon=True).start()
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"deploy-dev accepted")
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
self.send_response(200)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"airshelf webhook ok")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not SECRET:
|
||||||
|
raise SystemExit("WEBHOOK_SECRET is required")
|
||||||
|
print(f"listening on {HOST}:{PORT}, deploy={DEPLOY_SH}")
|
||||||
|
HTTPServer((HOST, PORT), Handler).serve_forever()
|
||||||
Reference in New Issue
Block a user