Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84431c9c5 | ||
|
|
5dbb240b59 | ||
|
|
ceb5163881 | ||
|
|
cfac6006ef | ||
|
|
e7afcd0449 |
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>新建项目 · Airshelf</title>
|
||||
<title>新建项目 · YingQing AI</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="assets/restraint.css">
|
||||
<style>
|
||||
|
||||
+4
-4
@@ -15,10 +15,10 @@ REDIS_CACHE_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/0
|
||||
CELERY_BROKER_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://:redis_WFSzWJ@yingqing-redis:6379/2
|
||||
REDIS_LOCK_URL=redis://:redis_WFSzWJ@yingqing-redis:6379/3
|
||||
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
|
||||
TOS_BUCKET=airshelf
|
||||
TOS_ACCESS_KEY_ID=AKLTODVhY2U1NzY1MTU3NDA4NThiYzk2ZDMyZDNjYmZhZGY
|
||||
TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ==
|
||||
TOS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
|
||||
TOS_BUCKET=yingqing-media
|
||||
TOS_ACCESS_KEY_ID=LTAI5t62RF5Swgs2uwopvuZz
|
||||
TOS_SECRET_ACCESS_KEY=CMmQNGiG2ieESRrhD1ORwuB6FYBmxE
|
||||
VOLCANO_ARK_API_KEY=ark-a0efcea2-fbf4-47bb-a43e-e3eed4138abc-0ad26
|
||||
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
# 2026-07-09 视频(Seedance)ARK key 换成新账号,与新账号素材库审核同账号 → 真人 asset:// 可解析。EP=ep-20260707204223-wkcn9(备用,代码按模型名调不用它)。
|
||||
|
||||
@@ -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")
|
||||
|
||||
def _tos_region() -> str:
|
||||
"""阿里云 OSS 的 S3 签名区域是 oss-cn-beijing 这种主机名前缀。火山 TOS 仍用 cn-shanghai。"""
|
||||
explicit = (env("TOS_REGION") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
host = (env("TOS_ENDPOINT") or "").split("://", 1)[-1].split("/", 1)[0]
|
||||
if host.startswith("oss-") and host.endswith(".aliyuncs.com"):
|
||||
return host.split(".", 1)[0]
|
||||
return "cn-shanghai"
|
||||
|
||||
|
||||
TOS = {
|
||||
"endpoint": env("TOS_ENDPOINT"),
|
||||
"bucket": env("TOS_BUCKET"),
|
||||
"region": _tos_region(),
|
||||
"access_key_id": env("TOS_ACCESS_KEY_ID"),
|
||||
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
|
||||
# 换到阿里云之后,历史文件仍在火山引擎旧桶。按桶名把旧链接指回原地址。
|
||||
"legacy_endpoint": env("TOS_LEGACY_ENDPOINT", "https://tos-s3-cn-shanghai.volces.com"),
|
||||
"legacy_bucket": env("TOS_LEGACY_BUCKET", "airshelf"),
|
||||
}
|
||||
|
||||
VOLCANO = {
|
||||
|
||||
@@ -3,5 +3,16 @@ from .base import * # noqa: F403
|
||||
|
||||
DEBUG = True
|
||||
|
||||
# 本机 runserver 不在 K8s 的 service DNS 中;若直接沿用集群 Redis 地址,任何依赖
|
||||
# cache.get/cache.add 的普通读取接口都会变成 500。开发环境默认使用进程内缓存,确需
|
||||
# 联调分布式锁或 Celery 时再显式设 AIRSHELF_DEV_USE_REDIS_CACHE=true。
|
||||
if not env_bool("AIRSHELF_DEV_USE_REDIS_CACHE", False): # noqa: F405
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "airshelf-development-cache",
|
||||
}
|
||||
}
|
||||
|
||||
# MySQL 连接复用(CONN_MAX_AGE / 健康检查 / connect_timeout)已上移到 base.py,
|
||||
# development 与 production 共用,此处不再重复设置。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -103,6 +105,21 @@ class SingleDeviceLoginTests(TestCase):
|
||||
self.assertEqual(active.count(), 1)
|
||||
self.assertEqual(active.get().user_agent, "Device B")
|
||||
|
||||
def test_login_succeeds_when_device_audit_write_fails(self):
|
||||
"""设备审计表异常不能回滚已经签发的登录 Token。"""
|
||||
with patch(
|
||||
"apps.accounts.views.LoginSession.objects.create",
|
||||
side_effect=IntegrityError("device audit unavailable"),
|
||||
):
|
||||
response = APIClient().post(
|
||||
"/api/auth/login/",
|
||||
{"username": self.user.username, "password": "strong-password"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("token", response.data)
|
||||
|
||||
|
||||
class InvitationFlowTests(TestCase):
|
||||
def _register(self, client, username, **extra):
|
||||
|
||||
@@ -99,6 +99,9 @@ def issue_single_device_token(request, user):
|
||||
LoginSession.objects.filter(user=locked_user, revoked_at__isnull=True).update(revoked_at=timezone.now())
|
||||
Token.objects.filter(user=locked_user).delete()
|
||||
token = Token.objects.create(user=locked_user)
|
||||
# 设备记录只用于设置页展示和审计,不属于 Token 签发的关键路径。此前它在
|
||||
# 同一事务里即使被 record_login_session 捕获,数据库事务仍可能已被标记为
|
||||
# rollback,离开 atomic 后登录照样变成 500。
|
||||
record_login_session(request, locked_user)
|
||||
return token
|
||||
|
||||
|
||||
@@ -193,15 +193,20 @@ def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None
|
||||
if state == "allowed":
|
||||
return
|
||||
name = label or asset.name or "未命名"
|
||||
if state == "unavailable":
|
||||
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试")
|
||||
if state == "failed":
|
||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
|
||||
# unsubmitted / 仍 processing:补送并短等,通过则放行
|
||||
waited = wait_upload_review(asset, timeout_s=20.0)
|
||||
if waited in ("active", "allowed"):
|
||||
if waited == "active":
|
||||
return
|
||||
if waited == "failed":
|
||||
err = (asset.review_error or "").strip()
|
||||
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"({err})" if err else ""))
|
||||
if waited == "unavailable":
|
||||
err = (asset.review_error or "").strip()
|
||||
raise ValueError(f"素材「{name}」审核服务暂不可用,请稍后再试" + (f"({err})" if err else ""))
|
||||
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
|
||||
|
||||
|
||||
|
||||
@@ -2991,7 +2991,7 @@ def notify_generation_failure(
|
||||
|
||||
|
||||
def _asset_preview_url(asset) -> str:
|
||||
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
|
||||
"""资产主文件的公读直链。写进会话的地址不能带签名,否则 1 小时后图片会消失。"""
|
||||
if asset is None:
|
||||
return ""
|
||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
@@ -2999,16 +2999,16 @@ def _asset_preview_url(asset) -> str:
|
||||
return ""
|
||||
if primary.preview_url:
|
||||
return primary.preview_url
|
||||
if not primary.object_key:
|
||||
return ""
|
||||
try:
|
||||
return TosStorage().presigned_get_url(object_key=primary.object_key)
|
||||
return TosStorage().public_url(object_key=primary.object_key, bucket=primary.bucket or None)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def asset_stable_url(asset) -> tuple[str, str]:
|
||||
"""资产的「长期可打开」直链 (主文件, 封面图)。历史记录这类隔天还要点开的场景必须用这个:
|
||||
预签名链只活 1 小时,存进 payload 快照的话第二天就播不了 —— 公读直链不过期还能被缓存。
|
||||
顺序:落库 preview_url → TOS 公读直链 → 兜底预签名。"""
|
||||
"""资产的长期直链 (主文件, 封面图)。公读地址不过期,结果卡隔天还能打开。"""
|
||||
if asset is None:
|
||||
return "", ""
|
||||
files = list(asset.files.all())
|
||||
@@ -3031,10 +3031,6 @@ def asset_stable_url(asset) -> tuple[str, str]:
|
||||
return ""
|
||||
try:
|
||||
return TosStorage().public_url(object_key=f.object_key, bucket=f.bucket or None)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
return TosStorage().presigned_get_url(object_key=f.object_key)
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def _asset(team, *, source, review_status="", review_remote_id="", category=Asse
|
||||
|
||||
|
||||
class ReferenceReviewStateTests(TestCase):
|
||||
"""审核判定四态。审核服务开着时才生效,关着一律放行(不能拿没配置的机制拦人)。"""
|
||||
"""审核判定四态。平台生成素材可免责,用户上传绝不能因审核停用而放行。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="refowner", password="p")
|
||||
@@ -83,9 +83,9 @@ class ReferenceReviewStateTests(TestCase):
|
||||
asset.save(update_fields=["in_library"])
|
||||
self.assertEqual(reference_review_state(asset), "unsubmitted")
|
||||
|
||||
def test_review_disabled_lets_everything_through(self):
|
||||
def test_review_disabled_still_blocks_uploaded_asset(self):
|
||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "allowed")
|
||||
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unavailable")
|
||||
|
||||
|
||||
class VideoSeedRangeTests(TestCase):
|
||||
|
||||
@@ -1715,8 +1715,14 @@ class FreeVideoUploadView(APIView):
|
||||
thumb_url = storage.public_url(object_key=poster_key)
|
||||
|
||||
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
|
||||
from apps.assets import assets_client
|
||||
from apps.assets.review import wait_upload_review
|
||||
|
||||
if not assets_client.is_enabled():
|
||||
return Response(
|
||||
{"detail": "素材审核服务暂不可用,请稍后重试", "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
review_status = wait_upload_review(asset)
|
||||
asset.refresh_from_db()
|
||||
if review_status == "failed":
|
||||
@@ -1727,7 +1733,13 @@ class FreeVideoUploadView(APIView):
|
||||
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||
)
|
||||
# active / allowed:进库,可供引用
|
||||
if review_status != "active":
|
||||
detail = (asset.review_error or "素材审核服务暂不可用,请稍后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
# active:进库,可供引用
|
||||
if not asset.in_library:
|
||||
asset.in_library = True
|
||||
asset.save(update_fields=["in_library", "updated_at"])
|
||||
@@ -1742,7 +1754,7 @@ class FreeVideoUploadView(APIView):
|
||||
"width": width,
|
||||
"height": height,
|
||||
"thumb_url": thumb_url or (url if kind == "image" else ""),
|
||||
"review_status": "active" if review_status in ("active", "allowed") else review_status,
|
||||
"review_status": "active",
|
||||
"in_library": True,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
@@ -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}"
|
||||
if is_plain:
|
||||
# 模型目录缓存只是加速层;开发机或 Redis 短暂不可用时仍应正常返回目录。
|
||||
try:
|
||||
cached = cache.get(cache_key)
|
||||
except Exception:
|
||||
logger.warning("模型目录缓存不可用,改为直接读取", exc_info=True)
|
||||
cached = None
|
||||
if cached is not None:
|
||||
return Response(cached)
|
||||
response = super().list(request, *args, **kwargs)
|
||||
if is_plain and response.status_code == 200:
|
||||
try:
|
||||
cache.set(cache_key, response.data, MODEL_CATALOG_CACHE_TTL)
|
||||
except Exception:
|
||||
logger.warning("模型目录缓存写入失败,已返回直读结果", exc_info=True)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -37,12 +37,15 @@ OTHER_REMOTE_GROUP_MESSAGE = "该素材组属于其他火山账号,请新建
|
||||
|
||||
|
||||
def _serialize_asset(fa: FreeAsset) -> dict:
|
||||
# 火山仍在审核的素材不能把 TOS 原始链接回传给浏览器。否则前端虽已禁选,
|
||||
# 仍可能把未审核直链带入其它生成入口。审核通过后 poll 接口会再返回链接。
|
||||
available = fa.status == FreeAsset.Status.ACTIVE
|
||||
return {
|
||||
"id": str(fa.id),
|
||||
"name": fa.name,
|
||||
"url": fa.url,
|
||||
"url": fa.url if available else "",
|
||||
"type": fa.asset_type.lower(),
|
||||
"thumb_url": fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else ""),
|
||||
"thumb_url": (fa.thumbnail_url or (fa.url if fa.asset_type == FreeAsset.Type.IMAGE else "")) if available else "",
|
||||
"duration": fa.duration,
|
||||
"status": fa.status,
|
||||
"error_message": fa.error_message,
|
||||
@@ -167,7 +170,9 @@ def _upload_asset_to_group(request, *, team, group: FreeAssetGroup, image_only:
|
||||
if not group.thumbnail_url and fa.thumbnail_url:
|
||||
group.thumbnail_url = fa.thumbnail_url
|
||||
group.save(update_fields=["thumbnail_url", "updated_at"])
|
||||
return Response({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_201_CREATED)
|
||||
# 202 明确表示「已受理审核」而不是上传成功。只有轮询到 active 后,序列化结果
|
||||
# 才会含可用于生成的 URL。
|
||||
return Response({"group": _serialize_group(group), "asset": _serialize_asset(fa)}, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
class FreeAssetGroupListView(APIView):
|
||||
@@ -263,7 +268,7 @@ class FreeAssetUploadView(APIView):
|
||||
if group is None:
|
||||
return Response({"detail": "素材组不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
response = _upload_asset_to_group(request, team=team, group=group)
|
||||
if response.status_code == status.HTTP_201_CREATED:
|
||||
if response.status_code in (status.HTTP_201_CREATED, status.HTTP_202_ACCEPTED):
|
||||
response.data.pop("group", None)
|
||||
return response
|
||||
|
||||
|
||||
@@ -157,7 +157,10 @@ def reference_review_state(asset: Asset, *, require_registered: bool = False) ->
|
||||
火山素材 ID 并通过 asset:// 引用。普通直链会被生成接口按真人隐私素材直接拒绝。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
|
||||
# 平台自生成资产不依赖用户上传审核;用户上传则必须阻止,不能因为审核服务
|
||||
# 短暂不可用而把历史未审素材带进 Seedance。
|
||||
if asset.source == Asset.Source.UPLOAD:
|
||||
return "unavailable"
|
||||
return "allowed"
|
||||
if asset.review_status == "active" and (asset.review_remote_id or not require_registered):
|
||||
return "allowed"
|
||||
@@ -243,16 +246,22 @@ def ensure_review_drain_loop() -> None:
|
||||
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
|
||||
"""上传素材:立刻送审并等到终态。
|
||||
|
||||
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)。
|
||||
审核未配置时不能拦上传,直接当 allowed。
|
||||
返回 active / failed / processing(超时仍在审) / unavailable(审核未启用或送审失败)。
|
||||
|
||||
用户上传的素材不能因为审核服务暂时不可用而被当作已通过。调用方应把
|
||||
unavailable 当作上传未完成处理,既不返回可引用链接,也不允许进入生成链路。
|
||||
"""
|
||||
if not assets_client.is_enabled():
|
||||
return "allowed"
|
||||
submit_asset_for_review(asset, force=True)
|
||||
return "unavailable"
|
||||
submitted = submit_asset_for_review(asset, force=True)
|
||||
asset.refresh_from_db()
|
||||
status = asset.review_status or ""
|
||||
if status in ("active", "failed"):
|
||||
return status
|
||||
# CreateAsset 失败时没有 remote id 可以轮询;此前会空等到超时后把上传误报为
|
||||
# 「审核中」。直接把真实的不可用状态交给上传接口,让用户可以重试。
|
||||
if not submitted and not asset.review_remote_id:
|
||||
return "unavailable"
|
||||
deadline = time.monotonic() + max(3.0, float(timeout_s))
|
||||
while time.monotonic() < deadline:
|
||||
status = poll_asset_review(asset) or ""
|
||||
|
||||
@@ -45,9 +45,8 @@ class AssetFileSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
def get_preview_url(self, obj):
|
||||
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼 TOS 公读直链。
|
||||
# TOS 桶公读,直链(虚拟主机式)免签名、且稳定可缓存——不再逐图签发预签名 URL
|
||||
# (签名是纯本地计算但每次都变、1h 过期会打不到浏览器/CDN 缓存;直链一劳永逸)。
|
||||
# 存储字段优先(如外部已写入绝对 URL);否则用 object_key 拼公读直链。
|
||||
# 桶公读,直链不过期、可缓存。不再签发 1 小时就失效的预签名 URL。
|
||||
if obj.preview_url:
|
||||
return obj.preview_url
|
||||
if not obj.object_key or not settings.TOS.get("endpoint"):
|
||||
|
||||
@@ -19,13 +19,25 @@ class TosStorage:
|
||||
def __init__(self) -> None:
|
||||
tos = settings.TOS
|
||||
self.bucket = tos["bucket"]
|
||||
config_kwargs = {
|
||||
"signature_version": "s3v4",
|
||||
"s3": {"addressing_style": "virtual"},
|
||||
}
|
||||
try:
|
||||
client_config = Config(
|
||||
**config_kwargs,
|
||||
request_checksum_calculation="when_required",
|
||||
response_checksum_validation="when_required",
|
||||
)
|
||||
except TypeError:
|
||||
client_config = Config(**config_kwargs)
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=tos["endpoint"],
|
||||
aws_access_key_id=tos["access_key_id"],
|
||||
aws_secret_access_key=tos["secret_access_key"],
|
||||
region_name="cn-shanghai",
|
||||
config=Config(s3={"addressing_style": "virtual"}),
|
||||
region_name=tos.get("region") or "cn-shanghai",
|
||||
config=client_config,
|
||||
)
|
||||
|
||||
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
|
||||
@@ -57,6 +69,13 @@ class TosStorage:
|
||||
def public_url(self, *, object_key: str, bucket: str | None = None) -> str:
|
||||
"""桶公读时的虚拟主机式直链:https://{bucket}.{host}/{key}。
|
||||
相比预签名 URL:不签名(省 boto3 客户端/签名开销)、且 URL 稳定可被浏览器/CDN 缓存
|
||||
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。"""
|
||||
host = urlparse(settings.TOS["endpoint"]).netloc
|
||||
return f"https://{bucket or self.bucket}.{host}/{quote(object_key, safe='/')}"
|
||||
(签名链每次都变、1h 过期,反而打不到缓存)。仅用于公开可读的预览图。
|
||||
历史文件的 bucket 仍是火山引擎旧桶,必须用旧域名,不能拼到当前阿里云地址上。"""
|
||||
bucket_name = bucket or self.bucket
|
||||
endpoint = settings.TOS.get("endpoint") or ""
|
||||
legacy_bucket = settings.TOS.get("legacy_bucket") or ""
|
||||
legacy_endpoint = settings.TOS.get("legacy_endpoint") or ""
|
||||
if legacy_bucket and legacy_endpoint and bucket_name == legacy_bucket:
|
||||
endpoint = legacy_endpoint
|
||||
host = urlparse(endpoint).netloc
|
||||
return f"https://{bucket_name}.{host}/{quote(object_key, safe='/')}"
|
||||
|
||||
@@ -20,6 +20,74 @@ def _mk_team(username, team_name):
|
||||
return user, team
|
||||
|
||||
|
||||
class AssetUploadReviewGateTests(TestCase):
|
||||
"""通用本地上传必须审核通过,才暴露可引用的资产数据。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user, self.team = _mk_team("upload-review-user", "UploadReview")
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
self.stored = SimpleNamespace(
|
||||
object_key="teams/upload-review/uploads/photo.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
size_bytes=12,
|
||||
)
|
||||
|
||||
def _post(self):
|
||||
return self.client.post(
|
||||
"/api/assets/upload/",
|
||||
{
|
||||
"file": SimpleUploadedFile("photo.png", b"fake-image", content_type="image/png"),
|
||||
"asset_type": Asset.Type.IMAGE,
|
||||
"category": Asset.Category.UPLOAD,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
def test_returns_asset_and_links_only_after_review_is_active(self):
|
||||
def approve(asset, **_kwargs):
|
||||
asset.review_status = "active"
|
||||
asset.review_remote_id = "remote-approved"
|
||||
asset.save(update_fields=["review_status", "review_remote_id"])
|
||||
return "active"
|
||||
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=True
|
||||
), patch("apps.assets.views.wait_upload_review", side_effect=approve) as wait:
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.json()["review_status"], "active")
|
||||
self.assertIn("files", response.json())
|
||||
wait.assert_called_once()
|
||||
|
||||
def test_processing_review_does_not_return_asset_link(self):
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=True
|
||||
), patch("apps.assets.views.wait_upload_review", return_value="processing"):
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 408)
|
||||
self.assertEqual(response.json()["review_status"], "processing")
|
||||
self.assertNotIn("files", response.json())
|
||||
self.assertNotIn("url", response.json())
|
||||
|
||||
def test_unavailable_review_service_does_not_return_asset_link(self):
|
||||
with patch("apps.assets.views.TosStorage") as tos, patch(
|
||||
"apps.assets.views.assets_client.is_enabled", return_value=False
|
||||
), patch("apps.assets.views.wait_upload_review") as wait:
|
||||
tos.return_value.upload_fileobj.return_value = self.stored
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response.json()["review_status"], "unavailable")
|
||||
self.assertNotIn("files", response.json())
|
||||
wait.assert_not_called()
|
||||
|
||||
|
||||
class ModelLibraryApiTests(TestCase):
|
||||
"""模特库:本团队模特 ∪ 官方模板;官方跨团队可见;软删;官方不可删;团队隔离。"""
|
||||
|
||||
@@ -421,7 +489,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
storage.public_url.return_value = "http://tos/a.png"
|
||||
|
||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image()}, format="multipart")
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 202)
|
||||
group = FreeAssetGroup.objects.get(team=self.team)
|
||||
self.assertEqual(group.name, "默认素材")
|
||||
self.assertEqual(group.remote_group_id, "Group-1")
|
||||
@@ -429,6 +497,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
asset = group.assets.get()
|
||||
self.assertEqual(asset.asset_type, FreeAsset.Type.IMAGE)
|
||||
self.assertEqual(asset.status, FreeAsset.Status.PROCESSING)
|
||||
self.assertEqual(res.json()["asset"]["url"], "")
|
||||
self.assertEqual(res.json()["asset"]["thumb_url"], "")
|
||||
create_group.assert_called_once()
|
||||
create_asset.assert_called_once_with("Group-1", "http://tos/a.png", name="a.png", asset_type=FreeAsset.Type.IMAGE)
|
||||
self.assertEqual(res.json()["group"]["id"], str(group.id))
|
||||
@@ -445,8 +515,8 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
|
||||
first = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("a.png")}, format="multipart")
|
||||
second = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("b.png")}, format="multipart")
|
||||
self.assertEqual(first.status_code, 201)
|
||||
self.assertEqual(second.status_code, 201)
|
||||
self.assertEqual(first.status_code, 202)
|
||||
self.assertEqual(second.status_code, 202)
|
||||
self.assertEqual(FreeAssetGroup.objects.filter(team=self.team).count(), 1)
|
||||
self.assertEqual(FreeAsset.objects.filter(group__team=self.team).count(), 2)
|
||||
create_group.assert_called_once()
|
||||
@@ -475,7 +545,7 @@ class FreeAssetQuickUploadTests(TestCase):
|
||||
|
||||
res = self.client.post("/api/assets/free-assets/quick-upload/", {"file": self._image("new.png")}, format="multipart")
|
||||
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 202)
|
||||
self.assertFalse(FreeAsset.objects.filter(group=old_group).exists())
|
||||
new_group = FreeAssetGroup.objects.exclude(id=old_group.id).get(team=self.team)
|
||||
self.assertEqual(new_group.remote_group_id, "Group-2")
|
||||
|
||||
@@ -19,7 +19,9 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.common.api import TeamScopedViewSetMixin, get_current_team
|
||||
|
||||
from . import assets_client
|
||||
from .models import Asset, AssetFile, Model
|
||||
from .review import wait_upload_review
|
||||
from .serializers import AssetSerializer, AssetUploadSerializer, ModelLibrarySerializer
|
||||
from .storage import TosStorage
|
||||
|
||||
@@ -778,7 +780,6 @@ class ModelLibraryViewSet(ModelViewSet):
|
||||
class AssetUploadView(APIView):
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
serializer = AssetUploadSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -793,6 +794,9 @@ class AssetUploadView(APIView):
|
||||
object_key=object_key,
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
)
|
||||
# 先持久化,审核服务才能从 TOS 拉取文件;网络审核绝不能放在数据库事务里,
|
||||
# 否则几十秒的轮询会长期锁住请求事务。
|
||||
with transaction.atomic():
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id,
|
||||
team=team,
|
||||
@@ -811,4 +815,31 @@ class AssetUploadView(APIView):
|
||||
size_bytes=stored.size_bytes,
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
# 所有本地上传(图片/视频/音频)一律 force 送审。未通过、审核中或审核
|
||||
# 服务不可用时都不返回 AssetSerializer,避免提前泄露可供生成使用的链接。
|
||||
if not assets_client.is_enabled():
|
||||
return Response(
|
||||
{"detail": "素材审核服务暂不可用,请稍后重试", "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
review_status = wait_upload_review(asset)
|
||||
asset.refresh_from_db()
|
||||
if review_status == "active":
|
||||
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)
|
||||
if review_status == "failed":
|
||||
detail = (asset.review_error or "素材未通过审核,请更换后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "failed", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if review_status == "processing":
|
||||
return Response(
|
||||
{"detail": "素材仍在审核中,请稍后重试", "review_status": "processing", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_408_REQUEST_TIMEOUT,
|
||||
)
|
||||
detail = (asset.review_error or "素材审核服务暂不可用,请稍后重试").strip()
|
||||
return Response(
|
||||
{"detail": detail, "review_status": "unavailable", "asset_id": str(asset.id)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
@@ -25,6 +26,9 @@ from .models import Notification
|
||||
from .serializers import NotificationSerializer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def project_stage_label(project):
|
||||
return {
|
||||
"script": "Stage 1 · 脚本",
|
||||
@@ -288,7 +292,13 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if not Notification.objects.filter(team=team).exists():
|
||||
ensure_team_notifications(team, request.user)
|
||||
return
|
||||
if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60):
|
||||
# 通知列表是普通读接口;Redis 不可用时跳过后台刷新,不能因此返回 500。
|
||||
try:
|
||||
should_refresh = cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60)
|
||||
except Exception:
|
||||
logger.warning("通知刷新缓存不可用,跳过本次后台刷新", exc_info=True)
|
||||
should_refresh = False
|
||||
if should_refresh:
|
||||
from .tasks import ensure_team_notifications_task
|
||||
|
||||
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
||||
|
||||
@@ -6,6 +6,20 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="icon" href="/assets/yz/logo.png" />
|
||||
<title>影擎</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var key = "yingqing-appearance";
|
||||
var raw = localStorage.getItem(key) || "system";
|
||||
var theme = raw;
|
||||
if (raw !== "light" && raw !== "dark") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"be": "npm --prefix ../.. run be",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
|
||||
+54
-17
@@ -21,6 +21,7 @@ import type {
|
||||
UserPreference
|
||||
} from "./types";
|
||||
import { publicModelDisplayName } from "./model-display";
|
||||
import { applyTheme, normalizeAppearance, readStoredAppearance } from "./theme";
|
||||
import { generationErrorText } from "./generation-error";
|
||||
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
|
||||
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
|
||||
@@ -143,6 +144,22 @@ export function App() {
|
||||
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
|
||||
const exportingRef = useRef(false);
|
||||
const [preferences, setPreferences] = useState<UserPreference | null>(null);
|
||||
|
||||
// 首屏先用本地外观,后端 preferences 到达后再对齐;跟随系统时监听系统切换
|
||||
useEffect(() => {
|
||||
applyTheme(readStoredAppearance());
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => {
|
||||
if (readStoredAppearance() === "system") applyTheme("system");
|
||||
};
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const appearance = preferences?.display?.appearance;
|
||||
if (appearance) applyTheme(normalizeAppearance(appearance));
|
||||
}, [preferences?.display?.appearance]);
|
||||
|
||||
const [sessions, setSessions] = useState<LoginSession[]>([]);
|
||||
|
||||
const [activeProductId, setActiveProductId] = useState(route.productId || "");
|
||||
@@ -211,19 +228,25 @@ export function App() {
|
||||
}
|
||||
}, [loadData]);
|
||||
|
||||
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
|
||||
// 设置页数据:偏好 + 登录会话。settingsLoadSeq 挡住过期 GET 覆盖更新的 PUT 结果。
|
||||
const settingsLoadSeq = useRef(0);
|
||||
const loadSettingsData = useCallback(async () => {
|
||||
const seq = ++settingsLoadSeq.current;
|
||||
const [pref, sess] = await Promise.all([
|
||||
api.preferences().catch(() => null),
|
||||
api.loginSessions().catch(() => [])
|
||||
]);
|
||||
if (seq !== settingsLoadSeq.current) return;
|
||||
if (pref) setPreferences(pref);
|
||||
setSessions(sess);
|
||||
}, []);
|
||||
|
||||
async function savePreferences(payload: Partial<UserPreference>) {
|
||||
const next = await api.updatePreferences(payload).catch(() => null);
|
||||
if (next) setPreferences(next);
|
||||
// 失败必须抛出,不能吞成 null —— 否则 settings 会误以为已落盘并推进 baseline
|
||||
const next = await api.updatePreferences(payload);
|
||||
// 使在途的 preferences GET 失效,避免刚保存的外观被旧响应打回 system
|
||||
settingsLoadSeq.current += 1;
|
||||
setPreferences(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -399,16 +422,30 @@ export function App() {
|
||||
// Load preferences + sessions when entering settings.
|
||||
// 进创作相关页时重拉模型目录,避免后台刚改能力/积分,前端还捧着首屏缓存
|
||||
useEffect(() => {
|
||||
if (!authed || !dataLoaded) return;
|
||||
// 模型目录是创作页的必要数据,不能被商品/项目等全局数据的加载状态卡住。
|
||||
// 否则任一非关键列表短暂失败时,模型按钮仍显示默认名却展开空菜单。
|
||||
if (!authed) return;
|
||||
if (!["freeCreate", "omniCreate", "omniSession", "omniHistory", "quickCreate", "videoReplace", "pipeline"].includes(page)) return;
|
||||
refreshModelConfigs();
|
||||
}, [authed, page, dataLoaded, refreshModelConfigs]);
|
||||
}, [authed, page, refreshModelConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
|
||||
loadSettingsData();
|
||||
}, [authed, page, loadSettingsData]);
|
||||
|
||||
// 登录后拉一次偏好(外观等),不必等进设置页才生效;仍走 settingsLoadSeq,与设置页重载互斥
|
||||
useEffect(() => {
|
||||
if (!authed || !user) return;
|
||||
const seq = ++settingsLoadSeq.current;
|
||||
void api.preferences()
|
||||
.then((pref) => {
|
||||
if (seq !== settingsLoadSeq.current) return;
|
||||
if (pref) setPreferences(pref);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [authed, user]);
|
||||
|
||||
// YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮)
|
||||
useEffect(() => {
|
||||
if (!authed || !user) return;
|
||||
@@ -901,16 +938,15 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
navigate("omniCreate", { replace: true });
|
||||
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
|
||||
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
|
||||
try {
|
||||
await loadDataWithRetry();
|
||||
} catch (error) {
|
||||
console.error("[login] data hydrate failed:", error);
|
||||
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
|
||||
} finally {
|
||||
// 登录凭证已验证即可进入工作台。此前等待商品/项目等整套数据(包含重试)才
|
||||
// setAuthed,任一慢接口都会让登录页长时间卡在「正在进入工作台」;而刷新后的
|
||||
// boot 路径本来就是先展示外壳、后台补数据。首次登录也保持同一体验。
|
||||
setDataLoaded(false);
|
||||
setAuthed(true);
|
||||
}
|
||||
void loadDataWithRetry().catch((error) => {
|
||||
console.error("[login] data hydrate failed:", error);
|
||||
setNotice({ type: "error", text: "部分数据加载失败,页面会自动重试" });
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
@@ -1216,9 +1252,9 @@ export function App() {
|
||||
case "modelPhotoDemoB":
|
||||
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
|
||||
case "settings":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(type, text) => setNotice({ type, text })} onLogout={logout} />;
|
||||
case "settingsNotify":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(type, text) => setNotice({ type, text })} onLogout={logout} />;
|
||||
default:
|
||||
return <OmniCreatePage modelConfigs={modelConfigs} navigate={navigate} onNotify={(type, text) => setNotice({ type, text })} />;
|
||||
}
|
||||
@@ -1379,7 +1415,8 @@ export function App() {
|
||||
)}
|
||||
<span className="balance-chip" onClick={() => navigate("account")}>
|
||||
<IconKitSvg name="creditCard" />
|
||||
余额 <strong>{money(billing?.account.balance)}</strong>
|
||||
<span className="balance-chip-label">余额</span>
|
||||
<strong>{money(billing?.account.balance)}</strong>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
|
||||
<IconKitSvg name="bell" />
|
||||
|
||||
@@ -347,7 +347,7 @@
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
color: #0b1d4c;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: color .18s ease, background .18s ease, transform .18s ease;
|
||||
@@ -716,7 +716,7 @@
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -755,7 +755,7 @@
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bill-jump button:disabled {
|
||||
@@ -807,7 +807,7 @@
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 24px 70px rgba(18, 25, 38, 0.18);
|
||||
transform: translateY(10px) scale(.985);
|
||||
transition: transform .2s ease;
|
||||
@@ -840,7 +840,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.1);
|
||||
border-radius: 9px;
|
||||
color: #20242c;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.billing-contact-close:hover {
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
height: var(--topbar-height);
|
||||
min-height: var(--topbar-height);
|
||||
padding: 0 28px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||
}
|
||||
.admin-crumb {
|
||||
@@ -370,7 +370,7 @@
|
||||
|
||||
.admin-table-wrap { overflow-x: auto; }
|
||||
.admin-page table.t.admin-table {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-color: var(--st-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -626,7 +626,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--st-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -679,7 +679,7 @@
|
||||
.pt-foot .spacer { flex: 1; }
|
||||
|
||||
.admin-page .card-hard {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-color: var(--st-line);
|
||||
}
|
||||
.admin-page .empty-state {
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--af-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.asset-factory .af-search svg {
|
||||
width: 16px;
|
||||
@@ -229,7 +229,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -261,7 +261,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--af-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -280,7 +280,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -2746,7 +2746,7 @@
|
||||
place-items: center;
|
||||
border: 1px solid rgba(34, 42, 54, 0.18);
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.yz-image .product-choice.active .choice-check,
|
||||
.yz-image .model-choice.active .choice-check,
|
||||
@@ -2761,7 +2761,7 @@
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.yz-image .product-library-choice,
|
||||
.yz-image .upload-choice {
|
||||
@@ -2909,7 +2909,7 @@
|
||||
}
|
||||
.yz-image .image-prompt:focus {
|
||||
border-color: rgba(0, 47, 167, 0.48);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
|
||||
}
|
||||
.yz-image .model-line {
|
||||
@@ -3310,7 +3310,7 @@
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
}
|
||||
.yz-image .platform-choice .p-logo.has-img { background: #fff; }
|
||||
.yz-image .platform-choice .p-logo.has-img { background: var(--surface); }
|
||||
|
||||
.yz-image .model-pill {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -584,8 +584,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
aria-hidden="true"
|
||||
style={{ transform: `translateY(${liquid.y}px)` }}
|
||||
>
|
||||
<svg className="liquid-shape" viewBox="0 0 132 76" preserveAspectRatio="none">
|
||||
<path fill="#101012" d="M0 11H103C120 11 130 22 130 38C130 54 120 65 103 65H0Z" />
|
||||
<svg className="liquid-shape" viewBox="0 0 152 76" preserveAspectRatio="none">
|
||||
<path fill="currentColor" d="M0 11H123C140 11 150 22 150 38C150 54 140 65 123 65H0Z" />
|
||||
</svg>
|
||||
{activeMajor && (
|
||||
<div className="indicator-content">
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
// processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
||||
import { Check, CheckCircle2, ChevronLeft, CircleX, Clock3, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
||||
import { api } from "../../api";
|
||||
import { useFileDrop } from "../use-file-drop";
|
||||
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
||||
import { useBodyScrollLock } from "../overlays";
|
||||
|
||||
const STATUS_PILL: Record<FreeAssetItem["status"], { cls: string; label: string }> = {
|
||||
processing: { cls: "pill-info", label: "审核中" },
|
||||
active: { cls: "pill-ok", label: "可用" },
|
||||
failed: { cls: "pill-err", label: "未通过" }
|
||||
const STATUS_ICON: Record<FreeAssetItem["status"], { cls: string; label: string; Icon: typeof CheckCircle2 }> = {
|
||||
processing: { cls: "is-processing", label: "审核中,暂不可引用", Icon: Clock3 },
|
||||
active: { cls: "is-active", label: "审核通过,可被引用", Icon: CheckCircle2 },
|
||||
failed: { cls: "is-failed", label: "审核未通过,无法引用", Icon: CircleX }
|
||||
};
|
||||
|
||||
export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
@@ -97,8 +97,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [open, activeGroup, assets]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const createGroup = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || creatingGroup) return;
|
||||
@@ -124,7 +122,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
form.append("file", file);
|
||||
const data = await api.uploadFreeAsset(activeGroup.id, form);
|
||||
setAssets((prev) => [data.asset, ...prev]);
|
||||
notify("success", "素材已上传,审核中");
|
||||
notify("info", "素材已提交审核,通过后即可引用");
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||
} finally {
|
||||
@@ -151,7 +149,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
const data = await api.quickUploadFreeAsset(form);
|
||||
setActiveGroup(data.group);
|
||||
setAssets([data.asset]);
|
||||
notify("success", "图片已上传到默认素材组,审核中");
|
||||
notify("info", "图片已提交审核,通过后即可引用");
|
||||
void loadGroupDetail(data.group);
|
||||
} catch (error) {
|
||||
notify("error", error instanceof Error ? error.message : "上传失败");
|
||||
@@ -160,6 +158,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
}
|
||||
};
|
||||
|
||||
// 所有 Hook 必须在关闭态也保持同一调用顺序;不能在此之前提前 return。
|
||||
if (!open) return null;
|
||||
|
||||
const startRenameAsset = (item: FreeAssetItem) => {
|
||||
setConfirmDeleteAssetId(null);
|
||||
setConfirmDeleteGroupId(null);
|
||||
@@ -442,7 +443,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
{assets.map((item) => {
|
||||
const pill = STATUS_PILL[item.status];
|
||||
const statusIcon = STATUS_ICON[item.status];
|
||||
const StatusIcon = statusIcon.Icon;
|
||||
const isEditing = editingAssetId === item.id;
|
||||
const isDeleting = confirmDeleteAssetId === item.id;
|
||||
return (
|
||||
@@ -457,6 +459,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<div className="fc-lib-thumb">
|
||||
{item.thumb_url ? <img src={item.thumb_url} alt={item.name} /> : <span className="mono">{item.type === "audio" ? "♪" : item.type.toUpperCase()}</span>}
|
||||
{item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
|
||||
<span className={`fc-lib-status-icon ${statusIcon.cls}`} title={statusIcon.label} aria-label={statusIcon.label}>
|
||||
<StatusIcon size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="fc-lib-rename-row" onClick={(event) => event.stopPropagation()}>
|
||||
@@ -488,7 +493,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
) : (
|
||||
<div className="fc-lib-name" title={item.name}>{item.name}</div>
|
||||
)}
|
||||
<span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
||||
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>{item.error_message}</div>}
|
||||
<div className="fc-lib-ops">
|
||||
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
100% { background-position: 480px 0; }
|
||||
}
|
||||
.skeleton-block {
|
||||
background: var(--background-base, #f1f1f1);
|
||||
background-image: linear-gradient(90deg, rgba(0,0,0,0.03) 0%, rgba(0,0,0,0.07) 40%, rgba(0,0,0,0.03) 80%);
|
||||
background: var(--black-alpha-4);
|
||||
background-image: linear-gradient(90deg, transparent 0%, var(--black-alpha-8) 40%, transparent 80%);
|
||||
background-size: 480px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: sk-shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 0 1px var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,8 +153,228 @@
|
||||
--shadow-cta-active:
|
||||
0 1px 2px rgba(0, 47, 167, .18);
|
||||
--shadow-floating: 0 8px 28px rgba(17, 19, 24, .06);
|
||||
|
||||
/* 容器四角准星:用 mask 着色,让它跟随当前主题的边框层级。 */
|
||||
--corner-mark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21'%3E%3Cpath fill='%23000' d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Dark theme · 同一套语义 token 反色
|
||||
由 html[data-theme="dark"] 驱动(见 theme.ts / settings 外观)
|
||||
============================================================ */
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--background-base: #0f1115;
|
||||
--background-lighter: #14171c;
|
||||
--surface: #181b21;
|
||||
--surface-raised: #1e2229;
|
||||
|
||||
--border-faint: #2a2f38;
|
||||
--border-muted: #323843;
|
||||
--border-loud: #3c4450;
|
||||
|
||||
/* 主文字走近白;accent-white 保持真白(按钮/反色字) */
|
||||
--accent-black: #e8eaed;
|
||||
--accent-white: #ffffff;
|
||||
--nav-active: #e8eaed;
|
||||
|
||||
--forest-bg: rgba(66, 195, 102, .14);
|
||||
--forest-bd: rgba(66, 195, 102, .32);
|
||||
--crimson-bg: rgba(235, 52, 36, .14);
|
||||
--crimson-bd: rgba(235, 52, 36, .32);
|
||||
--honey-bg: rgba(236, 183, 48, .14);
|
||||
--honey-bd: rgba(236, 183, 48, .32);
|
||||
|
||||
/* 克莱因在深底上略提亮,避免发灰 */
|
||||
--klein: #3d6bff;
|
||||
--klein-hover: #5a82ff;
|
||||
--heat: var(--klein);
|
||||
--heat-hover: var(--klein-hover);
|
||||
|
||||
--black: var(--accent-black);
|
||||
--text: var(--accent-black);
|
||||
--heat-90: rgba(61, 107, 255, .90);
|
||||
--heat-40: rgba(61, 107, 255, .40);
|
||||
--heat-20: rgba(61, 107, 255, .20);
|
||||
--heat-16: rgba(61, 107, 255, .16);
|
||||
--heat-12: rgba(61, 107, 255, .14);
|
||||
--heat-8: rgba(61, 107, 255, .10);
|
||||
--heat-4: rgba(61, 107, 255, .06);
|
||||
|
||||
/* black-alpha → 白透明阶梯(同语义:轻微叠色) */
|
||||
--black-alpha-1: rgba(255, 255, 255, .015);
|
||||
--black-alpha-2: rgba(255, 255, 255, .025);
|
||||
--black-alpha-3: rgba(255, 255, 255, .035);
|
||||
--black-alpha-4: rgba(255, 255, 255, .05);
|
||||
--black-alpha-5: rgba(255, 255, 255, .06);
|
||||
--black-alpha-6: rgba(255, 255, 255, .07);
|
||||
--black-alpha-7: rgba(255, 255, 255, .08);
|
||||
--black-alpha-8: rgba(255, 255, 255, .09);
|
||||
--black-alpha-10: rgba(255, 255, 255, .11);
|
||||
--black-alpha-12: rgba(255, 255, 255, .13);
|
||||
--black-alpha-16: rgba(255, 255, 255, .16);
|
||||
--black-alpha-20: rgba(255, 255, 255, .20);
|
||||
--black-alpha-24: rgba(255, 255, 255, .24);
|
||||
--black-alpha-32: rgba(255, 255, 255, .32);
|
||||
--black-alpha-40: rgba(255, 255, 255, .40);
|
||||
--black-alpha-48: rgba(255, 255, 255, .48);
|
||||
--black-alpha-56: rgba(255, 255, 255, .56);
|
||||
--black-alpha-64: rgba(255, 255, 255, .64);
|
||||
--black-alpha-72: rgba(255, 255, 255, .72);
|
||||
--black-alpha-88: rgba(255, 255, 255, .88);
|
||||
|
||||
--shadow-cta:
|
||||
0 1px 2px rgba(61, 107, 255, .22),
|
||||
0 6px 16px rgba(61, 107, 255, .28);
|
||||
--shadow-cta-hover:
|
||||
0 2px 4px rgba(61, 107, 255, .26),
|
||||
0 10px 22px rgba(61, 107, 255, .34);
|
||||
--shadow-cta-active:
|
||||
0 1px 2px rgba(61, 107, 255, .22);
|
||||
--shadow-floating: 0 8px 28px rgba(0, 0, 0, .45);
|
||||
|
||||
/* styles.css legacy aliases */
|
||||
--bg: var(--background-base);
|
||||
--bg-soft: var(--background-lighter);
|
||||
--card: var(--surface);
|
||||
--border: var(--border-faint);
|
||||
--border-soft: var(--border-muted);
|
||||
--ink: var(--accent-black);
|
||||
--ink-2: rgba(232, 234, 237, .72);
|
||||
--ink-3: rgba(232, 234, 237, .52);
|
||||
--ink-4: rgba(232, 234, 237, .32);
|
||||
--orange: var(--klein);
|
||||
--orange-soft: var(--heat-40);
|
||||
--orange-tint: var(--heat-8);
|
||||
--orange-hover: var(--klein-hover);
|
||||
--green: #6dd88a;
|
||||
--green-bg: var(--forest-bg);
|
||||
--green-bd: var(--forest-bd);
|
||||
--red: #ff6b5c;
|
||||
--red-bg: var(--crimson-bg);
|
||||
--red-bd: var(--crimson-bd);
|
||||
}
|
||||
|
||||
/* 壳层硬编码浅色 → dark 对齐 */
|
||||
html[data-theme="dark"] aside.sidebar {
|
||||
/* 盖过 aside.sidebar 本地 --klein:#002fa7,否则子菜单 active 仍是深蓝 */
|
||||
--klein: #3d6bff;
|
||||
--klein-hover: #5a82ff;
|
||||
--st-black: #e8eaed;
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-submenu {
|
||||
border-left-color: rgba(61, 107, 255, 0.32);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem {
|
||||
color: rgba(232, 234, 237, 0.78);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem:hover {
|
||||
color: #a8bbff;
|
||||
background: rgba(61, 107, 255, 0.14);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem.active {
|
||||
color: #c5d2ff;
|
||||
background: rgba(61, 107, 255, 0.2);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem.active::before {
|
||||
box-shadow: 0 0 0 4px rgba(61, 107, 255, 0.22);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-subitem .pill-mini {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #a8bbff;
|
||||
}
|
||||
html[data-theme="dark"] .sidebar-head {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
html[data-theme="dark"] .brand-logo {
|
||||
mix-blend-mode: normal;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.92;
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item {
|
||||
color: rgba(232, 234, 237, 0.78);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item .nav-desc,
|
||||
html[data-theme="dark"] aside.sidebar .nav-item .nav-sub {
|
||||
color: rgba(232, 234, 237, 0.42);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item.active,
|
||||
html[data-theme="dark"] aside.sidebar .nav-item[aria-current="page"] {
|
||||
color: #e8eaed;
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .org-switcher,
|
||||
html[data-theme="dark"] aside.sidebar .org-switcher span {
|
||||
color: rgba(232, 234, 237, 0.78);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .org-switcher svg {
|
||||
color: rgba(232, 234, 237, 0.48);
|
||||
}
|
||||
html[data-theme="dark"] .user .av {
|
||||
background: #3a4050;
|
||||
color: #fff;
|
||||
}
|
||||
html[data-theme="dark"] .grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||
}
|
||||
html[data-theme="dark"] .topbar {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
html[data-theme="dark"] .top-search {
|
||||
border-color: #3a4050;
|
||||
color: #e8eaed;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
html[data-theme="dark"] .top-search:hover {
|
||||
border-color: #4a5160;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
html[data-theme="dark"] .queue-chip,
|
||||
html[data-theme="dark"] .balance-chip {
|
||||
border-color: var(--border-faint);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] aside.sidebar .nav-item .nav-chevron {
|
||||
color: rgba(232, 234, 237, 0.42);
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-item:hover {
|
||||
color: #e8eaed;
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .liquid-shape {
|
||||
/* 深色下不用纯黑胶囊,改用品牌蓝选中态 */
|
||||
color: #3d6bff;
|
||||
filter: drop-shadow(0 5px 10px rgba(61, 107, 255, 0.28));
|
||||
}
|
||||
html[data-theme="dark"] aside.sidebar .nav-liquid,
|
||||
html[data-theme="dark"] aside.sidebar .nav-capsule {
|
||||
background: rgba(61, 107, 255, 0.12);
|
||||
box-shadow: 0 0 0 4px rgba(61, 107, 255, 0.12);
|
||||
}
|
||||
html[data-theme="dark"] .main,
|
||||
html[data-theme="dark"] .content,
|
||||
html[data-theme="dark"] .page {
|
||||
background: var(--background-base);
|
||||
color: var(--accent-black);
|
||||
}
|
||||
html[data-theme="dark"] .icon-btn {
|
||||
background: var(--surface);
|
||||
border-color: var(--border-faint);
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
html[data-theme="dark"] .icon-btn:hover {
|
||||
background: var(--background-lighter);
|
||||
color: var(--accent-black);
|
||||
border-color: var(--border-loud);
|
||||
}
|
||||
|
||||
|
||||
::selection { background: var(--heat-20); color: var(--heat); }
|
||||
|
||||
html, body {
|
||||
@@ -268,7 +488,7 @@ aside.sidebar {
|
||||
min-height: var(--topbar-height);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.07);
|
||||
}
|
||||
.brand { display: flex; align-items: center; justify-content: center; min-width: 0; color: var(--accent-black); }
|
||||
@@ -607,7 +827,7 @@ aside.sidebar .nav-subitem .pill-mini {
|
||||
padding: 0 5px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--klein);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -619,7 +839,7 @@ aside.sidebar .liquid-indicator {
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 132px;
|
||||
width: 152px;
|
||||
height: 76px;
|
||||
pointer-events: none;
|
||||
transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -635,6 +855,7 @@ aside.sidebar .liquid-shape {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
color: #101012;
|
||||
filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.14));
|
||||
transform-origin: left center;
|
||||
}
|
||||
@@ -651,7 +872,7 @@ aside.sidebar .indicator-content {
|
||||
top: 0;
|
||||
left: 33px;
|
||||
height: 76px;
|
||||
width: 94px;
|
||||
width: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
@@ -776,7 +997,7 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: clip;
|
||||
@@ -827,7 +1048,7 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 0 clamp(36px, 3.2vw, 68px);
|
||||
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 4px 16px rgba(20, 27, 38, 0.035);
|
||||
position: sticky; top: 0; z-index: var(--z-topbar);
|
||||
align-self: start;
|
||||
@@ -1168,8 +1389,11 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
.with-corners .corner-tr, .with-corners .corner-bl {
|
||||
content: ''; position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
background-color: var(--border-muted);
|
||||
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||
mask: var(--corner-mark) center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-hard.with-corners::before { top: -7px; left: -7px; }
|
||||
@@ -2269,6 +2493,11 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
color: var(--black-alpha-56);
|
||||
font-weight: 500;
|
||||
}
|
||||
html[data-theme="dark"] .placeholder .ph-frame {
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--border-faint);
|
||||
color: var(--black-alpha-56);
|
||||
}
|
||||
.placeholder.has-mock-media {
|
||||
background-color: var(--background-lighter);
|
||||
background-image: var(--mock-media-url);
|
||||
@@ -2599,6 +2828,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
margin: 6px 4px;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Modal ─── */
|
||||
.modal-bg {
|
||||
position: fixed; inset: 0;
|
||||
@@ -2632,24 +2862,27 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
background-color: var(--border-muted);
|
||||
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||
mask: var(--corner-mark) center / contain no-repeat;
|
||||
pointer-events: none;
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px; line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
.modal::before { top: -7px; left: -7px; }
|
||||
.modal::after { bottom: -7px; right: -7px; }
|
||||
.modal::before { top: 0; left: 0; }
|
||||
.modal::after { bottom: 0; right: 0; }
|
||||
.modal .corner-tr, .modal .corner-bl {
|
||||
position: absolute;
|
||||
width: 14px; height: 14px;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
|
||||
background-size: contain;
|
||||
background-color: var(--border-muted);
|
||||
-webkit-mask: var(--corner-mark) center / contain no-repeat;
|
||||
mask: var(--corner-mark) center / contain no-repeat;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.modal .corner-tr { top: -7px; right: -7px; }
|
||||
.modal .corner-bl { bottom: -7px; left: -7px; }
|
||||
.modal .corner-tr { top: 0; right: 0; }
|
||||
.modal .corner-bl { bottom: 0; left: 0; }
|
||||
.modal-h {
|
||||
padding: 22px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-faint);
|
||||
@@ -2716,6 +2949,12 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
font-family: inherit;
|
||||
}
|
||||
.card-del-btn svg { width: 14px; height: 14px; }
|
||||
html[data-theme="dark"] .card-del-btn {
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--border-faint);
|
||||
color: var(--black-alpha-56);
|
||||
box-shadow: none;
|
||||
}
|
||||
.product-card:hover .card-del-btn,
|
||||
.asset-card:hover .card-del-btn,
|
||||
.project-card:hover .card-del-btn,
|
||||
@@ -2804,10 +3043,18 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
z-index: 1200;
|
||||
/* 手机上的侧栏是抽屉,必须遮住页面内容,不能沿用桌面端的半透明背景。 */
|
||||
background: var(--surface-raised);
|
||||
border-right-color: var(--border-faint);
|
||||
transform: translateX(-100%);
|
||||
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, .18);
|
||||
}
|
||||
/* 暗色桌面侧栏可保留轻透层;窄屏抽屉必须是实底,避免透出页面内容。 */
|
||||
html[data-theme="dark"] aside.sidebar {
|
||||
background: var(--surface-raised);
|
||||
border-right-color: var(--border-faint);
|
||||
}
|
||||
/* 抽屉里收窄态不适用,强制展开宽度,避免 body.sidebar-collapsed 把抽屉压成 96px */
|
||||
body.sidebar-collapsed aside.sidebar { width: var(--sidebar-width); }
|
||||
aside.sidebar.mobile-open { transform: translateX(0); }
|
||||
@@ -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-backdrop { position: fixed; inset: 0; z-index: 1150; background: rgba(21, 20, 15, .42); }
|
||||
/* 顶栏给汉堡键让出左侧空间 */
|
||||
.topbar { padding-left: 64px; }
|
||||
/* 顶栏给汉堡键让出左侧空间;搜索与余额压缩为单行工具,避免中文在窄窗逐字折行。 */
|
||||
.topbar { padding: 0 16px 0 64px; gap: 8px; }
|
||||
.topbar .right { gap: 8px; flex: 0 0 auto; }
|
||||
.top-search {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.top-search span { display: none; }
|
||||
.balance-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance-chip-label { display: none; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.stat:nth-child(2) { border-right: 0; }
|
||||
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
|
||||
@@ -2899,7 +3160,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
left: 0;
|
||||
width: 252px;
|
||||
padding: 11px 13px 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: var(--r-md);
|
||||
@@ -2921,7 +3182,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
|
||||
position: absolute;
|
||||
top: -5px; left: 14px;
|
||||
width: 10px; height: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-top: 1px solid rgba(34, 42, 54, 0.10);
|
||||
transform: rotate(45deg);
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -598,7 +598,7 @@
|
||||
max-width: 280px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
@@ -708,7 +708,7 @@
|
||||
left: 0;
|
||||
z-index: 60;
|
||||
min-width: 180px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
@@ -936,6 +936,23 @@
|
||||
.fc-lib-rename-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
|
||||
.fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.fc-lib-status-icon {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
color: var(--black-alpha-56);
|
||||
}
|
||||
.fc-lib-status-icon.is-active { background: var(--forest-bg); color: var(--accent-forest); border-color: var(--forest-bd); }
|
||||
.fc-lib-status-icon.is-processing { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
|
||||
.fc-lib-status-icon.is-failed { background: var(--crimson-bg); color: var(--accent-crimson); border-color: var(--crimson-bd); }
|
||||
.fc-lib-status {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
@@ -966,7 +983,7 @@
|
||||
height: 7px;
|
||||
background: currentColor;
|
||||
}
|
||||
.fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
||||
.fc-lib-ops { position: absolute; top: 14px; left: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; }
|
||||
.fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; }
|
||||
.fc-lib-ops button {
|
||||
width: 22px;
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--lib-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.library-page .lib-search svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--lib-muted); }
|
||||
.library-page .lib-search input {
|
||||
@@ -152,7 +152,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -175,7 +175,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--lib-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -197,7 +197,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyTheme, readStoredAppearance } from "./theme";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
@@ -22,5 +23,8 @@ import "./quick-create-page.css";
|
||||
import "./admin-page.css";
|
||||
import "./omni-create-page.css";
|
||||
import "./omni-session-page.css";
|
||||
import "./dark-mode-pages.css";
|
||||
|
||||
applyTheme(readStoredAppearance());
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--st-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -178,7 +178,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--st-muted);
|
||||
}
|
||||
.msg-search input {
|
||||
@@ -196,6 +196,8 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.msg-load-more {
|
||||
padding: 14px 16px 18px;
|
||||
@@ -274,6 +276,7 @@
|
||||
}
|
||||
|
||||
.msg-empty {
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.models-page .ml-check svg { width: 13px; height: 13px; }
|
||||
.models-page.manage-mode .ml-card:not(.selectable) { cursor: not-allowed; }
|
||||
.models-page.manage-mode .ml-card.selectable .ml-check { display: grid; }
|
||||
.models-page.manage-mode .ml-card.selected .ml-check {
|
||||
color: #fff;
|
||||
@@ -383,7 +384,7 @@
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: #414750;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -36,24 +36,31 @@
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
padding: 0 14px;
|
||||
border: 1.5px solid rgba(16, 16, 18, .22);
|
||||
border-radius: 12px;
|
||||
color: var(--black);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
box-shadow: 0 4px 12px rgba(16, 16, 18, .08);
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-home-history:hover {
|
||||
border-color: var(--black);
|
||||
background: #f7f7f8;
|
||||
border-color: var(--black-alpha-24);
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-home-history:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-home-history svg {
|
||||
@@ -378,7 +385,7 @@
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -447,7 +454,7 @@
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -582,7 +589,7 @@
|
||||
.omni-parameter .custom-select-trigger:hover,
|
||||
.omni-parameter .custom-select-shell.open .custom-select-trigger {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-parameter .custom-select-trigger svg {
|
||||
@@ -640,7 +647,7 @@
|
||||
.omni-duration-trigger:hover,
|
||||
.omni-duration-trigger[aria-expanded="true"] {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .07);
|
||||
}
|
||||
|
||||
@@ -664,7 +671,7 @@
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 18px 38px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -738,7 +745,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #575e69;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
@@ -995,15 +1002,15 @@
|
||||
.omni-preset-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1040px, 100%);
|
||||
width: min(1180px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: 440px minmax(0, 1fr);
|
||||
gap: 42px;
|
||||
padding: 30px;
|
||||
grid-template-columns: 500px minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-top: 3px solid var(--klein);
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 28px 70px rgba(10, 17, 30, .22);
|
||||
animation: omniMessageIn 180ms ease both;
|
||||
}
|
||||
@@ -1139,7 +1146,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 10px;
|
||||
color: #616873;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1231,7 +1238,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 10px;
|
||||
color: #414750;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1307,35 +1314,34 @@
|
||||
.omni-history-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.omni-history-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr) 70px;
|
||||
grid-template-columns: 150px minmax(0, 1fr) 36px;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 17px;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .09);
|
||||
border-radius: 19px;
|
||||
border-radius: var(--r-md);
|
||||
background: rgba(255, 255, 255, .96);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 22px rgba(20, 27, 38, .05);
|
||||
transition: transform 170ms ease, border-color 170ms ease, box-shadow 170ms ease;
|
||||
box-shadow: none;
|
||||
transition: border-color 170ms ease, background-color 170ms ease;
|
||||
}
|
||||
|
||||
.omni-history-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 47, 167, .30);
|
||||
box-shadow: 0 13px 26px rgba(20, 27, 38, .09);
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
|
||||
.omni-history-item > img {
|
||||
width: 150px;
|
||||
height: 112px;
|
||||
display: block;
|
||||
border-radius: 13px;
|
||||
border-radius: var(--r-md);
|
||||
object-fit: cover;
|
||||
background: #edf0f4;
|
||||
}
|
||||
@@ -1354,21 +1360,24 @@
|
||||
}
|
||||
|
||||
.omni-history-item h2 {
|
||||
margin: 8px 0 7px;
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
margin: 6px 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.omni-history-item p,
|
||||
.omni-history-item small {
|
||||
color: #858b94;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.omni-history-item small {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
margin-top: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.omni-history-actions {
|
||||
@@ -1389,14 +1398,16 @@
|
||||
}
|
||||
|
||||
.omni-history-actions button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: #858b94;
|
||||
background: #f4f5f7;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.omni-history-actions button:hover {
|
||||
@@ -1404,6 +1415,25 @@
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.omni-history-actions button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-history-item:hover .omni-history-actions button,
|
||||
.omni-history-item:focus-within .omni-history-actions button,
|
||||
.omni-history-actions button:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.omni-history-item:hover .omni-history-actions button {
|
||||
border-color: rgba(34, 42, 54, .09);
|
||||
background: #f4f5f7;
|
||||
}
|
||||
}
|
||||
|
||||
.omni-delete-confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1432,7 +1462,7 @@
|
||||
padding: 26px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
box-shadow: 0 26px 64px rgba(10, 17, 30, .22);
|
||||
animation: omniMessageIn 180ms ease both;
|
||||
@@ -1476,7 +1506,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 9px;
|
||||
color: #5e6570;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -1547,7 +1577,7 @@
|
||||
.omni-parameter .rs-select-btn:hover,
|
||||
.omni-parameter .rs-select.open .rs-select-btn {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-parameter .rs-select-btn svg {
|
||||
|
||||
@@ -71,12 +71,25 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 10px;
|
||||
color: #414750;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-faint);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-56);
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--t-base), border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
|
||||
.omni-session-back:hover {
|
||||
border-color: var(--black-alpha-24);
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-session-back:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40);
|
||||
}
|
||||
|
||||
.omni-session-back svg {
|
||||
@@ -375,7 +388,7 @@
|
||||
margin: 0 0 24px 44px;
|
||||
border: 1px solid rgba(34, 42, 54, .09);
|
||||
border-radius: 15px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
|
||||
animation: omniMessageIn 220ms ease both;
|
||||
overflow: hidden;
|
||||
@@ -407,7 +420,7 @@
|
||||
margin: 0 0 24px 44px;
|
||||
border: 1px solid rgba(34, 42, 54, .09);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
||||
animation: omniMessageIn 220ms ease both;
|
||||
}
|
||||
@@ -594,7 +607,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .08);
|
||||
border-radius: 10px;
|
||||
color: #5b626d;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
@@ -689,7 +702,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 9px;
|
||||
color: #333944;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -712,7 +725,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, .20);
|
||||
border-radius: 9px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -814,7 +827,7 @@
|
||||
.omni-session-preset-trigger[aria-expanded="true"] {
|
||||
border-color: rgba(0, 47, 167, .36);
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-session-preset-trigger span {
|
||||
@@ -837,7 +850,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 17px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 24px 60px rgba(20, 27, 38, .18);
|
||||
transform: translateX(-52%);
|
||||
}
|
||||
@@ -1035,7 +1048,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 16px 36px rgba(20, 27, 38, .15);
|
||||
}
|
||||
|
||||
@@ -1707,7 +1720,7 @@
|
||||
padding: 7px 13px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: #575d66;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@@ -1730,7 +1743,7 @@
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--black);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
@@ -1754,7 +1767,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -1844,7 +1857,7 @@
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
|
||||
/* 不走入场 opacity 动画:poll 重渲染时避免「闪一下」 */
|
||||
animation: none;
|
||||
@@ -1905,7 +1918,7 @@
|
||||
}
|
||||
|
||||
.omni-step-confirm-actions button.is-secondary {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.14);
|
||||
color: #222a36;
|
||||
}
|
||||
@@ -1946,7 +1959,7 @@
|
||||
|
||||
.omni-step-confirm-revise-input:focus {
|
||||
border-color: rgba(0, 47, 167, .42);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, .14);
|
||||
}
|
||||
|
||||
@@ -1973,7 +1986,7 @@
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
/* 时长/参数下拉要冒出卡片,不能被裁 */
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -2604,7 +2617,7 @@
|
||||
gap: 10px;
|
||||
padding: 14px 14px 12px;
|
||||
border-bottom: 1px solid rgba(34, 42, 54, .08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-prompt-drawer-file {
|
||||
@@ -2684,7 +2697,7 @@
|
||||
padding: 14px 15px;
|
||||
border: 1px solid rgba(34, 42, 54, .06);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 6px 16px rgba(20, 27, 38, .04);
|
||||
}
|
||||
|
||||
@@ -2756,7 +2769,7 @@
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.omni-prompt-table {
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pipeline-page .pl-rail-summary > span { color: var(--pl-muted); font-size: 10px; }
|
||||
.pipeline-page .pl-rail-track {
|
||||
@@ -139,7 +139,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.085);
|
||||
border-radius: 12px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
@@ -156,7 +156,7 @@
|
||||
}
|
||||
.pipeline-page .pl-step.static:hover {
|
||||
border-color: rgba(34, 42, 54, 0.085);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pipeline-page .pl-step.static:hover .pl-step-chev { color: var(--pl-muted); }
|
||||
.pipeline-page .pl-step.current {
|
||||
@@ -250,7 +250,7 @@
|
||||
.pipeline-page .pl-ghost {
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pipeline-page .pl-next {
|
||||
min-width: 210px;
|
||||
@@ -328,7 +328,7 @@
|
||||
border-radius: 8px;
|
||||
}
|
||||
.chat-msg.ai .chat-bubble {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||
border-radius: 8px 8px 8px 2px;
|
||||
}
|
||||
@@ -465,7 +465,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 11px 26px rgba(20, 27, 38, 0.07);
|
||||
}
|
||||
.shot-head {
|
||||
@@ -504,7 +504,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||
border-radius: 8px;
|
||||
color: var(--pl-muted);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.shot-icon-btn svg { width: 13px; height: 13px; }
|
||||
@@ -542,7 +542,7 @@
|
||||
.shot-area:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 47, 167, 0.38);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
|
||||
}
|
||||
.shot-dialogue { min-height: 42px; }
|
||||
@@ -564,7 +564,7 @@
|
||||
.icon-mini-btn.armed { width: auto; padding: 0 8px; font-size: 12px; color: var(--accent-crimson); border-color: var(--accent-crimson); background: var(--surface); }
|
||||
.shot-insert-gap { flex: 0 0 auto; height: 10px; position: relative; display: flex; align-items: center; justify-content: center; padding: 0; transition: height .24s cubic-bezier(.18,.72,.28,1), padding .24s cubic-bezier(.18,.72,.28,1); }
|
||||
.shot-insert-gap:hover { height: 72px; padding: 14px 0; }
|
||||
.shot-insert-gap .add-shot-btn { opacity: 0; transform: translateY(4px) scale(.96); height: 28px; padding: 0 14px; background: #fff; color: var(--klein); border: 1px dashed rgba(0, 47, 167, 0.35); border-radius: 8px; font-size: 13px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base); display: inline-flex; align-items: center; gap: 6px; pointer-events: none; white-space: nowrap; }
|
||||
.shot-insert-gap .add-shot-btn { opacity: 0; transform: translateY(4px) scale(.96); height: 28px; padding: 0 14px; background: var(--surface); color: var(--klein); border: 1px dashed rgba(0, 47, 167, 0.35); border-radius: 8px; font-size: 13px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base); display: inline-flex; align-items: center; gap: 6px; pointer-events: none; white-space: nowrap; }
|
||||
.shot-insert-gap .add-shot-btn svg { width: 12px; height: 12px; }
|
||||
.shot-insert-gap:hover .add-shot-btn { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
|
||||
.shot-insert-gap .add-shot-btn:hover { background: rgba(0, 47, 167, 0.06); border-style: solid; border-color: var(--klein); }
|
||||
@@ -613,7 +613,7 @@
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
@@ -638,7 +638,7 @@
|
||||
display: grid;
|
||||
grid-template-rows: 72px minmax(0, 1fr) 74px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.as-head {
|
||||
display: flex;
|
||||
@@ -677,7 +677,7 @@
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.075);
|
||||
}
|
||||
.as-row::before {
|
||||
@@ -755,7 +755,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 7px 18px rgba(20, 27, 38, 0.06);
|
||||
}
|
||||
.as-preview.ready {
|
||||
@@ -782,7 +782,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
background-color: var(--surface);
|
||||
background-image:
|
||||
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
|
||||
radial-gradient(ellipse 1px 4px at center, rgba(0, 47, 167, 0.28) 98%, transparent);
|
||||
@@ -807,7 +807,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.26);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -911,7 +911,7 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.085);
|
||||
}
|
||||
.as-gen-preview {
|
||||
@@ -923,7 +923,7 @@
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.as-gen-preview.ready {
|
||||
background-image: var(--mock-media-url);
|
||||
@@ -1001,7 +1001,7 @@
|
||||
}
|
||||
.as-gen-prompt:focus {
|
||||
border-color: rgba(0, 47, 167, 0.38);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.06);
|
||||
}
|
||||
.as-gen-actions {
|
||||
@@ -1088,7 +1088,7 @@
|
||||
.as-ai-btn svg, .as-ghost-btn svg { width: 14px; height: 14px; }
|
||||
.as-ai-btn:disabled, .as-ghost-btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
|
||||
.as-ai-btn-lg { min-height: 44px; margin-top: 18px; padding: 0 18px; font-size: 14px; }
|
||||
.as-ghost-btn { border: 1px solid rgba(0, 47, 167, 0.19); color: var(--klein); background: #fff; }
|
||||
.as-ghost-btn { border: 1px solid rgba(0, 47, 167, 0.19); color: var(--klein); background: var(--surface); }
|
||||
.as-ghost-btn:hover { transform: translateY(-1px); border-color: rgba(0, 47, 167, 0.38); background: #eef5ff; }
|
||||
.as-ghost-btn-sm { min-height: 30px; font-size: 12px; }
|
||||
@media (max-width: 1180px) {
|
||||
@@ -1264,14 +1264,14 @@
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 280px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.video-thumb.pending::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
background-color: var(--surface);
|
||||
background-image:
|
||||
radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent),
|
||||
radial-gradient(ellipse 1px 4px at center, rgba(0, 47, 167, 0.28) 98%, transparent);
|
||||
@@ -1296,7 +1296,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.26);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: -28px 0 70px rgba(10, 15, 24, 0.22);
|
||||
transform: translateX(100%);
|
||||
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -422,7 +422,7 @@
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -445,7 +445,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -635,7 +635,7 @@
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--accent-black);
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
@@ -766,7 +766,7 @@
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.12);
|
||||
}
|
||||
.pcd-toast-ic {
|
||||
@@ -816,7 +816,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 11px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -845,7 +845,7 @@
|
||||
background: rgba(255, 255, 255, 0.90);
|
||||
box-shadow: 0 10px 28px rgba(20, 27, 38, 0.07);
|
||||
}
|
||||
.pcd-gallery-slot img { width: 100%; height: 100%; display: block; object-fit: contain; background: #fff; }
|
||||
.pcd-gallery-slot img { width: 100%; height: 100%; display: block; object-fit: contain; background: var(--surface); }
|
||||
.pcd-gallery-slot.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--pl-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.pl-search svg {
|
||||
width: 16px;
|
||||
@@ -148,7 +148,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -181,7 +181,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--pl-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -203,7 +203,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -316,6 +316,35 @@
|
||||
border-color: var(--klein);
|
||||
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--pl-shadow);
|
||||
}
|
||||
.pl-skeleton-card {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
.pl-skeleton-card:hover { transform: none; }
|
||||
.pl-skeleton-thumb {
|
||||
aspect-ratio: 4 / 3;
|
||||
margin: 8px 8px 0;
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.pl-skeleton-meta {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.pl-skeleton-line { display: block; height: 12px; }
|
||||
.pl-skeleton-line--title { width: 52%; }
|
||||
.pl-skeleton-line--copy { width: 76%; height: 10px; }
|
||||
.pl-skeleton-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.pl-skeleton-pill {
|
||||
display: block;
|
||||
width: 58px;
|
||||
height: 22px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.pl-skeleton-pill--short { width: 46px; }
|
||||
|
||||
.pl-thumb {
|
||||
position: relative;
|
||||
@@ -323,7 +352,8 @@
|
||||
overflow: hidden;
|
||||
background: #e6e7ea;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-radius: 10px;
|
||||
margin: 8px 8px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.pl-thumb img {
|
||||
@@ -331,6 +361,7 @@
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.pl-thumb.has-mock-media {
|
||||
background-size: cover;
|
||||
@@ -339,7 +370,7 @@
|
||||
}
|
||||
.pl-thumb.placeholder {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.pl-thumb .ph-frame { display: none; }
|
||||
|
||||
@@ -421,9 +452,10 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
.pl-grid.list .pl-thumb {
|
||||
width: 148px;
|
||||
width: 132px;
|
||||
height: 100px;
|
||||
aspect-ratio: auto;
|
||||
margin: 8px;
|
||||
}
|
||||
.pl-grid.list .pl-meta {
|
||||
display: flex;
|
||||
@@ -489,7 +521,15 @@
|
||||
border-color: var(--klein, #002fa7);
|
||||
background: var(--klein, #002fa7);
|
||||
}
|
||||
.products-page.manage-mode .pl-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
|
||||
/* 删除是管理动作:普通浏览态不因 hover 露出,进入管理态才显示。 */
|
||||
.products-page:not(.manage-mode) .product-card:hover .card-del-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.products-page.manage-mode .pl-card .card-del-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.products-page.manage-mode .pl-card button.pl-tag { pointer-events: none; }
|
||||
|
||||
/* 影擎吸底选择条 · 管理模式即出现 */
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.project-wizard-page .nw-rail-summary > span { color: var(--nw-muted); font-size: 10px; }
|
||||
.project-wizard-page .nw-rail-track {
|
||||
@@ -148,7 +148,7 @@
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.085);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.project-wizard-page .nw-step[hidden] {
|
||||
display: none;
|
||||
@@ -270,7 +270,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.project-wizard-page .nw-search svg { width: 16px; height: 16px; color: var(--nw-muted); }
|
||||
.project-wizard-page .nw-search input {
|
||||
@@ -297,7 +297,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.15);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -317,7 +317,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -334,7 +334,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
@@ -513,7 +513,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--accent-black);
|
||||
@@ -549,7 +549,7 @@
|
||||
border: 1px solid var(--nw-line);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--vc-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
.projects-page .vc-search svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--vc-muted); }
|
||||
.projects-page .vc-search input {
|
||||
@@ -220,7 +220,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
@@ -243,7 +243,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid var(--vc-line);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -262,7 +262,7 @@
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--accent-black);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
.quick-create-page .quick-field-label { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: 14px; font-weight: 700; }
|
||||
.quick-create-page .quick-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; }
|
||||
.quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: #fff; box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: var(--surface); box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); }
|
||||
.quick-create-page .quick-upload input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; border: 0; color: inherit; background: transparent; font: inherit; text-align: center; cursor: pointer; }
|
||||
@@ -52,7 +52,7 @@
|
||||
.quick-create-page .quick-image-tile button { position: absolute; top: 3px; right: 3px; width: 19px; height: 19px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; color: #fff; background: rgba(25,28,34,.72); cursor: pointer; }.quick-create-page .quick-image-tile button svg { width: 12px; height: 12px; }
|
||||
.quick-create-page .quick-upload-more { min-height: 240px; display: grid; place-items: center; align-content: center; gap: 8px; border: 1px dashed rgba(0,47,167,.35); border-radius: 13px; background: rgba(243,247,255,.78); }
|
||||
.quick-create-page .quick-upload-trigger { display: grid; justify-items: center; gap: 8px; padding: 0; border: 0; color: #303746; background: transparent; font: inherit; cursor: pointer; }.quick-create-page .quick-upload-trigger strong { font-size: 14px; font-weight: 700; }.quick-create-page .quick-upload-trigger small { color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: #fff; box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-upload-more-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 12px; color: var(--quick-blue); background: var(--surface); box-shadow: 0 5px 16px rgba(0,47,167,.08); }.quick-create-page .quick-upload-more-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-clear-all { padding: 5px 9px; border: 0; border-radius: 7px; color: var(--quick-muted); background: rgba(255,255,255,.72); font: inherit; font-size: 11px; cursor: pointer; }
|
||||
.quick-create-page .quick-parameter-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 20px; }
|
||||
.quick-create-page .quick-parameter-field { min-width: 0; display: grid; gap: 9px; padding: 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 12px; background: rgba(248,249,252,.72); }
|
||||
@@ -61,7 +61,7 @@
|
||||
.quick-create-page .quick-parameter-field .rs-select-btn { height: 38px; font-size: 12px; }
|
||||
.quick-create-page .quick-form-footer { display: flex; margin-top: 26px; }
|
||||
.quick-create-page .quick-generate-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: #fff; font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-cancel-button { width: 100%; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 1px solid rgba(34,42,54,.16); border-radius: 12px; color: #272a30; background: var(--surface); font: inherit; font-weight: 700; cursor: pointer; }.quick-create-page .quick-cancel-button:hover:not(:disabled) { border-color: rgba(200,61,77,.35); color: #c83d4d; background: rgba(200,61,77,.06); }.quick-create-page .quick-cancel-button:disabled { opacity: .55; cursor: not-allowed; }.quick-create-page .quick-cancel-button svg { width: 18px; height: 18px; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; }
|
||||
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
@@ -85,7 +85,7 @@
|
||||
.quick-create-page .quick-state-ready { position: relative; top: -10px; }
|
||||
.quick-create-page .quick-state-ready h2 { margin: 0 0 9px; font-size: 23px; }.quick-create-page .quick-state-ready > p { max-width: 430px; margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-ready-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 20px; }.quick-create-page .quick-ready-tags span { padding: 7px 10px; border: 1px solid rgba(34,42,54,.08); border-radius: 999px; color: #505762; background: rgba(255,255,255,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-state-generating { width: min(460px,100%); justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; }
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: var(--surface); }
|
||||
.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; }
|
||||
.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }
|
||||
@@ -104,7 +104,7 @@
|
||||
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
|
||||
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
.quick-create-page .quick-video-result-grid.is-single { grid-template-columns: minmax(0, 1fr); width: min(320px,100%); justify-self: center; }
|
||||
.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: #fff; font: inherit; text-align: left; cursor: pointer; }
|
||||
.quick-create-page .quick-video-result-card { min-width: 0; padding: 8px 8px 9px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; color: #25282d; background: var(--surface); font: inherit; text-align: left; cursor: pointer; }
|
||||
.quick-create-page .quick-video-result-thumb { position: relative; display: block; aspect-ratio: 16/9; overflow: hidden; border-radius: 8px; background: #eef1f5; }
|
||||
.quick-create-page .quick-video-result-thumb img,.quick-create-page .quick-video-result-thumb video { width: 100%; height: 100%; display: block; object-fit: cover; pointer-events: none; }
|
||||
.quick-create-page .quick-video-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); }
|
||||
@@ -130,7 +130,7 @@
|
||||
.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-head span { color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: var(--surface); box-shadow: inset 3px 0 0 var(--quick-blue); }
|
||||
.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; }
|
||||
.quick-create-page .quick-history-thumb:disabled { cursor: default; }
|
||||
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
@@ -144,7 +144,7 @@
|
||||
.quick-create-page .quick-history-badge.is-wait { color: var(--quick-muted); background: rgba(34,42,54,.06); }
|
||||
.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: var(--surface); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.quick-create-page .quick-history-open:hover { background: rgba(0,47,167,.05); }
|
||||
.quick-create-page .quick-history-open svg { width: 15px; height: 15px; }
|
||||
.quick-create-page .quick-history-empty { margin: 0; padding: 28px 8px; color: var(--quick-muted); font-size: 13px; }
|
||||
|
||||
@@ -20,7 +20,7 @@ const TABS: { k: Tab; label: string; title: string; note: string }[] = [
|
||||
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
type Preview = { src: string; kind: "image"; name: string };
|
||||
|
||||
@@ -294,7 +294,7 @@ function modelTags(m: ModelEntity): string[] {
|
||||
}
|
||||
|
||||
export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
onNotify?: (type: "success" | "error", text: string) => void;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
onBillingChanged?: () => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("all");
|
||||
@@ -313,6 +313,14 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
|
||||
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 next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
@@ -415,7 +423,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
<p>维护可复用的品牌模特与人物参考资产</p>
|
||||
</div>
|
||||
<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 />
|
||||
<span>{editMode ? "完成" : "管理模特"}</span>
|
||||
</button>
|
||||
@@ -483,6 +491,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
const onCardClick = () => {
|
||||
if (editMode) {
|
||||
if (selectable) toggleSelect(m.id);
|
||||
else onNotify?.("info", "官方模特不能删除");
|
||||
return;
|
||||
}
|
||||
setDetail(m);
|
||||
|
||||
@@ -941,7 +941,7 @@ export function OmniHistoryPage({
|
||||
<div className="omni-history-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="删除项目"
|
||||
aria-label="删除会话"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDeleteTarget(item);
|
||||
|
||||
@@ -297,7 +297,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
</div>
|
||||
|
||||
{loading && products.length === 0 ? (
|
||||
<SkeletonGrid count={8} />
|
||||
<ProductGridSkeleton />
|
||||
) : pageItems.length === 0 ? (
|
||||
<div className="pl-empty" role="status">
|
||||
<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 }) {
|
||||
const mock = productCover(product.title);
|
||||
const assetCount = product.images?.length || 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Bell,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Shield,
|
||||
Upload,
|
||||
User as UserIcon,
|
||||
@@ -12,16 +13,18 @@ import type { LoginSession, Team, User, UserPreference } from "../types";
|
||||
import { TeamModal } from "../components/overlays";
|
||||
import { CustomSelect } from "../components/custom-select";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import { applyTheme, normalizeAppearance, readStoredAppearance } from "../theme";
|
||||
|
||||
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
||||
|
||||
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
|
||||
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
|
||||
// 可导航分区。pref(创作默认)仍隐藏;display(显示)开放外观切换。
|
||||
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "display"];
|
||||
|
||||
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
|
||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||
{ key: "security", label: "安全", icon: <Shield /> },
|
||||
{ key: "notify", label: "通知", icon: <Bell /> },
|
||||
{ key: "display", label: "显示", icon: <Monitor /> },
|
||||
];
|
||||
|
||||
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>;
|
||||
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
|
||||
onResetAvatar?: () => void | Promise<unknown>;
|
||||
onNotify?: (text: string) => void;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
onLogout?: () => void | Promise<void>;
|
||||
}) {
|
||||
const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection)
|
||||
@@ -199,7 +202,10 @@ export function SettingsPage({
|
||||
transition: cd?.transition ?? DEFAULT_PREFS.transition,
|
||||
twoFactor: !!preferences?.two_factor_enabled,
|
||||
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,
|
||||
density: dp?.density ?? DEFAULT_PREFS.density,
|
||||
};
|
||||
@@ -210,18 +216,19 @@ export function SettingsPage({
|
||||
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端)
|
||||
// 外观即时生效(含后端 preferences 到达 / 放弃修改回滚)
|
||||
useEffect(() => {
|
||||
setBaseline(baselineFromProps);
|
||||
setDraft(baselineFromProps);
|
||||
}, [baselineFromProps]);
|
||||
applyTheme(normalizeAppearance(draft.appearance));
|
||||
}, [draft.appearance]);
|
||||
|
||||
// 单字段 draft 更新器
|
||||
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
// 已保存基线的 ref,自动保存时避免连点用到过期 baseline
|
||||
const baselineRef = useRef(baseline);
|
||||
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 keys = Object.keys(FIELD_SECTION) as Array<keyof TrackedState>;
|
||||
return keys.filter((key) => {
|
||||
@@ -239,6 +246,97 @@ export function SettingsPage({
|
||||
const dirtyCount = dirtyFields.length;
|
||||
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 派生)
|
||||
const name = draft.name;
|
||||
const email = draft.email;
|
||||
@@ -271,55 +369,6 @@ export function SettingsPage({
|
||||
[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() {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
@@ -381,32 +430,13 @@ export function SettingsPage({
|
||||
return () => URL.revokeObjectURL(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 (
|
||||
<section className="settings-page">
|
||||
<div className="settings-inner">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<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 className="sub">个人信息、偏好、通知与安全 · 修改后自动保存</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -449,7 +479,7 @@ export function SettingsPage({
|
||||
</div>
|
||||
</SettingRow>
|
||||
<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 title="登录邮箱" hint="仅做记录用,不用于登录">
|
||||
<input
|
||||
@@ -458,13 +488,15 @@ export function SettingsPage({
|
||||
value={email}
|
||||
placeholder="邮箱验证未启用"
|
||||
onChange={(event) => patchDraft("email", event.target.value)}
|
||||
onBlur={saveDraftNow}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow title="手机号" hint="保存所有更改时一并提交">
|
||||
<SettingRow title="手机号" hint="修改后自动保存">
|
||||
<input
|
||||
className="st-input"
|
||||
value={phone}
|
||||
onChange={(event) => patchDraft("phone", event.target.value)}
|
||||
onBlur={saveDraftNow}
|
||||
placeholder="138****8000"
|
||||
/>
|
||||
</SettingRow>
|
||||
@@ -499,7 +531,7 @@ export function SettingsPage({
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
|
||||
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
|
||||
<Switch checked={draft.twoFactor} onChange={(v) => commitField("twoFactor", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow stack title="在用设备" hint="真实登录会话 · 每次登录记录设备 UA / IP">
|
||||
<div className="device-list">
|
||||
@@ -541,7 +573,7 @@ export function SettingsPage({
|
||||
<section className="pane" aria-label="通知">
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<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>
|
||||
))}
|
||||
<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}>
|
||||
<Switch
|
||||
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>
|
||||
))}
|
||||
@@ -571,7 +603,7 @@ export function SettingsPage({
|
||||
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => patchDraft("template", choice.v)}
|
||||
onClick={() => commitField("template", choice.v)}
|
||||
>
|
||||
<div className="t">{choice.t}</div>
|
||||
<div className="d">{choice.d}</div>
|
||||
@@ -590,7 +622,7 @@ export function SettingsPage({
|
||||
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => patchDraft("duration", d)}
|
||||
onClick={() => commitField("duration", d)}
|
||||
>
|
||||
{d}s
|
||||
</span>
|
||||
@@ -609,7 +641,7 @@ export function SettingsPage({
|
||||
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => patchDraft("subtitle", choice.v)}
|
||||
onClick={() => commitField("subtitle", choice.v)}
|
||||
>
|
||||
<div className="t">{choice.t}</div>
|
||||
<div className="d">{choice.d}</div>
|
||||
@@ -621,7 +653,7 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认 BGM 库</div>
|
||||
<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: "emotion", label: "情绪向 · 治愈/悬念" },
|
||||
{ value: "urban", label: "都市电子 · 通勤场景" },
|
||||
@@ -632,7 +664,7 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认转场</div>
|
||||
<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: "fade", label: "淡入淡出 · 0.3s" },
|
||||
{ value: "slide", label: "滑动 · 0.3s" },
|
||||
@@ -651,6 +683,8 @@ export function SettingsPage({
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{section === "display" && (
|
||||
<section className="pane" aria-label="显示">
|
||||
<h3>显示</h3>
|
||||
@@ -659,17 +693,17 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">外观</div>
|
||||
<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: "light", label: "浅色" },
|
||||
{ value: "dark", label: "深色(V2)", disabled: true },
|
||||
{ value: "dark", label: "深色" },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="lbl">语言</div>
|
||||
<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: "en", label: "English(V2)", disabled: true },
|
||||
]} />
|
||||
@@ -678,7 +712,7 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">表格密度</div>
|
||||
<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: "standard", label: "标准" },
|
||||
{ value: "loose", label: "宽松" },
|
||||
@@ -813,7 +847,7 @@ export function SettingsPage({
|
||||
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||
</div>
|
||||
{isDirty ? <div className="logout-unsaved-note">当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。</div> : null}
|
||||
{isDirty ? <div className="logout-unsaved-note">还有 {dirtyCount} 项未落盘的修改(通常是正在编辑的文本),退出后不会保存。</div> : null}
|
||||
</TeamModal>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 210px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
@@ -42,9 +42,9 @@
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
align-content: start;
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--st-line);
|
||||
border-radius: 13px;
|
||||
border-radius: 20px;
|
||||
background: var(--st-wash);
|
||||
}
|
||||
.settings-nav .nav-h {
|
||||
@@ -104,7 +104,7 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.logout-pill {
|
||||
@@ -132,24 +132,34 @@
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 8px 22px;
|
||||
padding: 22px 28px 24px;
|
||||
border: 1px solid var(--st-line);
|
||||
border-radius: 13px;
|
||||
background: rgba(34, 42, 54, 0.04);
|
||||
border-radius: 20px;
|
||||
background: rgba(34, 42, 54, 0.035);
|
||||
box-shadow: var(--st-shadow);
|
||||
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 .pane-desc { font-size: 12px; color: var(--st-muted); margin-bottom: 18px; }
|
||||
|
||||
.st-row {
|
||||
min-height: 82px;
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(34, 42, 54, 0.075);
|
||||
}
|
||||
.st-row:last-child { border-bottom: 0; }
|
||||
@@ -196,7 +206,7 @@
|
||||
border: 1px solid rgba(34, 42, 54, 0.11);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--st-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
@@ -283,7 +293,7 @@
|
||||
.switch { position: relative; width: 46px; height: 26px; flex: 0 0 46px; display: inline-block; }
|
||||
.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::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::before { transform: translateX(20px); }
|
||||
.switch input:disabled + .slider { cursor: not-allowed; opacity: .55; }
|
||||
|
||||
@@ -68,6 +68,31 @@
|
||||
--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 {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
@@ -177,7 +202,7 @@ nav a.disabled:hover { background: transparent; color: var(--ink-4); }
|
||||
.user .em { font-size: 13px; }
|
||||
|
||||
/* ─── Main + grid background ─── */
|
||||
main { position: relative; background: #fff; }
|
||||
main { position: relative; background: var(--surface); }
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
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-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 .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; }
|
||||
|
||||
/* 团队页旧全局块已移除 · 详见 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 input { opacity: 0; width: 0; height: 0; }
|
||||
.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::before { transform: translateX(18px); }
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
padding: 0 13px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--st-muted);
|
||||
}
|
||||
.tc-search input {
|
||||
@@ -239,7 +239,7 @@
|
||||
line-height: 0;
|
||||
border: 1px solid var(--st-line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
color: var(--st-muted);
|
||||
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-radius: 50%;
|
||||
color: #78849a;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||
@@ -903,7 +903,7 @@
|
||||
box-sizing: content-box;
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 20px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
@@ -1015,7 +1015,7 @@
|
||||
|
||||
.vr-page .remix-page .remix-prompt-panel .analysis-prompt {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1245,7 +1245,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.24);
|
||||
border-radius: 9px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -1312,7 +1312,7 @@
|
||||
border-radius: 9px;
|
||||
outline: none;
|
||||
color: #27334a;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
@@ -1327,7 +1327,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
.vrep-page .replace-mode-button.active {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.12);
|
||||
border-radius: 13px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 8px 18px rgba(0, 47, 167, 0.07);
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.09);
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.vrep-page .replace-temporary-cell {
|
||||
@@ -686,7 +686,7 @@
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.vrep-page .replace-temporary-more svg {
|
||||
@@ -1130,7 +1130,7 @@
|
||||
border: 1px solid rgba(0, 47, 167, 0.18);
|
||||
border-radius: 8px;
|
||||
color: var(--klein);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
@@ -1232,7 +1232,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
box-shadow: -22px 0 48px rgba(7, 12, 22, 0.2);
|
||||
transform: translateX(100%);
|
||||
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -1363,7 +1363,7 @@
|
||||
gap: 10px;
|
||||
padding: 0 24px;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
+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
|
||||
Reference in New Issue
Block a user