From a84431c9c55544b5555cd0fa4d120f0db5318f9d Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Thu, 24 Sep 2026 18:25:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AE=A1=E6=A0=B8=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/free_video.py | 7 +- .../apps/ai/test_free_video_asset_ref.py | 6 +- core/backend/apps/ai/views.py | 16 ++- core/backend/apps/assets/free_library.py | 13 +- core/backend/apps/assets/review.py | 19 ++- core/backend/apps/assets/tests.py | 78 +++++++++- core/backend/apps/assets/views.py | 69 ++++++--- .../free-create/asset-library-modal.tsx | 21 +-- core/frontend/src/dark-mode-pages.css | 134 ++++++++++++++++++ core/frontend/src/free-create-page.css | 19 ++- 10 files changed, 334 insertions(+), 48 deletions(-) diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index fed7be2..a6138b3 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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}」还在审核中,请稍后再试") diff --git a/core/backend/apps/ai/test_free_video_asset_ref.py b/core/backend/apps/ai/test_free_video_asset_ref.py index f5ae492..bf302ad 100644 --- a/core/backend/apps/ai/test_free_video_asset_ref.py +++ b/core/backend/apps/ai/test_free_video_asset_ref.py @@ -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): diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 913d3e7..f96e601 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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, diff --git a/core/backend/apps/assets/free_library.py b/core/backend/apps/assets/free_library.py index fbe5297..aca0039 100644 --- a/core/backend/apps/assets/free_library.py +++ b/core/backend/apps/assets/free_library.py @@ -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 diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index 974efbe..d8b6071 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -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 "" diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index b39a1c7..db62741 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -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") diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 30ace91..2509e25 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -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,22 +794,52 @@ class AssetUploadView(APIView): object_key=object_key, content_type=upload.content_type or "application/octet-stream", ) - asset = Asset.objects.create( - id=asset_id, - team=team, - created_by=request.user, - name=serializer.validated_data.get("name") or upload.name, - asset_type=serializer.validated_data["asset_type"], - source=Asset.Source.UPLOAD, - category=serializer.validated_data["category"], - description=serializer.validated_data.get("description", ""), + # 先持久化,审核服务才能从 TOS 拉取文件;网络审核绝不能放在数据库事务里, + # 否则几十秒的轮询会长期锁住请求事务。 + with transaction.atomic(): + asset = Asset.objects.create( + id=asset_id, + team=team, + created_by=request.user, + name=serializer.validated_data.get("name") or upload.name, + asset_type=serializer.validated_data["asset_type"], + source=Asset.Source.UPLOAD, + category=serializer.validated_data["category"], + description=serializer.validated_data.get("description", ""), + ) + AssetFile.objects.create( + asset=asset, + object_key=stored.object_key, + bucket=stored.bucket, + content_type=stored.content_type, + 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, ) - AssetFile.objects.create( - asset=asset, - object_key=stored.object_key, - bucket=stored.bucket, - content_type=stored.content_type, - size_bytes=stored.size_bytes, - is_primary=True, - ) - return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED) diff --git a/core/frontend/src/components/free-create/asset-library-modal.tsx b/core/frontend/src/components/free-create/asset-library-modal.tsx index 6a952c7..e232595 100644 --- a/core/frontend/src/components/free-create/asset-library-modal.tsx +++ b/core/frontend/src/components/free-create/asset-library-modal.tsx @@ -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 = { - processing: { cls: "pill-info", label: "审核中" }, - active: { cls: "pill-ok", label: "可用" }, - failed: { cls: "pill-err", label: "未通过" } +const STATUS_ICON: Record = { + 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 }: { @@ -122,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 { @@ -149,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 : "上传失败"); @@ -443,7 +443,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { ) : (
{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 ( @@ -458,6 +459,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
{item.thumb_url ? {item.name} : {item.type === "audio" ? "♪" : item.type.toUpperCase()}} {item.duration ? {item.duration}s : null} + + +
{isEditing ? (
event.stopPropagation()}> @@ -489,7 +493,6 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { ) : (
{item.name}
)} - {pill.label} {item.status === "failed" && item.error_message &&
{item.error_message}
}
diff --git a/core/frontend/src/dark-mode-pages.css b/core/frontend/src/dark-mode-pages.css index bd14932..c9ba55e 100644 --- a/core/frontend/src/dark-mode-pages.css +++ b/core/frontend/src/dark-mode-pages.css @@ -189,6 +189,140 @@ html[data-theme="dark"] .settings-page { /* --st-black 已改成浅字色,不能再当选中底用 */ } +/* 平台后台:后台壳层保留了一组独立浅色变量,必须从根节点统一接入深色 token。 */ +html[data-theme="dark"] .admin-app { + --st-muted: var(--black-alpha-64); + --st-text: var(--accent-black); + --st-line: var(--border-faint); + --st-wash: var(--background-lighter); + --st-hover: var(--black-alpha-4); + --st-shadow: none; +} +html[data-theme="dark"] .admin-app .topbar { + border-color: var(--border-faint); + background: var(--surface); +} +html[data-theme="dark"] .admin-rail-mark, +html[data-theme="dark"] .admin-group, +html[data-theme="dark"] .admin-crumb, +html[data-theme="dark"] .admin-crumb button { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .admin-link { + color: var(--black-alpha-64); +} +html[data-theme="dark"] .admin-link:hover { + color: var(--accent-black); + background: var(--black-alpha-4); + transform: none; +} +html[data-theme="dark"] .admin-link.active { + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .admin-foot { + border-color: var(--border-faint); + background: var(--surface); +} +html[data-theme="dark"] .admin-user { + border: 1px solid var(--border-faint); + background: var(--background-lighter); +} +html[data-theme="dark"] .admin-user .av { + color: var(--accent-white); + background: var(--heat); +} +html[data-theme="dark"] .admin-user-meta .nm { + color: var(--accent-black); +} +html[data-theme="dark"] .admin-user-meta .rl, +html[data-theme="dark"] .admin-logout { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .admin-logout:hover { + color: var(--accent-black); + background: var(--black-alpha-4); +} +html[data-theme="dark"] .admin-crumb button:hover, +html[data-theme="dark"] .admin-crumb span { + color: var(--accent-black); +} +html[data-theme="dark"] .admin-mode { + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .admin-welcome p, +html[data-theme="dark"] .admin-group-label span, +html[data-theme="dark"] .admin-page .page-head .sub, +html[data-theme="dark"] .admin-page .page-head p, +html[data-theme="dark"] .admin-page .page-head .sub .mono { + color: var(--black-alpha-64); +} +html[data-theme="dark"] .admin-entry { + border-color: var(--border-faint); + color: var(--accent-black); + background: var(--surface); + box-shadow: none; +} +html[data-theme="dark"] .admin-entry:hover { + border-color: var(--black-alpha-24); + background: var(--background-lighter); + box-shadow: none; + transform: none; +} +html[data-theme="dark"] .admin-entry svg:last-child { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .admin-entry-copy small { + color: var(--black-alpha-56); +} +html[data-theme="dark"] .tabs-sub { + border-color: var(--border-faint); + background: var(--background-lighter); +} +html[data-theme="dark"] .tab-sub.active { + color: var(--accent-white); + background: var(--heat); +} +html[data-theme="dark"] .admin-page table.t.admin-table, +html[data-theme="dark"] .admin-attempt-list, +html[data-theme="dark"] .gov-card, +html[data-theme="dark"] .admin-page .card-hard { + border-color: var(--border-faint); + background: var(--surface); + box-shadow: none; +} +html[data-theme="dark"] .admin-page table.t.admin-table thead th, +html[data-theme="dark"] .admin-json, +html[data-theme="dark"] .pt-textarea, +html[data-theme="dark"] .admin-page .empty-state { + border-color: var(--border-faint); + background: var(--background-lighter); +} +html[data-theme="dark"] .gov-card.warn { + border-color: var(--heat-20); + background: var(--heat-12); +} +html[data-theme="dark"] .pt-vars code, +html[data-theme="dark"] .qw-chip { + border-color: var(--heat-20); + color: var(--heat); + background: var(--heat-12); +} +html[data-theme="dark"] .admin-drawer { + --st-muted: var(--black-alpha-64); + --st-text: var(--accent-black); + --st-line: var(--border-faint); + --st-wash: var(--background-lighter); + border-color: var(--border-faint); + background: var(--surface); +} +html[data-theme="dark"] .admin-bulk-bar { + border-color: var(--border-faint); + background: var(--surface-raised); + box-shadow: var(--shadow-floating); +} + /* 设置侧栏:选中项白底是 --st-black 被深色复用成字色导致 */ html[data-theme="dark"] .settings-page .st-tab { color: rgba(232, 234, 237, 0.72); diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css index 0d7642f..0d3c5a8 100644 --- a/core/frontend/src/free-create-page.css +++ b/core/frontend/src/free-create-page.css @@ -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;