大量修改UI

This commit is contained in:
Azmat@qq.com
2026-08-19 17:44:56 +08:00
parent 18b3927817
commit a343f47f0c
76 changed files with 11655 additions and 6104 deletions
@@ -21,6 +21,15 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
read_only_fields = ["updated_at"]
def update(self, instance, validated_data):
# JSON 字段按 key 合并,避免消息中心写入的 mute-* 被设置页整包覆盖丢掉
for field in ("notify", "creation_defaults", "display"):
incoming = validated_data.get(field)
current = getattr(instance, field)
if isinstance(incoming, dict) and isinstance(current, dict):
validated_data[field] = {**current, **incoming}
return super().update(instance, validated_data)
class LoginSessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField()
+38 -1
View File
@@ -1,13 +1,15 @@
from decimal import Decimal
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.billing.models import CreditAccount, CreditLedger
from apps.ops.models import Notification
from apps.ops.views import ensure_team_notifications
from apps.ops.views import NotificationViewSet, ensure_team_notifications
from apps.products.models import Product
from apps.projects.models import Project
@@ -84,3 +86,38 @@ class BillingNotificationTests(TestCase):
ensure_team_notifications(self.team, self.user)
note.refresh_from_db()
self.assertEqual(note.cost_label, "¥0.32")
class NotificationMuteFilterTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="owner", password="pass")
self.team = Team.objects.create(name="T", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.client = APIClient()
self.client.force_authenticate(self.user)
for kind, title in (
(Notification.Type.BILLING, "计费 1"),
(Notification.Type.BILLING, "计费 2"),
(Notification.Type.TASK, "任务 1"),
(Notification.Type.SYSTEM, "系统 1"),
):
Notification.objects.create(
team=self.team,
recipient=self.user,
notification_type=kind,
title=title,
dedupe_key=f"mute-test:{title}",
)
@patch.object(NotificationViewSet, "_refresh_notifications")
def test_exclude_type_hides_muted_category(self, _refresh):
res = self.client.get("/api/ops/notifications/", {"exclude_type": "billing", "page_size": 50})
self.assertEqual(res.status_code, 200)
titles = [row["title"] for row in res.data["results"]]
self.assertNotIn("计费 1", titles)
self.assertNotIn("计费 2", titles)
self.assertIn("任务 1", titles)
self.assertIn("系统 1", titles)
# chip 计数仍是全量,不被静音筛掉
self.assertEqual(res.data["type_counts"]["billing"], 2)
self.assertEqual(res.data["type_counts"]["task"], 1)
+12
View File
@@ -257,6 +257,15 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
user = self.request.user
return queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
def _excluded_types(self):
"""静音同类: ?exclude_type=billing&exclude_type=system 或逗号分隔。非法值丢弃。"""
allowed = {choice[0] for choice in Notification.Type.choices}
raw = self.request.query_params.getlist("exclude_type")
types: list[str] = []
for item in raw:
types.extend(part.strip() for part in item.split(",") if part.strip())
return [item for item in types if item in allowed]
def get_queryset(self):
queryset = self._recipient_scope()
notification_type = self.request.query_params.get("type")
@@ -264,6 +273,9 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = queryset.filter(notification_type=notification_type)
if self.request.query_params.get("unread") in {"1", "true", "yes"}:
queryset = queryset.filter(is_read=False)
excluded = self._excluded_types()
if excluded:
queryset = queryset.exclude(notification_type__in=excluded)
return queryset
def _refresh_notifications(self, request):
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="icon" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="%23fa5d19"/><text x="16" y="21" text-anchor="middle" font-family="Arial" font-size="12" fill="white">AS</text></svg>' />
<title>AirShelf</title>
<link rel="icon" href="/assets/yz/logo.png" />
<title>影擎</title>
</head>
<body>
<div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+34 -13
View File
@@ -21,7 +21,7 @@ import type {
} from "./types";
import { publicModelDisplayName } from "./model-display";
import { generationErrorText } from "./generation-error";
import { CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { AccountMenu, CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { SystemLoading } from "./components/loading";
import {
AccountPage,
@@ -115,6 +115,7 @@ export function App() {
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
const [notice, setNotice] = useState<Notice>(null);
const [loading, setLoading] = useState(false);
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
// 右上角 toast 自动消失:成功/信息 3s,错误 5s(此前 notice 不会自己清,导致「提示一直挂着」)
useEffect(() => {
@@ -899,7 +900,7 @@ export function App() {
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 先创建一个视频项目</span>
<span className="mono"></span>
</div>
</div>
<div className="actions">
@@ -935,6 +936,7 @@ export function App() {
billing={billing}
projects={projects}
team={currentTeam}
navigate={navigate}
onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
onNotify={(type, text) => setNotice({ type, text })}
/>
@@ -966,7 +968,7 @@ export function App() {
case "assetFactory":
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} />;
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => navigate("projects")} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto":
@@ -1016,13 +1018,12 @@ export function App() {
/>
);
}
if (page === "pipeline" && pipelineProject) {
const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
return (
const pipelineTextModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
const pipelinePage = page === "pipeline" && pipelineProject ? (
<PipelinePage
key={pipelineProject.id}
project={pipelineProject}
scriptModelName={publicModelDisplayName(textModel, "AI")}
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
loading={loading}
navigate={navigate}
@@ -1123,12 +1124,11 @@ export function App() {
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
onSubmitExport={submitExport}
/>
);
}
) : null;
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} logout={logout} onOpenAdmin={() => navigateAdmin("")} />
<Sidebar page={page} navigate={navigate} user={currentUser} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} />
<main>
<Decorations />
<header className="topbar">
@@ -1163,17 +1163,38 @@ export function App() {
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
<button
className="topbar-avatar"
type="button"
title="账户"
aria-haspopup="menu"
aria-expanded={accountAnchor !== null}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setAccountAnchor((prev) => (prev ? null : rect));
}}
>
{currentUser.avatar_url ? <img src={currentUser.avatar_url} alt="头像" /> : <span>{avatarChar}</span>}
</div>
</button>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{renderPage()}
{pipelinePage || renderPage()}
</div>
</main>
{accountAnchor && (
<AccountMenu
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={logout}
user={currentUser}
team={currentTeam}
canManageBilling={isOwner}
/>
)}
</div>
);
}
+172 -115
View File
@@ -1,20 +1,60 @@
/* 账户页 · 从 public/exact/account.html 内联 <style> 忠实移植,整段 scope .account-page 防冲突 */
/* 账户页 · 对照影擎页头(返回方钮 + 大标题 + 灰摘要)。仅 scope .account-page,不改共享 .btn/.pill/.input */
.account-page {
--klein: #002fa7;
--st-muted: #6f747c;
--st-text: #17181a;
--st-black: #101012;
--st-line: rgba(34, 42, 54, 0.10);
--st-wash: rgba(34, 42, 54, 0.045);
--st-shadow: 0 8px 20px rgba(20, 27, 38, 0.09);
max-width: 1140px;
padding-bottom: 44px;
.page-head {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 14px;
margin-bottom: 22px;
flex-wrap: nowrap;
}
.page-head h1 { font-size: 28px; }
.page-head .sub { margin-top: 6px; font-size: 15px; color: var(--st-muted); }
.ac-back {
width: 36px;
height: 36px;
flex: 0 0 36px;
margin-top: 4px;
display: grid;
place-items: center;
padding: 0;
border-radius: 8px;
border: 1px solid rgba(28, 34, 43, 0.12);
background: #fff;
color: var(--st-text);
cursor: pointer;
}
.ac-back:hover { background: rgba(34, 42, 54, 0.05); }
.ac-back:focus-visible { outline: none; box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgba(0, 47, 167, 0.28); }
.ac-back svg { width: 18px; height: 18px; display: block; }
.top-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(480px, .85fr); gap: 16px; margin-bottom: 22px; align-items: stretch; }
@media (max-width: 1120px) { .top-grid { grid-template-columns: 1fr; } }
.balance-banner {
background: var(--accent-black);
color: var(--accent-white);
background: #202124;
color: #fff;
padding: 26px 28px;
position: relative;
border: 1px solid var(--accent-black);
border-radius: var(--r-md);
border: 0;
border-radius: 14px;
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
min-height: 246px;
box-shadow: 0 14px 30px rgba(16, 16, 18, 0.18);
}
.balance-banner::before, .balance-banner::after,
.balance-banner > .corner-tr, .balance-banner > .corner-bl {
@@ -29,189 +69,206 @@
.balance-hero { display: flex; flex-direction: column; gap: 4px; }
.balance-hero .lbl { font-family: var(--font-mono); font-size: 10.5px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.balance-hero .v { font-size: 38px; font-weight: 700; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
.balance-hero .v { font-size: 38px; font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
.balance-hero .meta { font-size: 11.5px; color: rgba(255,255,255,.5); font-family: var(--font-mono); letter-spacing: .02em; margin-top: 4px; }
.balance-sub { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 16px 0 0; border-top: 1px solid rgba(255,255,255,.1); }
.balance-sub .col { min-width: 0; }
.balance-sub .lbl { font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); letter-spacing: .06em; text-transform: uppercase; }
.balance-sub .v { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.balance-sub .v { font-size: 18px; font-weight: 600; letter-spacing: -.01em; margin-top: 4px; font-variant-numeric: tabular-nums; }
.balance-sub .meta { font-size: 10.5px; color: rgba(255,255,255,.42); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.balance-foot { margin-top: auto; padding-top: 2px; }
.balance-meter { height: 5px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); overflow: hidden; }
.balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--heat); border-radius: inherit; }
.balance-meter > span { display: block; height: 100%; width: 5.4%; background: var(--klein); border-radius: inherit; }
.balance-foot-meta { display: flex; justify-content: space-between; gap: 14px; margin-top: 8px; color: rgba(255,255,255,.46); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.pane .desc { font-size: 11.5px; color: var(--black-alpha-48); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; }
.pane { background: #fff; border: 1px solid var(--st-line); border-radius: 14px; padding: 20px; margin-bottom: 16px; box-shadow: var(--st-shadow); }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: var(--st-text); }
.pane .desc { font-size: 11.5px; color: var(--st-muted); margin-bottom: 14px; font-family: var(--font-mono); letter-spacing: .02em; }
.topup-pane { display: flex; flex-direction: column; padding: 20px 22px; margin-bottom: 0; min-height: 246px; }
.topup-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.topup-head h3 { margin-bottom: 5px; }
.topup-head .desc { margin-bottom: 0; }
.topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); white-space: nowrap; padding-top: 2px; }
.topup-selected { font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); white-space: nowrap; padding-top: 2px; }
.recharge-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.recharge-card { min-height: 76px; border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 10px 8px; text-align: center; cursor: pointer; background: var(--surface); position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base), transform var(--t-fast); }
.recharge-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-24); }
.recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--heat-40); }
.recharge-card.selected { border-color: var(--heat); background: var(--heat-12); box-shadow: inset 0 0 0 1px var(--heat); }
.recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--heat); color: var(--accent-white); font-size: 10px; line-height: 1; }
.recharge-card .amt { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.15; }
.recharge-card .gift { font-size: 10px; color: var(--black-alpha-48); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; }
.recharge-card { min-height: 76px; border: 1px solid var(--st-line); border-radius: 10px; padding: 10px 8px; text-align: center; cursor: pointer; background: #fff; position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: background var(--t-base), border-color var(--t-base), box-shadow var(--t-base); }
.recharge-card:hover { background: var(--st-wash); border-color: rgba(28, 34, 43, 0.18); }
.recharge-card:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.28); }
.recharge-card.selected { border-color: rgba(0, 47, 167, 0.28); background: rgba(0, 47, 167, 0.07); box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.18); }
.recharge-card.selected::after { content: '✓'; position: absolute; top: 6px; right: 7px; width: 15px; height: 15px; border-radius: 50%; display: grid; place-items: center; background: var(--klein); color: #fff; font-size: 10px; line-height: 1; }
.recharge-card .amt { font-size: 17px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.15; color: var(--st-text); }
.recharge-card .gift { font-size: 10px; color: var(--st-muted); margin-top: 4px; font-family: var(--font-mono); white-space: nowrap; }
.recharge-card .gift.bonus { color: var(--accent-forest); font-weight: 600; }
.recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--heat); color: var(--accent-white); letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); }
.recharge-card .ribbon { position: absolute; top: 6px; left: 7px; font-family: var(--font-mono); font-size: 9px; padding: 1px 5px; background: var(--klein); color: #fff; letter-spacing: .03em; font-weight: 600; border-radius: var(--r-sm); }
.pay-row { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding-top: 0; border-top: 0; }
.pay-title { font-size: 12px; font-weight: 600; color: var(--accent-black); line-height: 1.2; }
.pay-row .input { width: 100%; box-sizing: border-box; height: 38px; }
.pay-title { font-size: 12px; font-weight: 600; color: var(--st-text); line-height: 1.2; }
.pay-row .input { width: 100%; box-sizing: border-box; }
.pay-btn-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.pay-method-btn { height: 38px; border-radius: var(--r-pill); display: inline-flex; justify-content: center; align-items: center; gap: 8px; background: var(--surface); color: var(--accent-black); border-color: var(--border-faint); font-weight: 500; }
.pay-method-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--black-alpha-24); }
.pay-method-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--black-alpha-16); }
.pay-method-btn { height: 38px; border-radius: 11px; display: inline-flex; justify-content: center; align-items: center; gap: 8px; }
.pay-logo { width: 18px; height: 18px; border-radius: 6px; display: inline-block; flex: 0 0 18px; overflow: hidden; }
.pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: inherit; }
@media (max-width: 720px) { .recharge-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.billing-tabs { display: flex; align-items: flex-end; gap: 4px; border-bottom: 1px solid var(--border-faint); margin: 24px 0 18px; padding: 0 2px; overflow-x: auto; scrollbar-width: none; }
.billing-tabs { display: flex; align-items: center; gap: 8px; border-bottom: 0; margin: 8px 0 18px; padding: 0; overflow-x: auto; scrollbar-width: none; }
.billing-tabs::-webkit-scrollbar { display: none; }
.billing-tabs .tab { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 6px; background: transparent; border: 0; border-bottom: 2px solid transparent; border-radius: var(--r-md) var(--r-md) 0 0; margin-bottom: -1px; padding: 10px 14px; font-size: 13px; font-weight: 500; color: var(--black-alpha-56); font-family: inherit; cursor: pointer; letter-spacing: 0; user-select: none; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.billing-tabs .tab:hover { color: var(--accent-black); background: var(--black-alpha-4); }
.billing-tabs .tab:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--heat-40); }
.billing-tabs .tab.active { color: var(--accent-black); border-bottom-color: var(--heat); font-weight: 600; }
.billing-tabs .tab .count { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding: 1px 7px; background: var(--black-alpha-4); border-radius: var(--r-sm); letter-spacing: .04em; }
.billing-tabs .tab.active .count { background: var(--heat-12); color: var(--heat); }
.ac-tab {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 6px;
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: 500;
color: #50555d;
cursor: pointer;
letter-spacing: 0;
user-select: none;
}
.ac-tab:hover { color: var(--st-text); background: rgba(34, 42, 54, 0.06); }
.ac-tab:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.28); }
.ac-tab.active { color: #fff; background: var(--st-black); font-weight: 500; }
.ac-tab .count { font-family: var(--font-mono); font-size: 10.5px; color: inherit; opacity: .72; padding: 1px 7px; background: rgba(34, 42, 54, 0.08); border-radius: 7px; letter-spacing: .04em; }
.ac-tab.active .count { background: rgba(255, 255, 255, 0.14); color: #fff; opacity: 1; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.overview-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 16px; align-items: stretch; }
@media (max-width: 980px) { .overview-grid { grid-template-columns: 1fr; } }
.trend-pane { padding: 18px 20px 14px; display: flex; flex-direction: column; }
.trend-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }
.trend-head h3 { margin-bottom: 0; }
.trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .02em; }
.trend-head .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--st-muted); letter-spacing: .02em; }
.trend-head .spacer { flex: 1; }
.trend-head .chip { font-family: var(--font-mono); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--border-faint); border-radius: var(--r-pill); color: var(--black-alpha-56); cursor: pointer; }
.trend-head .chip.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.ac-seg {
height: 30px;
padding: 0 12px;
border: 0;
border-radius: 8px;
color: var(--st-muted);
background: rgba(34, 42, 54, 0.055);
cursor: pointer;
font: inherit;
font-size: 12px;
}
.ac-seg:hover { background: rgba(34, 42, 54, 0.10); color: var(--st-text); }
.ac-seg.active { color: #fff; background: var(--st-black); }
.trend-chart { display: grid; grid-template-rows: 1fr auto; gap: 6px; min-height: 170px; flex: 1; padding: 6px 4px 2px; position: relative; }
.trend-chart .bars { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; align-items: end; height: 100%; }
.trend-chart .bar { background: var(--background-lighter); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; height: 100%; }
.trend-chart .bar > span { position: absolute; left: 0; bottom: 0; display: block; width: 100%; flex: none; min-height: 0; background: var(--heat); border-radius: 2px 2px 0 0; }
.trend-chart .bar:hover > span { background: var(--accent-black); }
.trend-chart .bar.peak > span { background: var(--accent-black); }
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-32); text-align: center; letter-spacing: .02em; }
.trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-faint); font-size: 12px; }
.trend-chart .bar { background: rgba(34, 42, 54, 0.06); border-radius: 2px 2px 0 0; position: relative; transition: background var(--t-base); cursor: pointer; height: 100%; }
.trend-chart .bar > span { position: absolute; left: 0; bottom: 0; display: block; width: 100%; flex: none; min-height: 0; background: var(--klein); border-radius: 2px 2px 0 0; }
.trend-chart .bar:hover > span { background: var(--st-black); }
.trend-chart .bar.peak > span { background: var(--st-black); }
.trend-chart .x-axis { display: grid; grid-template-columns: repeat(14, 1fr); gap: 5px; font-family: var(--font-mono); font-size: 9.5px; color: var(--st-muted); text-align: center; letter-spacing: .02em; }
.trend-foot { display: flex; gap: 14px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--st-line); font-size: 12px; }
.trend-foot .item { display: flex; align-items: baseline; gap: 6px; }
.trend-foot .item .k { color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
.trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-black); }
.trend-foot .item .v.warn { color: #B45309; }
.trend-foot .item .k { color: var(--st-muted); font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .02em; }
.trend-foot .item .v { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--st-text); }
.trend-foot .item .v.warn { color: var(--accent-honey); }
.stage-pane .usage-line { display: flex; justify-content: space-between; padding: 4px 0 4px; font-size: 13px; }
.stage-pane .usage-line .k { color: var(--accent-black); }
.stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--accent-black); font-weight: 600; }
.stage-pane .usage-bar { height: 4px; background: var(--background-lighter); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; }
.stage-pane .usage-line .k { color: var(--st-text); }
.stage-pane .usage-line .v { font-variant-numeric: tabular-nums; color: var(--st-text); font-weight: 600; }
.stage-pane .usage-bar { height: 4px; background: rgba(34, 42, 54, 0.08); border-radius: 2px; margin: 4px 0 10px; overflow: hidden; }
.stage-pane .usage-bar > span { display: block; height: 100%; transition: width .3s ease; }
.stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--border-faint); font-size: 13px; font-weight: 600; }
.stage-pane .total { display: flex; justify-content: space-between; padding-top: 10px; margin-top: 6px; border-top: 1px solid var(--st-line); font-size: 13px; font-weight: 600; }
.stage-pane .total .v { font-variant-numeric: tabular-nums; }
.rule-pane .rule-list { font-size: 13px; color: var(--black-alpha-56); line-height: 1.7; }
.rule-pane .rule-list strong { color: var(--accent-black); font-weight: 600; }
.rule-pane .mono-acc { font-family: var(--font-mono); color: var(--heat); background: var(--heat-12); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); }
.rule-pane .rule-list { font-size: 13px; color: var(--st-muted); line-height: 1.7; }
.rule-pane .rule-list strong { color: var(--st-text); font-weight: 600; }
.rule-pane .mono-acc { font-family: var(--font-mono); color: var(--klein); background: rgba(0, 47, 167, 0.09); padding: 1px 5px; font-size: 11.5px; border-radius: var(--r-sm); }
.quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-faint); }
.quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; }
.quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 13px; color: var(--accent-black); }
.quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; }
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--heat); background: var(--heat-12); padding: 0 4px; border-radius: var(--r-sm); }
.quota-rules { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--st-line); }
.quota-rules .qr-head { font-family: var(--font-mono); font-size: 10.5px; color: var(--st-muted); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 10px; }
.quota-rules .step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; align-items: baseline; margin-bottom: 6px; font-size: 13px; color: var(--st-text); }
.quota-rules .step .num { width: 20px; height: 20px; border-radius: 50%; background: rgba(0, 47, 167, 0.09); color: var(--klein); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; display: grid; place-items: center; }
.quota-rules .step .formula { font-family: var(--font-mono); font-size: 11.5px; color: var(--klein); background: rgba(0, 47, 167, 0.09); padding: 0 4px; border-radius: var(--r-sm); }
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; }
.billing-table { width: 100%; border-collapse: separate; border-spacing: 0; background: #fff; border: 1px solid var(--st-line); border-radius: 14px; overflow: hidden; box-shadow: var(--st-shadow); }
.billing-table th, .billing-table td { padding: 13px 16px; text-align: left; font-size: 13px; border-bottom: 0; }
.billing-table thead th { background: var(--background-lighter); border-bottom: 1px solid var(--border-muted); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
/* 对齐 V1:tbody 不画行间横线(违反 inside-border 铁律),只靠 thead 分隔 + 行 hover 底色区分 */
.billing-table tbody tr:hover { background: var(--background-lighter); }
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-black); text-align: right; }
.billing-table thead th { background: var(--st-wash); border-bottom: 1px solid var(--st-line); font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; color: var(--st-muted); letter-spacing: .04em; text-transform: uppercase; }
.billing-table tbody tr:hover { background: var(--st-wash); }
.billing-table .ts { font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; }
.billing-table .neg { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--st-text); text-align: right; }
.billing-table .pos { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--accent-forest); text-align: right; }
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: var(--black-alpha-32); text-align: right; }
/* 已用 / 月度额度:已用部分本色,额度部分弱化,整列左对齐 */
.billing-table .zero { font-variant-numeric: tabular-nums; font-weight: 500; color: rgba(28, 34, 43, 0.28); text-align: right; }
.billing-table .quota { font-variant-numeric: tabular-nums; text-align: left; }
.billing-table .quota .used { font-weight: 500; color: var(--accent-black); }
.billing-table .quota .lim { color: var(--black-alpha-32); }
.billing-table .muted { color: var(--black-alpha-56); font-size: 12px; }
/* 系统流水(无成员):弱化的 mono 占位,不喧宾夺主 */
.billing-table .sys { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-32); }
.billing-table .ref { color: var(--black-alpha-48); font-size: 10.5px; font-family: var(--font-mono); }
.billing-table .quota .used { font-weight: 500; color: var(--st-text); }
.billing-table .quota .lim { color: rgba(28, 34, 43, 0.32); }
.billing-table .muted { color: var(--st-muted); font-size: 12px; }
.billing-table .sys { font-family: var(--font-mono); font-size: 10.5px; color: rgba(28, 34, 43, 0.32); }
.billing-table .ref { color: var(--st-muted); font-size: 10.5px; font-family: var(--font-mono); }
.billing-table .who { display: inline-flex; align-items: center; gap: 8px; }
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: inline-grid; place-items: center; font-size: 12px; font-weight: 600; color: var(--accent-black); }
.billing-table .who .av { width: 24px; height: 24px; border-radius: 50%; background: var(--st-wash); border: 1px solid var(--st-line); display: inline-grid; place-items: center; font-size: 12px; font-weight: 600; color: var(--st-text); }
.billing-table .role-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: var(--r-pill); font-size: 10.5px; font-weight: 500; }
.billing-table .role-pill .dot { width: 5px; height: 5px; border-radius: 50%; }
.billing-table .role-super { background: var(--heat-12); color: var(--heat); }
.billing-table .role-super .dot { background: var(--heat); }
.billing-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
.billing-table .role-admin .dot { background: #1E40AF; }
.billing-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
.billing-table .role-member .dot { background: var(--black-alpha-56); }
.billing-table .role-super { background: rgba(0, 47, 167, 0.09); color: var(--klein); }
.billing-table .role-super .dot { background: var(--klein); }
.billing-table .role-admin { background: rgba(22, 184, 78, 0.10); color: #138d3f; }
.billing-table .role-admin .dot { background: #138d3f; }
.billing-table .role-member { background: var(--st-wash); color: var(--st-muted); }
.billing-table .role-member .dot { background: var(--st-muted); }
.billing-table .status-tag { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: var(--r-sm); letter-spacing: .04em; }
.billing-table .status-tag.ok { background: rgba(66,195,102,.12); color: var(--accent-forest); }
.billing-table .status-tag.wip { background: var(--heat-12); color: var(--heat); }
.billing-table .status-tag.wip { background: rgba(0, 47, 167, 0.09); color: var(--klein); }
.billing-table .status-tag.fail { background: rgba(235,52,36,.10); color: var(--accent-crimson); }
.bill-pager { display: flex; align-items: center; gap: 12px; margin-top: 14px; font-size: 12px; }
.bill-pager .total { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.bill-pager .total { font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); letter-spacing: .02em; }
.bill-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
.bill-pager .pages button { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid var(--border-muted); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.bill-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
.bill-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
.bill-pager .pages button { min-width: 28px; height: 28px; padding: 0 8px; border: 1px solid var(--st-line); border-radius: var(--r-sm); background: #fff; font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); cursor: pointer; }
.bill-pager .pages button:hover:not(.active):not(:disabled) { border-color: rgba(28, 34, 43, 0.22); color: var(--st-text); }
.bill-pager .pages button.active { background: var(--st-black); color: #fff; border-color: var(--st-black); font-weight: 600; }
.bill-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
.bill-pager .pages .ellipsis { min-width: 20px; height: 28px; display: inline-flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 12px; user-select: none; }
/* row40:跳页输入框 */
.bill-pager .bill-jump { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
.bill-pager .bill-jump input { width: 48px; height: 28px; padding: 0 6px; border: 1px solid var(--border-muted); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 12px; color: var(--accent-black); text-align: center; }
.bill-pager .bill-jump input:focus { outline: none; border-color: var(--heat); }
.bill-pager .bill-jump button { min-width: 40px; height: 28px; padding: 0 10px; border: 1px solid var(--border-muted); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }
.bill-pager .bill-jump button:hover:not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
.bill-pager .pages .ellipsis { min-width: 20px; height: 28px; display: inline-flex; align-items: flex-end; justify-content: center; padding-bottom: 4px; color: rgba(28, 34, 43, 0.28); font-family: var(--font-mono); font-size: 12px; user-select: none; }
.bill-pager .bill-jump { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); }
.bill-pager .bill-jump input { width: 48px; height: 28px; padding: 0 6px; border: 1px solid var(--st-line); border-radius: var(--r-sm); background: #fff; font-family: var(--font-mono); font-size: 12px; color: var(--st-text); text-align: center; }
.bill-pager .bill-jump input:focus { outline: none; border-color: var(--klein); }
.bill-pager .bill-jump button { min-width: 40px; height: 28px; padding: 0 10px; border: 1px solid var(--st-line); border-radius: var(--r-sm); background: #fff; font-family: var(--font-mono); font-size: 12px; color: var(--st-muted); cursor: pointer; }
.bill-pager .bill-jump button:hover:not(:disabled) { border-color: rgba(28, 34, 43, 0.22); color: var(--st-text); }
.bill-pager .bill-jump button:disabled { opacity: .4; cursor: not-allowed; }
/* row39:详情列过长不撑行 —— 截断省略,状态列不换行(避免「成功」被挤换行) */
.billing-table .bill-detail { max-width: 320px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.billing-table .bill-status { white-space: nowrap; }
.billing-table .progress-mini { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--heat); }
.billing-table .progress-mini { width: 80px; height: 4px; background: rgba(34, 42, 54, 0.08); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 8px; }
.billing-table .progress-mini > span { display: block; height: 100%; background: var(--klein); }
.filter-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.filter-bar select, .filter-bar input { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--accent-black); transition: border-color var(--t-base); }
.filter-bar select:hover, .filter-bar input:hover { border-color: var(--black-alpha-24); }
.filter-bar select, .filter-bar input { background: #fff; border: 1px solid var(--st-line); border-radius: 8px; padding: 6px 10px; font-size: 13px; font-family: inherit; color: var(--st-text); }
.filter-bar select:hover, .filter-bar input:hover { border-color: rgba(28, 34, 43, 0.18); }
.filter-bar select { padding-right: 24px; }
.filter-bar .spacer { flex: 1; }
.filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.filter-bar .ct b { color: var(--accent-black); font-weight: 600; }
/* 清除筛选:弱化的文字按钮(不抢主橙),hover 转近主前景 */
.filter-reset { height: 30px; padding: 0 10px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; cursor: pointer; transition: border-color var(--t-base), color var(--t-base), background var(--t-base); }
.filter-reset:hover { border-color: var(--black-alpha-24); color: var(--accent-black); background: var(--background-lighter); }
.filter-bar .ct { font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; }
.filter-bar .ct b { color: var(--st-text); font-weight: 600; }
.filter-reset { height: 30px; padding: 0 10px; border: 1px solid var(--st-line); border-radius: 8px; background: #fff; font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; cursor: pointer; }
.filter-reset:hover { border-color: rgba(28, 34, 43, 0.18); color: var(--st-text); background: var(--st-wash); }
/* 项目名:列表内主文本,单行省略不撑表 */
.billing-table .proj-name { font-weight: 500; color: var(--accent-black); }
.billing-table .proj-name { font-weight: 500; color: var(--st-text); }
/* 充值扫码弹窗 restraint token 重写(不复用 styles.css 旧暖米版 .topup-modal)
外壳走设计系统 .modal-bg/.modal,此处只定弹窗宽度 + QR 占位 + 金额/赠送排版 */
.topup-modal { width: min(460px, 92vw); }
.topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--black-alpha-56); margin-bottom: 4px; }
.topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--heat); margin-bottom: 6px; line-height: 1.1; }
.topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; margin-bottom: 14px; }
/* 二维码占位:线框 + 对角网纹(纯 CSS,不引二维码库)+ 居中 mono「扫码支付」字样 */
.topup-modal .topup-qr { aspect-ratio: 1; max-width: 200px; margin: 0 auto 12px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; }
.topup-modal .topup-info { text-align: center; font-size: 13px; color: var(--st-muted); margin-bottom: 4px; }
.topup-modal .topup-amt { text-align: center; font-size: 26px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--klein); margin-bottom: 6px; line-height: 1.1; }
.topup-modal .topup-note { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; margin-bottom: 14px; }
.topup-modal .topup-qr { aspect-ratio: 1; max-width: 200px; margin: 0 auto 12px; background: var(--st-wash); border: 1px solid var(--st-line); border-radius: var(--r-md); display: grid; place-items: center; position: relative; overflow: hidden; }
.topup-modal .topup-qr::before {
content: ''; position: absolute; inset: 16px;
background-image: linear-gradient(45deg, var(--accent-black) 25%, transparent 25%),
linear-gradient(-45deg, var(--accent-black) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--accent-black) 75%),
linear-gradient(-45deg, transparent 75%, var(--accent-black) 75%);
background-image: linear-gradient(45deg, var(--st-black) 25%, transparent 25%),
linear-gradient(-45deg, var(--st-black) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--st-black) 75%),
linear-gradient(-45deg, transparent 75%, var(--st-black) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
opacity: .12;
}
.topup-modal .topup-qr .center { position: relative; z-index: 1; background: var(--surface); padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; text-align: center; line-height: 1.6; }
.topup-modal .topup-qr .ref { color: var(--black-alpha-32); }
.topup-modal .topup-valid { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.topup-modal .topup-qr .center { position: relative; z-index: 1; background: #fff; padding: 10px 12px; border-radius: var(--r-sm); font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; text-align: center; line-height: 1.6; }
.topup-modal .topup-qr .ref { color: rgba(28, 34, 43, 0.32); }
.topup-modal .topup-valid { text-align: center; font-family: var(--font-mono); font-size: 11px; color: var(--st-muted); letter-spacing: .02em; }
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -887,13 +887,16 @@ export const api = {
return request<BillingConfigInfo>("/api/billing/config/");
},
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number; excludeTypes?: string[] }) {
const query = new URLSearchParams();
if (params?.type && params.type !== "all") query.set("type", params.type);
if (params?.unread) query.set("unread", "1");
if (params?.search) query.set("search", params.search);
if (params?.page) query.set("page", String(params.page));
if (params?.pageSize) query.set("page_size", String(params.pageSize));
for (const type of params?.excludeTypes || []) {
if (type) query.append("exclude_type", type);
}
const qs = query.toString();
return request<NotificationList>(`/api/ops/notifications/${qs ? `?${qs}` : ""}`);
},
+14 -61
View File
@@ -1,10 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Check, Info, LogOut } from "lucide-react";
import { AlertCircle, Boxes, Check, Info, LogOut, Settings, UsersRound } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock } from "./overlays";
import { api, setToken } from "../api";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -15,10 +13,10 @@ type Command = { id: string; group: string; label: string; sub: string; page: Pa
const SHELL_COMMANDS: Command[] = [
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
{ id: "settings", group: "导航", label: "设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" },
@@ -73,7 +71,7 @@ function CommandPalette({ open, onClose, navigate, canManageBilling = true }: {
<div className="shell-command-empty">
<IconKitSvg name="search" />
<span></span>
<span className="shell-command-section">// 换个关键词试试</span>
<span className="shell-command-section"></span>
</div>
)}
{items.map((cmd, i) => {
@@ -113,7 +111,7 @@ const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
{ act: "account", icon: "creditCard", label: "消费与余额" }
];
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
anchorRect: DOMRect;
onClose: () => void;
navigate: Navigate;
@@ -196,10 +194,10 @@ const NAV: NavDef[] = [
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
{ id: "products", page: "products", label: "商品库", icon: "package" },
{ id: "models", page: "models", label: "模特库", icon: "model" },
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
{ id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" },
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
{ id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" },
{ id: "library", page: "library", label: "资产库", icon: "library" },
{ id: "library", page: "library", label: "成品库", icon: "library" },
{ id: "team", page: "team", label: "团队", icon: "users" },
{ id: "account", page: "account", label: "消费", icon: "creditCard" },
{ id: "trash", page: "trash", label: "垃圾桶", icon: "trash" },
@@ -208,7 +206,7 @@ const NAV: NavDef[] = [
const RESOURCE_NAV: Array<{ id: string; page: Page; label: string }> = [
{ id: "products", page: "products", label: "商品库" },
{ id: "models", page: "models", label: "模特库" },
{ id: "library", page: "library", label: "资产库" },
{ id: "library", page: "library", label: "成品库" },
// 成品库先隐藏,后续再加回资源中心
{ id: "account", page: "account", label: "消费" },
{ id: "trash", page: "trash", label: "垃圾桶" },
@@ -258,11 +256,10 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
settingsNotify: "settings"
};
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, logout, onOpenAdmin }: {
export function Sidebar({ page, navigate, user, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, onOpenAdmin }: {
page: Page;
navigate: Navigate;
user: User;
team: Team | null;
// 主账号(owner=超管)才看得到「团队」「消费」入口;子账号隐藏(PMC#3)。默认 true 兼容旧调用。
canManageBilling?: boolean;
products: Product[];
@@ -271,9 +268,6 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
projectTotal?: number;
// YYX#row22:未读生成任务总数 → 「图片生成」右侧数字胶囊
aiUnread?: number;
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
logout?: () => void;
// 平台超管入口:仅 user.is_platform_admin 时显示,点了进 /admin 后台
onOpenAdmin?: () => void;
}) {
@@ -292,7 +286,6 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
// YYX#row22:有未读生成任务才显示胶囊(0 不显示)
if (aiUnread && aiUnread > 0) badges["asset-factory"] = aiUnread;
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
// 类(CSS 在 design-restraint.css),并持久化到 localStorage。
@@ -319,20 +312,6 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
else if (topModule) setResourceOpen(false);
}, [activeMajor, topModule]);
// 账户下拉菜单:点侧栏 .user 用其 DOMRect 当锚点展开,再次点击收起(toggle)。
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
const toggleAccountMenu = (event: ReactMouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
setAccountAnchor((prev) => (prev ? null : rect));
};
// 退出兜底:父级未传 logout 时,自行 best-effort 调登出接口 + 清 token + 跳登录页。
const handleLogout = () => {
if (logout) { logout(); return; }
void api.logout().catch(() => undefined);
setToken(null);
window.location.href = "/login";
};
// 命令面板:Ctrl/Cmd K,以及顶栏搜索按钮
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
@@ -360,8 +339,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
<div className="sidebar-head">
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
<a className="brand" href="/dashboard" aria-label="影擎工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
</a>
</div>
<button
@@ -391,7 +370,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
}}
>
<span className="major-dot" aria-hidden="true" />
<IconKitSvg name="library" />
<Boxes size={16} strokeWidth={2} />
<span></span>
<span className="chev"><IconKitSvg name="chevronRight" /></span>
</button>
@@ -417,7 +396,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate("team"); }}
>
<span className="major-dot" aria-hidden="true" />
<IconKitSvg name="users" />
<UsersRound size={18} strokeWidth={2} />
<span></span>
</a>
<a
@@ -426,7 +405,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate("settings"); }}
>
<span className="major-dot" aria-hidden="true" />
<IconKitSvg name="settings" />
<Settings size={18} strokeWidth={2} />
<span></span>
</a>
</div>
@@ -446,34 +425,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
</nav>
</>
)}
<div className="aside-foot">
<div
className="user"
role="button"
tabIndex={0}
aria-haspopup="menu"
aria-expanded={accountAnchor !== null}
title="账户"
onClick={toggleAccountMenu}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleAccountMenu(event as unknown as ReactMouseEvent<HTMLElement>); } }}
>
<div className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</div>
<div className="em">{team?.name || user.username}</div>
</div>
</div>
</aside>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} canManageBilling={canManageBilling} />
{accountAnchor && (
<AccountMenu
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={handleLogout}
user={user}
team={team}
canManageBilling={canManageBilling}
/>
)}
</>
);
}
@@ -274,7 +274,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
<ChevronLeft size={14} /> {activeGroup.name}
</button>
) : "人物素材库"}
<span>// 火山素材登记 · @ 引用生成</span>
<span> · @ </span>
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
@@ -318,7 +318,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
<Upload size={13} /> {quickUploading ? "上传中…" : "上传图片"}
</button>
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> </button>
<span className="mono fc-lib-hint">// 自动进入默认素材组审核</span>
<span className="mono fc-lib-hint"></span>
</>
)}
</div>
@@ -326,7 +326,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
<div className="empty-state show">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截</p>
<p>一个角色一组:登记后可在提示词里 @ ,</p>
</div>
) : (
<div className="fc-lib-groups">
@@ -375,7 +375,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
) : (
<div className="fc-lib-name" title={group.name}>{group.name}</div>
)}
<div className="fc-lib-count mono">// {group.asset_count} 个素材</div>
<div className="fc-lib-count mono">{group.asset_count} </div>
<div className="fc-lib-ops">
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameGroup(group); }}><Pencil size={12} /></button>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); setEditingGroupId(null); setConfirmDeleteGroupId(group.id); }}><Trash2 size={12} /></button>
@@ -383,7 +383,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
{isDeleting ? (
<div className="fc-lib-delete-pop" onClick={(event) => event.stopPropagation()}>
<div className="fc-lib-delete-title"></div>
<div className="fc-lib-delete-note mono">// 组内素材也会删除</div>
<div className="fc-lib-delete-note mono"></div>
<div className="fc-lib-delete-actions">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setConfirmDeleteGroupId(null)}></button>
<button type="button" className="btn btn-sm fc-lib-delete-danger" disabled={deletingGroupId === group.id} onClick={() => void removeGroup(group)}>
@@ -415,13 +415,13 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => fileRef.current?.click()}>
<Upload size={13} /> {uploading ? "上传中…" : "上传素材"}
</button>
<span className="mono fc-lib-hint">// 审核通过(可用)后才能被生成引用</span>
<span className="mono fc-lib-hint">()</span>
</div>
{assets.length === 0 && !loading ? (
<div className="empty-state show">
<span className="ic-empty"><Upload size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 上传该角色的图片/视频/音频</p>
<p>//</p>
</div>
) : (
<div className="fc-lib-assets">
@@ -473,7 +473,7 @@ 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>}
{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>
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); setEditingAssetId(null); setConfirmDeleteAssetId(item.id); }}><Trash2 size={12} /></button>
@@ -10,6 +10,19 @@ function ratioStyle(ratio: string) {
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
}
function ratioCopy(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
if (!w || !h) return ratio;
if (w === h) return `方形 ${ratio}`;
return `${w > h ? "横屏" : "竖屏"} ${ratio}`;
}
function statusTag(status: string) {
if (status === "succeeded") return { cls: "green", label: "已完成" };
if (status === "failed" || status === "cancelled") return { cls: "danger", label: status === "cancelled" ? "已取消" : "失败" };
return { cls: "blue", label: STATUS_LABELS[status] || "生成中" };
}
export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: {
task: FreeVideoTask;
progress: number;
@@ -23,12 +36,13 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
const inFlight = isInFlight(task.status);
const failed = task.status === "failed" || task.status === "cancelled";
const done = task.status === "succeeded" && !!task.video_url;
const tag = statusTag(task.status);
const errorText = isPublicGenerationError(task.error)
? (() => { const presentation = presentGenerationError(task.error); return `${presentation.title}${presentation.description}`; })()
: task.error_message || "视频生成失败,请重试";
return (
<div className={`fc-card${done ? " done" : ""}`}>
<article className={`fc-card${done ? " done" : ""}`}>
<div
className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
@@ -43,18 +57,18 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
<div className="fc-card-generating">
<span className="spinner" />
<div className="fc-progress"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
<span className="mono">{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
<span>{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
</div>
)}
{failed && (
<div className="fc-card-failed">
<span className="pill pill-l2 pill-err"><span className="dot" /></span>
<span className="fc-tag danger"></span>
<p>{errorText}</p>
<div className="hstack">
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<div className="fc-failed-actions">
<button type="button" className="fc-mini" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
<RotateCcw size={13} />
</button>
<button type="button" className="btn btn-sm btn-ghost" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<button type="button" className="fc-mini" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<Trash2 size={13} />
</button>
</div>
@@ -71,6 +85,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
playsInline
preload="metadata"
/>
<span className="fc-dur">{task.duration}s</span>
<div className="fc-card-hover">
<button type="button" className="fc-hover-btn" title="下载" onClick={(event) => { event.stopPropagation(); onDownload(); }}>
<Download size={14} />
@@ -91,12 +106,16 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
)}
</div>
<div className="fc-card-meta">
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
<div className="fc-card-sub mono">
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
{task.status === "succeeded" && ` · ${Number(task.actual_cost || 0)} 积分`}
<h3 title={task.prompt}>{task.prompt || "未命名视频"}</h3>
<p>{MODE_LABELS[task.mode] || task.mode} · {task.duration} · {ratioCopy(task.aspect_ratio)}</p>
<div className="fc-card-line">
<span className={`fc-tag ${tag.cls}`}>{tag.label}</span>
<span className="fc-tag">{modelLabel(task.model)}</span>
</div>
{inFlight && (
<div className="fc-progress-mini"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
)}
</div>
</div>
</article>
);
}
@@ -1,7 +1,7 @@
// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。
// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。
import { useRef, useState, type RefObject } from "react";
import { IconKitSvg } from "../IconKitSvg";
import { ImagePlus, Images, UserRoundPlus } from "lucide-react";
import type { ModelConfig } from "../../types";
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
import { PromptInput, type PromptInputHandle } from "./prompt-input";
@@ -16,11 +16,11 @@ function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void })
{item.type === "image" ? (
<img src={item.thumb_url || item.url} alt={item.label || "参考图"} />
) : item.type === "video" ? (
item.thumb_url ? <img src={item.thumb_url} alt={item.label || "参考视频"} /> : <span className="fc-ref-kind mono">MP4</span>
item.thumb_url ? <img src={item.thumb_url} alt={item.label || "参考视频"} /> : <span className="fc-ref-kind">MP4</span>
) : (
<span className="fc-ref-kind mono"></span>
<span className="fc-ref-kind"></span>
)}
{item.type !== "image" && item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
{item.type !== "image" && item.duration ? <span className="fc-ref-dur">{item.duration}s</span> : null}
{item.uploading && <span className="spinner" />}
{item.label ? <span className="fc-ref-label">@{item.label}</span> : null}
<button type="button" className="fc-ref-x" aria-label="删除素材" onClick={onRemove}>×</button>
@@ -39,7 +39,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
return (
<div className="fc-kf-slot-wrap">
<button type="button" className="fc-kf-slot" onClick={onPickLibrary}>
<IconKitSvg name="images" size={20} />
<Images size={18} />
<span>{label}</span>
</button>
</div>
@@ -50,7 +50,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
<button type="button" className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`} title={`从人物素材库替换${label}`} onClick={onPickLibrary}>
<img src={item.thumb_url || item.url} alt={label} />
{item.uploading && <span className="spinner" />}
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
<span className="fc-kf-tag">{role === "first_frame" ? "首" : "尾"}</span>
</button>
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
<button type="button" className="fc-kf-lib" title={`从人物素材库替换${label}`} aria-label={`从人物素材库替换${label}`} onClick={onPickLibrary}>
@@ -104,7 +104,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
return (
<div
className={`fc-inputbar${dragOver ? " drag" : ""}`}
className={`fc-composer${dragOver ? " drag" : ""}`}
onDragOver={(event) => { event.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
@@ -125,18 +125,17 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
pendingRoleRef.current = undefined;
}}
/>
<div className="fc-input-top">
<div className="fc-prompt-main">
{mode === "universal" ? (
<div className="fc-refs">
<button type="button" className="fc-add" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
<IconKitSvg name="images" size={18} />
<span>+</span>
<button type="button" className="fc-ref-btn" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
<ImagePlus />
</button>
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
<IconKitSvg name="users" size={18} />
<button type="button" className="fc-ref-btn" title="人物素材库" onClick={() => onOpenLibrary()}>
<UserRoundPlus />
</button>
<button type="button" className="fc-add fc-lib" title="引用平台素材(资产库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
<IconKitSvg name="library" size={18} />
<button type="button" className="fc-ref-btn" title="引用平台素材(成品库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
<Images />
</button>
{refs.map((item) => (
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
@@ -145,7 +144,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
) : (
<div className="fc-keyframes">
<KeyframeSlot role="first_frame" item={firstFrame} onPickLibrary={() => onOpenLibrary("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
<span className="fc-kf-arrow mono"></span>
<span className="fc-kf-arrow"></span>
<KeyframeSlot role="last_frame" item={lastFrame} onPickLibrary={() => onOpenLibrary("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
</div>
)}
@@ -155,7 +154,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onSubmit={onSend}
onOpenLibrary={mode === "universal" ? () => onOpenLibrary() : undefined}
onTextChange={setHasPrompt}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频@ 可引用参考素材,例如:产品在晨光下缓慢旋转,镜头推近展示包装细节……"}
/>
</div>
<FreeToolbar
@@ -179,7 +178,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onClear={onClear}
onSend={onSend}
/>
{dragOver && <div className="fc-drop-hint mono">{MODE_LABELS[mode]}</div>}
{dragOver && <div className="fc-drop-hint">{MODE_LABELS[mode]}</div>}
</div>
);
}
@@ -10,7 +10,7 @@ import { useBodyScrollLock } from "../overlays";
type LibTab = "assets" | "models" | "products";
const TAB_LABEL: Record<LibTab, string> = { assets: "资产库", models: "模特库", products: "商品库" };
const TAB_LABEL: Record<LibTab, string> = { assets: "成品库", models: "模特库", products: "商品库" };
const PAGE_SIZE = 24;
/** apps/assets/review.py reference_review_state :
@@ -213,7 +213,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
const emptyText = useMemo(() => {
if (tab === "models") return "还没有模特";
if (tab === "products") return openProduct ? "这个商品还没有素材" : "还没有商品";
return "资产库里还没有可引用的素材";
return "成品库里还没有可引用的素材";
}, [tab, openProduct]);
if (!open) return null;
@@ -239,7 +239,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
<div className="fc-lib-name" title={asset.display_name || asset.name}>{asset.display_name || asset.name}</div>
{pill
? <span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
: <span className="fc-lib-count mono">// {asset.source === "upload" ? "已过审" : "平台生成"}</span>}
: <span className="fc-lib-count mono">{asset.source === "upload" ? "已过审" : "平台生成"}</span>}
{state === "unsubmitted" && (
<button
type="button"
@@ -266,7 +266,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
<ChevronLeft size={14} /> {openProduct.title}
</button>
) : "引用平台素材"}
<span>// 资产库 · 模特库 · 商品库</span>
<span> · · </span>
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
@@ -296,7 +296,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
</label>
)}
<span className="mono fc-lib-hint">
{imageOnly ? "// 首尾帧只列图片素材" : "// 平台生成的直接可用,上传的要审核通过"}
{imageOnly ? "首尾帧只列图片素材" : "平台生成的直接可用,上传的要审核通过"}
</span>
</div>
@@ -305,7 +305,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
<div className="empty-state show">
<span className="ic-empty"><User size={22} strokeWidth={1.5} /></span>
<h3>{emptyText}</h3>
<p>// 去模特库添加模特后可在这里引用形象图与三视图</p>
<p></p>
</div>
) : (
<div className="fc-lib-assets">
@@ -319,7 +319,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
<div className="fc-lib-name" title={model.name}>{model.name}</div>
<div className="fc-lib-model-picks">
{items.length === 0
? <span className="fc-lib-count mono">// 还没有可引用的图</span>
? <span className="fc-lib-count mono"></span>
: items.map((item) => (
<button key={item.id} type="button" className="btn btn-sm" onClick={() => pickModelAsset(model, item)}>
{item.tag}
@@ -336,7 +336,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
<div className="empty-state show">
<span className="ic-empty"><Package size={22} strokeWidth={1.5} /></span>
<h3>{emptyText}</h3>
<p>// 去商品库建商品后可在这里引用它的素材</p>
<p></p>
</div>
) : (
<div className="fc-lib-assets">
@@ -348,7 +348,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
{product.cover_preview_url ? <img src={product.cover_preview_url} alt={product.title} /> : <Package size={20} />}
</div>
<div className="fc-lib-name" title={product.title}>{product.title}</div>
<div className="fc-lib-count mono">// {product.category || "未分类"}</div>
<div className="fc-lib-count mono">{product.category || "未分类"}</div>
</div>
))}
</div>
@@ -357,7 +357,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
<div className="empty-state show">
<span className="ic-empty"><Images size={22} strokeWidth={1.5} /></span>
<h3>{emptyText}</h3>
<p>// 平台生成的素材可直接引用,上传的要先审核通过</p>
<p>,</p>
</div>
) : (
<>
@@ -321,7 +321,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
style={{ left: Math.min(menuPos.left, window.innerWidth - 292), top: Math.max(12, menuPos.top - 8) }}
>
<div className="fc-mention-menu-inner">
<div className="fc-mention-menu-head mono">// 引用素材</div>
<div className="fc-mention-menu-head mono"></div>
{menuItems.map((item, i) => (
<button
key={item.key}
@@ -1,6 +1,7 @@
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
import { useEffect, useRef, useState } from "react";
import { ArrowRight, ChevronDown } from "lucide-react";
import type { ModelConfig } from "../../types";
import {
DEFAULT_BILLING_RATES,
@@ -39,10 +40,9 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
}, [open]);
return (
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
<button type="button" className="fc-dd-btn" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
<span className="fc-dd-lbl mono">{label}</span>
<span className="fc-dd-val">{display}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
<button type="button" className="fc-chip" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
{label} <strong>{display}</strong>
<ChevronDown />
</button>
{open && (
<div className="fc-dd-menu">
@@ -56,7 +56,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
onClick={() => { if (!item.disabled) { onSelect(item.value); setOpen(false); } }}
>
<span className="ti">{item.label}</span>
{item.desc && <span className="de mono">{item.desc}</span>}
{item.desc && <span className="de">{item.desc}</span>}
</button>
))}
</div>
@@ -103,14 +103,13 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
return (
<div className="fc-toolbar">
<div className="fc-toolbar-l">
<div className="fc-chips">
<FcDropdown
label="模型"
display={FC_MODELS.find((m) => m.name === model)?.label || model}
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))}
onSelect={(value) => {
onModelChange(value);
// 非标准档不支持 1080P/4K:自动回落 720P(与 jimeng 行为一致)
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p");
}}
/>
@@ -142,9 +141,8 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
onSelect={(value) => onDurationChange(Number(value))}
/>
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
<button type="button" className="fc-dd-btn" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
<span className="fc-dd-lbl mono"></span>
<span className="fc-dd-val">{seed === -1 ? "随机" : seed}</span>
<button type="button" className="fc-chip" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
<strong>{seed === -1 ? "随机" : seed}</strong>
</button>
{seedOpen && (
<div className="fc-dd-menu fc-seed-menu">
@@ -161,18 +159,19 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
/>
<button type="button" className="btn btn-sm" onClick={() => { onSeedChange(-1); setSeedOpen(false); }}></button>
</div>
<div className="fc-seed-hint mono">// -1 = 随机;相同种子可复现相似结果</div>
<div className="fc-seed-hint">-1 = ;</div>
</div>
)}
</div>
</div>
<div className="fc-toolbar-r">
<span className="fc-estimate mono" title="预估消耗(实际按真实用量结算,多退少不补超)">
<span className="fc-estimate" title="预估消耗(实际按真实用量结算,多退少不补超)">
{tokens.toLocaleString()} tokens · {points.toLocaleString()}
</span>
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}></button>
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
<button type="button" className="fc-clear" onClick={onClear}></button>
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
<ArrowRight />
</button>
</div>
</div>
@@ -144,7 +144,7 @@ export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClo
{task.seed_used != null && ` · seed ${task.seed_used}`}
{` · ${Number(task.actual_cost || 0)} 积分`}
</div>
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
{task.fallback_note && <div className="fc-player-warn mono">{task.fallback_note}</div>}
<div className="fc-player-actions">
<button type="button" className="btn btn-sm" onClick={onDownload}><Download size={13} /> </button>
<button type="button" className="btn btn-sm" onClick={onReuse}><RotateCcw size={13} /> </button>
+3 -3
View File
@@ -52,7 +52,7 @@ export function SystemLoading({
) : null}
<div className="system-loading-foot mono">
<span>{reference ? `// REF · ${reference}` : "// DATA SYNC"}</span>
<span>{reference ? `REF · ${reference}` : "DATA SYNC"}</span>
<span>{state === "error" ? "RETRY AVAILABLE" : "DATA HYDRATION"}</span>
</div>
</div>
@@ -64,7 +64,7 @@ export function SystemLoading({
export function SkeletonGrid({ count = 8 }: { count?: number }) {
return (
<div>
<div className="skeleton-loading-tag">// 加载中…</div>
<div className="skeleton-loading-tag"></div>
<div className="skeleton-grid">
{Array.from({ length: count }).map((_, i) => (
<div className="skeleton-block skeleton-card" key={i} />
@@ -77,7 +77,7 @@ export function SkeletonGrid({ count = 8 }: { count?: number }) {
export function SkeletonRows({ count = 6 }: { count?: number }) {
return (
<div>
<div className="skeleton-loading-tag">// 加载中…</div>
<div className="skeleton-loading-tag"></div>
<div className="skeleton-rows">
{Array.from({ length: count }).map((_, i) => (
<div className="skeleton-block skeleton-line" key={i} />
@@ -123,7 +123,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
<div className="actorlib" role="dialog" aria-modal="true" aria-label="模特库">
<div className="actorlib-h">
<h2>{mode === "replace" ? "模特库 · 选择模特" : "模特库"}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}</span>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}</span>
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
@@ -147,7 +147,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
<div className="actorlib-studio-left">
{studioMode === "ai" ? (
<div className="actorlib-studio-ai">
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 描述模特形象(年龄 / 风格 / 妆造 / 背景)→ 生成候选</div>
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 / / / </div>
<textarea className="asset-prompt-edit" rows={3} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genCandidates()}>
{busy ? "生成中…" : "生成立绘"}
@@ -155,18 +155,18 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
</div>
) : (
<div className="actorlib-studio-upload">
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地模特形象图 → 右侧命名并保存</div>
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 </div>
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div>
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>JPG / PNG / WEBP</div>
</div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
</div>
)}
{candidates.length > 0 && (
<>
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张形象图 → 右侧命名并保存模特</div>
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>2 </div>
<div className="actorlib-grid">
{candidates.map((a) => {
const url = previewOf(a);
@@ -192,7 +192,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
<div className={`placeholder actor-thumb${previewOf(picked) ? " has-mock-media" : ""}`} style={previewOf(picked) ? mediaStyle(previewOf(picked)) : undefined}>
{!previewOf(picked) && <span className="ph-frame">{picked.name}</span>}
</div>
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>// 3 给模特命名</label>
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>3 </label>
<input className="asset-prompt-edit" style={{ minHeight: 0, height: 36 }} placeholder="如:都市白领 · 小林" value={modelName} onChange={(e) => setModelName(e.target.value)} />
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveModel()}>
{busy ? "保存中…" : "保存模特"}
@@ -200,7 +200,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
</div>
) : (
<div className="placeholder" style={{ minHeight: 220, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// {studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
<span className="ph-frame">{studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
</div>
)}
</div>
@@ -209,7 +209,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
) : (
<div className="actorlib-body">
<div className="actorlib-toolbar">
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 共 {list.length} 位模特</span>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}> {list.length} </span>
<span className="spacer" style={{ flex: 1 }}></span>
<button className="btn btn-primary btn-sm" type="button" onClick={() => { setStudio(true); setStudioMode("ai"); }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 5v14M5 12h14" /></svg>
@@ -245,7 +245,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
</>
) : (
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
<span className="ph-frame">// 模特库暂无模特 · 点右上“添加模特”创建</span>
<span className="ph-frame"> · </span>
</div>
)}
</div>
+4 -4
View File
@@ -128,7 +128,7 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
);
}
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "// CONFIRM", icon, onCancel, onConfirm, dismissable = true }: {
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true }: {
open: boolean;
title: string;
detail: string;
@@ -148,7 +148,7 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "//
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}<span>{subtitle}</span></div></div>
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}{subtitle ? <span>{subtitle}</span> : null}</div></div>
<div className="modal-b">{detail}</div>
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}></button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
</div>
@@ -172,7 +172,7 @@ export function SuccessModal({ open, title, detail, actions, close }: {
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}<span>// SUCCESS</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}</div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="modal-b">{detail}</div>
<div className="modal-f">{actions}</div>
</div>
@@ -199,5 +199,5 @@ export function Drawer({ title, open, close, children }: { title: string; open:
}
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
return <div className="empty-state show"><span className="ic-empty"><Inbox size={22} strokeWidth={1.5} aria-hidden="true" /></span><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
return <div className="empty-state show"><span className="ic-empty"><Inbox size={22} strokeWidth={1.5} aria-hidden="true" /></span><h3>{title}</h3><p></p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageS
return (
<div className={`list-pager${sticky ? " sticky" : ""}`}>
<span className="total">// 共 {total} 条 · 第 {cur} / {totalPages} 页</span>
<span className="total"> {total} · {cur} / {totalPages} </span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => onChange(cur - 1)} aria-label="上一页"></button>
{pageWindow(cur, totalPages).map((p, i) => (
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, DragEvent } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft, Check, ChevronDown, Image as ImageIcon, Plus, ShieldCheck, Trash2, Upload, X } from "lucide-react";
import { api } from "../api";
import { useBodyScrollLock, ConfirmModal } from "./overlays";
import type { Product } from "../types";
@@ -21,22 +22,22 @@ export type ProductCreatePayload = {
category: string;
target_audience?: string;
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
// upload-first:图先进桶,创建时带 asset id 一块进来(cover_asset=主图,images=全部)
cover_asset?: string;
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
};
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
const PF_MAX = 5;
const PF_MAX = 9;
const PF_MAX_BYTES = 10 * 1024 * 1024;
// 抽屉内主图条目:拖入即传 /assets/upload/ 进桶,assetId 是返回的素材 id;url 仅作本地缩略预览
type PfImage = { id: string; file: File; url: string; assetId?: string; status: "uploading" | "done" | "error" };
const pfUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。
// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为
// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。
function padIndex(index: number) {
return String(index + 1).padStart(2, "0");
}
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现。
export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptions = PC_CAT_OPTIONS }: {
open: boolean;
close: () => void;
@@ -49,34 +50,32 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
useBodyScrollLock(open);
const [title, setTitle] = useState("");
const [businessType, setBusinessType] = useState<BusinessType>("ecommerce");
const [category, setCategory] = useState(PC_CAT_OPTIONS[0]);
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
const [bullets, setBullets] = useState<string[]>([]);
const [bullets, setBullets] = useState<string[]>([""]);
const [images, setImages] = useState<PfImage[]>([]);
const [dragOver, setDragOver] = useState(false);
const [titleError, setTitleError] = useState(false);
const [showGuide, setShowGuide] = useState(false);
const [categoryError, setCategoryError] = useState(false);
const [saving, setSaving] = useState(false);
// 抽屉内就地 toast(组件无全局 Shell.toast,这里自给一条,镜像 V1 反馈文案)
const [galleryOpen, setGalleryOpen] = useState(false);
const [openSelect, setOpenSelect] = useState<"" | "type" | "cat">("");
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
// YYX#24:填了内容中途退出 → 二次确认
const [confirmExit, setConfirmExit] = useState(false);
const imgInputRef = useRef<HTMLInputElement | null>(null);
const nameRef = useRef<HTMLInputElement | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
const imagesRef = useRef<PfImage[]>([]);
imagesRef.current = images;
const isLocal = businessType === "local_life";
const activeCatOptions = isLocal ? catOptionsFor(businessType) : (catOptions.length ? catOptions : PC_CAT_OPTIONS);
function flash(title: string, sub: string) {
function flash(nextTitle: string, sub: string) {
if (toastTimer.current) clearTimeout(toastTimer.current);
setToast({ title, sub });
setToast({ title: nextTitle, sub });
toastTimer.current = setTimeout(() => setToast(null), 2600);
}
// 卸载时清掉 toast 定时器 + 释放残留的预览 objectURL
useEffect(() => () => {
if (toastTimer.current) clearTimeout(toastTimer.current);
}, []);
@@ -84,33 +83,51 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
function resetForm() {
setTitle("");
setBusinessType("ecommerce");
setCategory(PC_CAT_OPTIONS[0]);
setCategory("");
setTarget("");
setBullets([]);
setBullets([""]);
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
setDragOver(false);
setTitleError(false);
setCategoryError(false);
setGalleryOpen(false);
setOpenSelect("");
}
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
useEffect(() => { if (open) resetForm(); }, [open]);
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
useEffect(() => {
if (!open) {
setGalleryOpen(false);
return;
}
resetForm();
const timer = window.setTimeout(() => nameRef.current?.focus(), 220);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
if (!openSelect) return;
const closeMenu = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".pcd-select")) setOpenSelect("");
};
document.addEventListener("click", closeMenu);
return () => document.removeEventListener("click", closeMenu);
}, [openSelect]);
function isDirty() {
return Boolean(title.trim() || businessType !== "ecommerce" || target.trim() || bullets.some((b) => b.trim()) || images.length);
return Boolean(title.trim() || businessType !== "ecommerce" || category || target.trim() || bullets.some((b) => b.trim()) || images.length);
}
function changeBusinessType(next: BusinessType) {
setBusinessType(next);
const nextOpts = catOptionsFor(next);
if (!nextOpts.includes(category)) setCategory(nextOpts[0]);
if (category && !nextOpts.includes(category)) setCategory("");
}
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
function guardedClose() {
if (isDirty()) setConfirmExit(true);
else close();
}
// upload-first:任一图处于「uploading」且未在途 → 立刻传进桶(/assets/upload/),拿到 assetId 标 done
async function uploadOne(entry: PfImage) {
try {
const fd = new FormData();
@@ -125,18 +142,20 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}
}
// 多图收编:超限/计数 toast 对齐 V1 pfAdd()。★ 必须同步读出 File(Array.from 立刻取),
// 否则 pickImage 紧跟着的 input.value="" 会清空 input.files,延迟读就成空 → 第一次点没反应。
function addImages(fileList: FileList | File[] | null) {
if (!fileList) return;
const picked = Array.from(fileList).filter((file) => file.type.startsWith("image/"));
if (picked.length === 0) return;
const oversized = picked.filter((file) => file.size > PF_MAX_BYTES);
const sized = picked.filter((file) => file.size <= PF_MAX_BYTES);
const room = PF_MAX - imagesRef.current.length;
if (room <= 0) { flash("已达上限", `${PF_MAX} / ${PF_MAX}`); return; }
const incoming = picked.slice(0, room).map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file), status: "uploading" as const }));
if (oversized.length) flash("已忽略超大图片", "单张不超过 10MB");
const incoming = sized.slice(0, room).map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file), status: "uploading" as const }));
if (!incoming.length) return;
setImages((list) => [...list, ...incoming]);
flash("上传中", `+ ${incoming.length} 张 · 共 ${imagesRef.current.length + incoming.length} / ${PF_MAX}`);
incoming.forEach((entry) => void uploadOne(entry)); // 立即发起上传,不靠 effect 时序
incoming.forEach((entry) => void uploadOne(entry));
}
function pickImage(event: ChangeEvent<HTMLInputElement>) {
addImages(event.target.files);
@@ -144,12 +163,20 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}
function removeImage(id: string) {
setImages((list) => {
const hit = list.find((item) => item.id === id);
if (hit) URL.revokeObjectURL(hit.url);
return list.filter((item) => item.id !== id);
const next = list.filter((item) => {
if (item.id !== id) return true;
URL.revokeObjectURL(item.url);
return false;
});
if (!next.length) setGalleryOpen(false);
return next;
});
}
// YYX#22:上传失败后「重新上传」—— 把该图置回 uploading 再发一次
function clearImages() {
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
setGalleryOpen(false);
flash("已删除上传图片", "可重新选择商品图");
}
function retryUpload(id: string) {
const hit = imagesRef.current.find((item) => item.id === id);
if (!hit) return;
@@ -161,7 +188,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
imgInputRef.current?.click();
}
// 拖拽上传:dragover 高亮边框,drop 收图(对齐 V1 pfZone 事件)
function onZoneDragOver(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(true); }
function onZoneDragLeave(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(false); }
function onZoneDrop(event: DragEvent<HTMLDivElement>) {
@@ -170,7 +196,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files);
}
// 点「添加卖点」→ 追加一条空的可编辑卖点行(显式新增,不靠回车隐晦添加)
function addBullet() {
setBullets((list) => [...list, ""]);
}
@@ -178,24 +203,26 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
setBullets((list) => list.map((item, position) => (position === index ? value : item)));
}
function removeBullet(index: number) {
setBullets((list) => list.filter((_, position) => position !== index));
setBullets((list) => (list.length <= 1 ? list : list.filter((_, position) => position !== index)));
}
async function submit() {
const name = title.trim();
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
// 电商必须有主图;本地生活团购是虚拟商品,允许不传图。
if (!isLocal && images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
const missing: string[] = [];
if (!name) { setTitleError(true); missing.push("商品名称"); }
if (!category) { setCategoryError(true); missing.push("商品品类"); }
const finalBullets = bullets.map((item) => item.trim()).filter(Boolean);
if (finalBullets.length === 0) missing.push("核心卖点");
if (!isLocal && images.length === 0) missing.push("商品图片");
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
// 卖点行可能有空行(用户点了「添加卖点」还没填):提交时过滤掉空行再去重判空
const finalBullets = bullets.map((item) => item.trim()).filter(Boolean);
if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条"); return; }
if (missing.length) {
flash("请完善必填信息", missing.join("、"));
return;
}
setSaving(true);
try {
// 图已在桶里,创建时直接带上 cover_asset(主图)+ images(全部),一步落库、不再二次传图
const created = (await onCreate({
title: name,
business_type: businessType,
@@ -209,7 +236,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}
: {}),
})) || null;
if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示
if (!created) { flash("商品创建失败", "请稍后重试"); return; }
close();
onCreated?.(created);
} finally {
@@ -217,166 +244,255 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
}
}
const previewName = images.length === 0
? "尚未上传"
: images.length === 1
? images[0].file.name
: `已上传 ${images.length} 张 · 点击查看九宫格`;
const uploading = images.some((im) => im.status === "uploading");
return createPortal(
<>
<div className={`drawer-bg${open ? " show" : ""}`} onClick={guardedClose} />
<aside className={`drawer pc-drawer${open ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!open}>
<div className="drawer-h">
<h3></h3>
<button className="x" type="button" onClick={guardedClose} aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="drawer-b">
<div className="form-card">
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className={`input${titleError ? " is-error" : ""}`} value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} />
{titleError && <div className="pc-err-note"></div>}
<div className={`pcd-layer${open ? " open" : ""}`} aria-hidden={!open}>
<button type="button" className="pcd-scrim" aria-label="关闭新建商品面板" onClick={guardedClose} />
<aside className="pcd-drawer" role="dialog" aria-modal="true" aria-labelledby="productDrawerTitle">
<header className="pcd-head">
<div>
<h2 id="productDrawerTitle"></h2>
<p></p>
</div>
<button type="button" className="pcd-close" aria-label="关闭" onClick={guardedClose}><X /></button>
</header>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={businessType} onChange={(event) => changeBusinessType(event.target.value as BusinessType)}>
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
</select>
{isLocal && <div className="pc-type-hint mono">// 团购按虚拟商品走主流程,无需实物三视图</div>}
</div>
<div className="pcd-body">
<div className="pcd-form">
<label className="pcd-field">
<span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span>
<input
ref={nameRef}
value={title}
maxLength={100}
aria-invalid={titleError}
placeholder="例如:净颜精华、轻醒咖啡"
onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }}
/>
</label>
<div className="field-row">
<div>
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
{activeCatOptions.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
<div>
<label className="field-label"><span className="opt">()</span></label>
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder={isLocal ? "例: 附近上班族、带娃家庭、周末聚餐" : "例: 22-32 岁女性、敏感肌、办公室通勤"} />
</div>
</div>
<div className="field">
<label className="field-label">{isLocal ? "门店图 / 套餐海报" : "商品主图"}{isLocal ? <span className="opt">()</span> : <span className="req">*</span>}</label>
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
<div className="pf-upload-row">
<div
className={`pf-upload-zone${dragOver ? " is-dragover" : ""}`}
role="button"
tabIndex={0}
onClick={openPicker}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
onDragOver={onZoneDragOver}
onDragLeave={onZoneDragLeave}
onDrop={onZoneDrop}
>
<div className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</div>
<div className="uz-t"><strong></strong></div>
<div className="uz-d">{isLocal ? "// 店招、套餐海报均可 · 团购无实物可不传" : "// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB"}</div>
</div>
<div className="pf-example">
<div className="ex-h"></div>
<div className="ex-grid">
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
</div>
<div className="ex-d">{isLocal ? "有门店或套餐图,后续脚本和出图更准" : "优质的商品图有助于生成更好的素材效果"}</div>
</div>
</div>
<div className="pf-grid">
{images.map((item, idx) => (
<div className={`pf-thumb pf-${item.status}`} key={item.id}>
<img src={item.url} alt="商品主图预览" />
{idx === 0 && <span className="pf-cover"></span>}
{item.status === "uploading" && <span className="pf-state"><span className="spinner" aria-hidden="true" /></span>}
{/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */}
{item.status === "error" && (
<div className="pf-fail-mask">
<span className="pf-fail-ic" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>
</span>
<span className="pf-fail-t"></span>
<button className="pf-retry" type="button" onClick={() => retryUpload(item.id)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" /></svg>
<div className="pcd-row">
<div className="pcd-field">
<span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span>
<div className={`pcd-select${openSelect === "type" ? " open" : ""}`}>
<button type="button" className="pcd-select-trigger" aria-haspopup="listbox" aria-expanded={openSelect === "type"} onClick={() => setOpenSelect((c) => (c === "type" ? "" : "type"))}>
<span>{BUSINESS_TYPES[businessType]}</span>
<ChevronDown />
</button>
<div className="pcd-select-menu" role="listbox">
{BUSINESS_TYPE_KEYS.map((key) => (
<button type="button" className={`pcd-select-option${businessType === key ? " selected" : ""}`} key={key} onClick={() => { changeBusinessType(key); setOpenSelect(""); }}>
{BUSINESS_TYPES[key]}
</button>
</div>
)}
<button className="pf-x" type="button" title="删除" aria-label="删除该图" onClick={() => removeImage(item.id)}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
))}
</div>
</div>
{isLocal && <span className="pcd-hint"></span>}
</div>
<div className="pcd-field">
<span className="pcd-label"><span> <span className="pcd-req">*</span></span><small></small></span>
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}`}>
<button type="button" className={`pcd-select-trigger${categoryError ? " is-error" : ""}`} aria-haspopup="listbox" aria-expanded={openSelect === "cat"} onClick={() => setOpenSelect((c) => (c === "cat" ? "" : "cat"))}>
<span className={category ? "" : "is-placeholder"}>{category || "请选择商品品类"}</span>
<ChevronDown />
</button>
<div className="pcd-select-menu" role="listbox">
{activeCatOptions.map((option) => (
<button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
{option}
</button>
))}
</div>
</div>
</div>
</div>
<label className="pcd-field">
<span className="pcd-label"><span></span><small></small></span>
<input
value={target}
onChange={(event) => setTarget(event.target.value)}
placeholder={isLocal ? "例如:附近上班族、带娃家庭、周末聚餐" : "例如:22–32 岁女性、敏感肌、通勤人群"}
/>
</label>
<section className="pcd-block" aria-labelledby="productImageTitle">
<div className="pcd-block-head">
<div>
<strong id="productImageTitle">{isLocal ? "门店图 / 套餐海报" : "商品图片"}{isLocal ? "" : <span className="pcd-req"> *</span>}</strong>
<p></p>
</div>
<span>JPG / PNG · 10MB</span>
</div>
<input ref={imgInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple hidden onChange={pickImage} />
<div className="pcd-upload-grid">
<div
className={`pcd-upload${dragOver ? " is-dragover" : ""}`}
role="button"
tabIndex={0}
onClick={openPicker}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
onDragOver={onZoneDragOver}
onDragLeave={onZoneDragLeave}
onDrop={onZoneDrop}
>
<span className="pcd-upload-hit">
<span className="pcd-upload-icon"><Upload /></span>
<strong></strong>
<small>{isLocal ? "店招、套餐海报均可 · 团购无实物可不传" : "最多九张,建议使用清晰商品实拍图"}</small>
</span>
</div>
<div className="pcd-preview">
<div
className={`pcd-preview-frame${images.length ? " has-image" : ""}${images.length > 1 ? " multi-image" : ""}`}
role="button"
tabIndex={images.length ? 0 : -1}
aria-label="打开商品图片九宫格预览"
onClick={() => { if (images.length) setGalleryOpen(true); }}
onKeyDown={(event) => { if ((event.key === "Enter" || event.key === " ") && images.length) { event.preventDefault(); setGalleryOpen(true); } }}
>
{images.length === 1 && <img src={images[0].url} alt={images[0].file.name} />}
{images.length > 1 && (
<div className="pcd-thumb-grid">
{images.slice(0, 4).map((item) => (
<span className="pcd-thumb" key={item.id}>
<img src={item.url} alt={item.file.name} />
<button type="button" className="pcd-thumb-x" aria-label={`删除图片 ${item.file.name}`} onClick={(event) => { event.stopPropagation(); removeImage(item.id); }}>
<X />
</button>
{item.status === "uploading" && <span className="pcd-thumb-state"><span className="spinner" aria-hidden="true" /></span>}
{item.status === "error" && (
<button type="button" className="pcd-thumb-retry" onClick={(event) => { event.stopPropagation(); retryUpload(item.id); }}></button>
)}
</span>
))}
</div>
)}
{images.length > 1 && <span className="pcd-count">{images.length} </span>}
{images.length === 1 && (
<button type="button" className="pcd-clear" aria-label="删除已上传图片" onClick={(event) => { event.stopPropagation(); clearImages(); }}>
<Trash2 />
</button>
)}
{images.length === 1 && images[0].status === "uploading" && <span className="pcd-thumb-state"><span className="spinner" aria-hidden="true" /></span>}
{images.length === 1 && images[0].status === "error" && (
<button type="button" className="pcd-retry-cover" onClick={(event) => { event.stopPropagation(); retryUpload(images[0].id); }}></button>
)}
{!images.length && (
<span className="pcd-preview-ph">
<ImageIcon />
<span></span>
</span>
)}
</div>
<span>{previewName}</span>
</div>
</div>
</section>
<section className="pcd-block" aria-labelledby="sellingPointTitle">
<div className="pcd-block-head pcd-sp-head">
<div>
<strong id="sellingPointTitle"> <span className="pcd-req">*</span></strong>
<p> Enter </p>
</div>
<button type="button" className="pcd-add-point" onClick={addBullet}>
<Plus />
<span></span>
</button>
</div>
<div className="pcd-points">
{bullets.map((bullet, index) => (
<div className="pcd-point" key={index}>
<span className="pcd-point-idx">{padIndex(index)}</span>
<input
value={bullet}
placeholder={index === 0
? (isLocal ? "例如:双人下午茶套餐,含蛋糕+饮品" : "例如:7 天改善干燥,敏感肌可用")
: "继续填写一个核心卖点"}
onChange={(event) => updateBullet(index, event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
/>
<button type="button" className="pcd-point-x" aria-label="删除卖点" disabled={bullets.length <= 1} onClick={() => removeBullet(index)}>
<Trash2 />
</button>
</div>
))}
</div>
</section>
</div>
</div>
<footer className="pcd-foot">
<span><ShieldCheck /></span>
<div className="pcd-actions">
<button type="button" className="pcd-ghost" onClick={guardedClose}></button>
<button type="button" className="pcd-primary" disabled={saving || uploading} onClick={submit}>
<Check />
<span>{saving ? "创建中…" : uploading ? "图片上传中…" : "创建商品"}</span>
</button>
</div>
</footer>
{toast && (
<div className="pcd-toast" role="status" aria-live="polite">
<span className="pcd-toast-ic"><Check /></span>
<div>{toast.title}<small>{toast.sub}</small></div>
</div>
)}
</aside>
</div>
{galleryOpen && (
<div className="pcd-gallery" role="dialog" aria-label="商品图片预览">
<header className="pcd-gallery-head">
<div>
<h2></h2>
<p></p>
</div>
<button type="button" className="pcd-gallery-back" onClick={() => setGalleryOpen(false)}>
<ArrowLeft />
<span></span>
</button>
</header>
<div className="pcd-gallery-body">
<div className="pcd-gallery-grid">
{Array.from({ length: PF_MAX }, (_, index) => {
const item = images[index];
if (!item) {
return (
<button type="button" className="pcd-gallery-slot empty" key={`empty-${index}`} onClick={openPicker}>
</button>
);
}
return (
<div className="pcd-gallery-slot" key={item.id}>
<img src={item.url} alt={item.file.name} />
<span>{padIndex(index)}</span>
<button type="button" className="pcd-gallery-x" aria-label={`删除图片 ${item.file.name}`} onClick={() => removeImage(item.id)}>
<Trash2 />
</button>
</div>
))}
</div>
</div>
<div className="field pc-field-last">
{/* YYX#17/R104:(,+ ),
/线; = , */}
<div className="field-label-row">
<label className="field-label"><span className="req">*</span></label>
<button type="button" className="bl-add-btn" onClick={addBullet}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
</div>
<ul className="bullet-list">
{bullets.map((bullet, index) => (
<li className="bl-item bl-item-edit" key={index}>
<span className="num">{index + 1}</span>
<input
className="bl-input"
value={bullet}
onChange={(event) => updateBullet(index, event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
placeholder={isLocal ? "例: 双人下午茶套餐,含蛋糕+饮品,周末通用" : "例: 玻尿酸双效保湿,4 小时持久水润"}
/>
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</li>
))}
</ul>
);
})}
</div>
</div>
</div>
)}
{showGuide && (
<div className="pc-guide-note">
<strong>// 建好商品的 3 步</strong><br />
+ (/) + <br />
;/<br />
24 ,
</div>
)}
<div className="drawer-f">
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
使
</button>
<button className="btn" type="button" onClick={guardedClose}></button>
<button className="btn btn-primary" type="button" disabled={saving || images.some((im) => im.status === "uploading")} onClick={submit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
{saving ? "创建中…" : images.some((im) => im.status === "uploading") ? "图片上传中…" : "创建商品"}
</button>
</div>
{toast && (
<div className="pc-toast" role="status" aria-live="polite">
<span className="ic-t"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 6" /></svg></span>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
</div>
)}
</aside>
{/* YYX#24:填了内容中途退出 → 二次确认(复用通用 ConfirmModal) */}
<ConfirmModal
open={confirmExit}
title="商品未保存"
subtitle="// UNSAVED"
detail="当前商品信息尚未保存,确定要退出编辑吗?"
confirmText="退出编辑"
onCancel={() => setConfirmExit(false)}
+387 -308
View File
@@ -1,353 +1,432 @@
/* 工作台 · YZ 影擎对照布局。仅 scope 在 .dashboard-page,不改共享 .btn/.pill */
/* 工作台 · 对照 影擎 workbench 页。仅 scope 在 .dashboard-page,不改共享 .btn/.pill */
.dashboard-page {
max-width: 1080px;
--klein: #002fa7;
--klein-hover: #002680;
--project-accent: #6f8fd8;
--muted: #6f747c;
--summary: rgba(34, 42, 54, 0.055);
--workspace: rgba(34, 42, 54, 0.032);
--project: #ffffff;
--control: rgba(34, 42, 54, 0.06);
--shadow-card: 0 8px 20px rgba(20, 27, 38, 0.09);
width: 100%;
max-width: none;
box-sizing: border-box;
/* 抵消 .content 的 24/28/60,换成影擎 .main 左右留白,工作台铺满主区 */
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
@media (max-width: 1100px) {
.dashboard-page { margin: -28px -24px -48px; }
}
.dashboard-page .dash-hero {
margin: 8px 0 36px;
}
.dashboard-page .dash-hero h1 {
font-size: 32px;
font-weight: 600;
letter-spacing: -.03em;
.dashboard-page .welcome h1 {
margin: 0 0 14px;
font-size: 33px;
line-height: 1.2;
color: var(--accent-black);
}
.dashboard-page .dash-hero-sub {
margin-top: 10px;
font-size: 14px;
color: var(--black-alpha-48);
}
.dashboard-page .dash-create h2 {
font-size: 18px;
font-weight: 600;
letter-spacing: -.01em;
color: var(--accent-black);
margin-bottom: 18px;
}
.dashboard-page .dash-create-group + .dash-create-group {
margin-top: 22px;
}
.dashboard-page .dash-create-label {
font-size: 13px;
color: var(--black-alpha-48);
margin-bottom: 10px;
}
.dashboard-page .dash-create-row {
display: flex;
flex-direction: row;
gap: 14px;
}
.dashboard-page .dash-create-card {
flex: 1;
min-width: 0;
height: 96px;
padding: 0 22px;
border: 0;
border-radius: 16px;
display: flex;
align-items: center;
gap: 14px;
text-align: left;
cursor: pointer;
font: inherit;
transition: transform var(--t-fast), box-shadow var(--t-base), filter var(--t-base);
}
.dashboard-page .dash-create-card:hover {
transform: translateY(-1px);
}
.dashboard-page .dash-create-ic {
width: 36px;
height: 36px;
flex-shrink: 0;
display: grid;
place-items: center;
}
.dashboard-page .dash-create-ic svg,
.dashboard-page .dash-create-arrow svg {
width: 22px;
height: 22px;
}
.dashboard-page .dash-create-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.dashboard-page .dash-create-copy .t {
font-size: 16px;
font-weight: 600;
letter-spacing: -.01em;
}
.dashboard-page .dash-create-copy .d {
font-size: 13px;
line-height: 1.4;
opacity: .82;
}
.dashboard-page .dash-create-arrow {
flex-shrink: 0;
display: grid;
place-items: center;
opacity: .88;
}
.dashboard-page .dash-create-card.blue-a {
background: linear-gradient(90deg, #1f4fd4 0%, #3f78f6 100%);
color: var(--accent-white);
box-shadow: 0 10px 28px rgba(47, 93, 232, .28);
}
.dashboard-page .dash-create-card.blue-b {
background: linear-gradient(90deg, #1636b8 0%, #2b5ee6 100%);
color: var(--accent-white);
box-shadow: 0 10px 28px rgba(22, 54, 184, .28);
}
.dashboard-page .dash-create-card.blue-a:hover,
.dashboard-page .dash-create-card.blue-b:hover {
filter: brightness(1.04);
}
.dashboard-page .dash-create-card.light {
background: var(--surface);
color: var(--accent-black);
box-shadow: var(--shadow-floating);
border: 1px solid var(--border-faint);
}
.dashboard-page .dash-create-card.light .d {
color: var(--black-alpha-48);
opacity: 1;
}
.dashboard-page .dash-create-card.light:hover {
background: var(--background-lighter);
}
.dashboard-page .dash-overview {
margin-top: 48px;
}
.dashboard-page .dash-overview-h {
display: flex;
align-items: center;
gap: 14px;
font-size: 13px;
color: var(--black-alpha-48);
margin-bottom: 14px;
}
.dashboard-page .dash-overview-h::before,
.dashboard-page .dash-overview-h::after {
content: "";
flex: 1;
height: 1px;
background: var(--border-faint);
}
.dashboard-page .dash-overview-bar {
display: flex;
align-items: center;
min-height: 88px;
padding: 18px 8px;
border-radius: 999px;
background: var(--black-alpha-4);
}
.dashboard-page .dash-overview-cell {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
color: var(--accent-black);
}
.dashboard-page .dash-overview-cell .lbl {
font-size: 13px;
color: var(--black-alpha-48);
}
.dashboard-page .dash-overview-cell .v {
font-size: 22px;
font-weight: 600;
letter-spacing: -.02em;
font-variant-numeric: tabular-nums;
color: var(--accent-black);
}
.dashboard-page .dash-overview-cell:hover .v {
color: var(--nav-active);
.dashboard-page .welcome p {
margin: 0;
color: #363a41;
font-size: 16px;
}
.dashboard-page .dash-mine {
margin-top: 36px;
.dashboard-page .welcome,
.dashboard-page .creation {
width: min(1140px, 100%);
margin-right: auto;
}
.dashboard-page .dash-mine-head {
.dashboard-page .creation { margin-top: 40px; }
.dashboard-page .creation-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
gap: 24px;
margin-bottom: 15px;
}
.dashboard-page .dash-mine-head h2 {
font-size: 20px;
.dashboard-page .creation-head .section-label {
display: block;
font-size: 25px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.dashboard-page .dash-mine-all {
display: inline-flex;
align-items: center;
gap: 2px;
border: 0;
background: transparent;
color: var(--black-alpha-48);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.dashboard-page .dash-mine-all:hover { color: var(--accent-black); }
.dashboard-page .dash-mine-all svg { width: 14px; height: 14px; }
.dashboard-page .dash-mine-pills {
.dashboard-page .creation-groups {
display: grid;
gap: 22px;
}
.dashboard-page .creation-group-block {
display: grid;
gap: 11px;
}
.dashboard-page .creation-group-label {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.dashboard-page .dash-mine-pill {
height: 32px;
padding: 0 14px;
border: 0;
border-radius: var(--r-pill);
background: var(--black-alpha-4);
color: var(--accent-black);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.dashboard-page .dash-mine-pill.active {
background: #e8eefc;
color: #2a4fd7;
}
.dashboard-page .dash-mine-list {
display: flex;
flex-direction: column;
align-items: baseline;
gap: 12px;
padding-left: 2px;
}
.dashboard-page .dash-mine-empty {
padding: 28px 16px;
text-align: center;
color: var(--black-alpha-48);
font-size: 13px;
}
.dashboard-page .dash-proj-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 18px;
min-height: 96px;
padding: 16px 18px 16px 20px;
border: 0;
border-radius: 14px;
background: var(--black-alpha-4);
position: relative;
cursor: pointer;
overflow: hidden;
}
.dashboard-page .dash-proj-card::before {
content: "";
position: absolute;
left: 0;
top: 14px;
bottom: 14px;
width: 3px;
border-radius: 0 2px 2px 0;
background: #3d63ff;
}
.dashboard-page .dash-proj-card:hover {
background: var(--black-alpha-7);
}
.dashboard-page .dash-proj-thumb {
width: 64px;
height: 64px;
border-radius: 10px;
flex-shrink: 0;
}
.dashboard-page .dash-proj-main {
flex: 1;
min-width: 0;
}
.dashboard-page .dash-proj-title {
.dashboard-page .creation-group-label strong {
font-size: 15px;
font-weight: 600;
color: var(--accent-black);
line-height: 1.35;
}
.dashboard-page .dash-proj-sub {
margin-top: 6px;
font-size: 13px;
color: var(--black-alpha-48);
line-height: 1.4;
.dashboard-page .creation-group-label span {
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.dashboard-page .dash-proj-stage {
width: 168px;
.dashboard-page .creation-entry-pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.dashboard-page .entry {
width: 100%;
height: 94px;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 20px;
align-items: center;
gap: 14px;
padding: 0 24px;
border: 0;
border-radius: 12px;
cursor: pointer;
font: inherit;
text-align: left;
transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
.dashboard-page .entry:hover { transform: translateY(-2px); }
.dashboard-page .entry svg {
display: block;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.dashboard-page .dash-proj-stage-lbl {
font-size: 12px;
color: var(--black-alpha-48);
margin-bottom: 4px;
.dashboard-page .entry-copy {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
text-align: left;
}
.dashboard-page .dash-proj-stage-name {
font-size: 14px;
.dashboard-page .entry-copy strong {
font-size: 17px;
font-weight: 600;
letter-spacing: -.01em;
}
.dashboard-page .entry-copy small {
overflow: hidden;
font-size: 11px;
font-weight: 400;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-page .entry-primary {
color: #fff;
background: linear-gradient(90deg, #00247f 0%, #002fa7 48%, #3b6bd7 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 10px 22px rgba(0, 47, 167, 0.25);
}
.dashboard-page .entry-primary:hover {
background: linear-gradient(90deg, #001f73 0%, #002c9c 48%, #4879e4 100%);
}
.dashboard-page .entry-primary .entry-copy small { color: rgba(255, 255, 255, 0.78); }
.dashboard-page .entry-primary svg { color: #fff; }
.dashboard-page .entry-subtle {
color: #263247;
border: 1px solid rgba(203, 208, 216, 0.72);
background: rgba(249, 250, 252, 0.9);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.96),
inset 0 0 36px rgba(124, 134, 150, 0.15),
inset 0 0 18px rgba(124, 134, 150, 0.07),
0 10px 24px rgba(42, 53, 74, 0.12);
}
.dashboard-page .entry-subtle:hover {
border-color: rgba(190, 196, 206, 0.78);
background: rgba(252, 253, 254, 0.94);
box-shadow:
inset 0 1px 0 #fff,
inset 0 0 40px rgba(116, 126, 143, 0.16),
inset 0 0 20px rgba(116, 126, 143, 0.07),
0 14px 28px rgba(42, 53, 74, 0.15);
}
.dashboard-page .entry-subtle .entry-copy small { color: #727b8a; }
.dashboard-page .entry-subtle svg { color: #315aa9; }
.dashboard-page .entry-subtle svg:last-child { color: #263247; }
.dashboard-page .workbench-section-divider {
width: 100%;
display: grid;
grid-template-columns: minmax(40px, 1fr) auto minmax(40px, 1fr);
align-items: center;
gap: 14px;
margin: 52px 0 28px;
color: #8a919d;
}
.dashboard-page .workbench-section-divider::before,
.dashboard-page .workbench-section-divider::after {
content: "";
height: 1px;
background: linear-gradient(90deg, transparent, rgba(83, 91, 105, 0.25));
}
.dashboard-page .workbench-section-divider::after {
background: linear-gradient(90deg, rgba(83, 91, 105, 0.25), transparent);
}
.dashboard-page .workbench-section-divider span {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
}
.dashboard-page .status-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
justify-content: stretch;
align-items: center;
min-height: 100px;
margin-top: 0;
padding: 0 34px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 12px;
background: var(--summary);
box-shadow: 0 8px 19px rgba(20, 27, 38, 0.08);
}
.dashboard-page .summary-item {
display: flex;
align-items: baseline;
justify-content: center;
gap: 10px;
padding: 0 24px;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.dashboard-page .summary-item + .summary-item {
border-left: 1px solid rgba(34, 42, 54, 0.10);
}
.dashboard-page .summary-item span {
font-size: 15px;
font-weight: 500;
color: var(--muted);
}
.dashboard-page .summary-item strong {
min-width: 30px;
color: #141820;
font-size: 22px;
font-weight: 600;
text-align: left;
font-variant-numeric: tabular-nums;
}
.dashboard-page .summary-item .positive { color: #16b84e; }
.dashboard-page .projects {
margin-top: 34px;
padding: 32px 30px 28px;
border: 1px solid rgba(34, 42, 54, 0.07);
border-radius: 12px;
background: var(--workspace);
box-shadow: 0 10px 24px rgba(20, 27, 38, 0.055);
}
.dashboard-page .projects-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.dashboard-page .projects-head h2 {
margin: 0;
font-size: 22px;
font-weight: 600;
color: var(--accent-black);
margin-bottom: 10px;
}
.dashboard-page .dash-proj-prog {
.dashboard-page .text-action {
display: flex;
gap: 4px;
}
.dashboard-page .dash-proj-prog span {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--black-alpha-8);
}
.dashboard-page .dash-proj-prog span.done { background: var(--accent-forest); }
.dashboard-page .dash-proj-prog span.cur { background: var(--accent-honey); }
.dashboard-page .dash-proj-prog span.fail { background: var(--accent-crimson); }
.dashboard-page .dash-proj-cta {
height: 40px;
min-width: 88px;
padding: 0 22px;
align-items: center;
gap: 7px;
border: 0;
border-radius: 999px;
background: var(--nav-active);
color: #2a2d32;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 14px;
}
.dashboard-page .text-action svg { display: block; width: 18px; height: 18px; }
.dashboard-page .filters {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.dashboard-page .filter {
height: 34px;
padding: 0 17px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 9px;
color: #2c3036;
background: var(--control);
box-shadow: 0 3px 7px rgba(20, 27, 38, 0.05);
cursor: pointer;
font: inherit;
font-size: 13px;
}
.dashboard-page .filter.active {
border-color: rgba(0, 47, 167, 0.65);
background: rgba(0, 47, 167, 0.07);
color: var(--klein);
}
.dashboard-page .project-list {
display: grid;
gap: 22px;
}
.dashboard-page .project-card {
position: relative;
min-height: 152px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr) minmax(220px, 1.1fr) 140px;
align-items: center;
gap: 28px;
padding: 18px 34px 18px 32px;
border: 1px solid rgba(34, 42, 54, 0.08);
border-radius: 12px;
background: var(--project);
box-shadow: var(--shadow-card);
overflow: hidden;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
.dashboard-page .project-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 5px;
background: var(--project-accent);
}
.dashboard-page .project-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 25px rgba(20, 27, 38, 0.08);
background: #fafafa;
}
.dashboard-page .product-thumb {
width: 122px;
height: 106px;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 10px;
background: rgba(255, 255, 255, 0.6);
}
.dashboard-page .product-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.dashboard-page .product-thumb .ph-frame {
font-size: 12px;
color: var(--muted);
}
.dashboard-page .project-info h3 {
margin: 0 0 9px;
font-size: 16px;
font-weight: 600;
color: var(--accent-black);
}
.dashboard-page .project-info p,
.dashboard-page .stage-label {
margin: 0;
color: #5e636b;
font-size: 14px;
}
.dashboard-page .stage {
padding-left: 28px;
border-left: 1px solid rgba(34, 42, 54, 0.10);
}
.dashboard-page .stage strong {
display: block;
margin: 8px 0 13px;
font-size: 16px;
font-weight: 600;
color: var(--accent-black);
}
.dashboard-page .progress {
display: flex;
gap: 8px;
}
.dashboard-page .segment {
width: 29px;
height: 5px;
border-radius: 3px;
background: #d4d7dc;
}
.dashboard-page .segment.done { background: #17b84e; }
.dashboard-page .segment.warn { background: #ff7219; }
.dashboard-page .segment.fail { background: var(--accent-crimson); }
.dashboard-page .continue-button {
height: 42px;
min-width: 88px;
padding: 0 18px;
border: 0;
border-radius: 10px;
color: var(--accent-white);
background: #101012;
box-shadow: 0 7px 14px rgba(0, 0, 0, 0.16);
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
transition: transform 180ms ease, background 180ms ease;
}
.dashboard-page .continue-button:hover {
transform: translateY(-1px);
background: #2a2a2c;
}
.dashboard-page .dash-proj-cta:hover { opacity: .9; }
@media (max-width: 820px) {
.dashboard-page .dash-create-row {
flex-direction: column;
}
.dashboard-page .dash-overview-bar {
border-radius: 16px;
flex-wrap: wrap;
.dashboard-page .empty-state {
display: grid;
min-height: 150px;
place-items: center;
color: var(--muted);
border: 1px dashed rgba(34, 42, 54, 0.15);
border-radius: 12px;
background: var(--control);
font-size: 14px;
}
@media (max-width: 1100px) {
.dashboard-page .status-summary { padding: 0 16px; }
.dashboard-page .summary-item { padding: 0 14px; }
.dashboard-page .project-card {
grid-template-columns: 122px minmax(0, 1fr);
min-height: 0;
}
.dashboard-page .dash-overview-cell {
flex: 1 1 40%;
padding: 8px 0;
.dashboard-page .stage,
.dashboard-page .continue-button { display: none; }
}
@media (max-width: 820px) {
.dashboard-page .creation-entry-pair { grid-template-columns: 1fr; }
.dashboard-page .status-summary {
grid-template-columns: 1fr 1fr;
min-height: 0;
padding: 16px;
row-gap: 12px;
}
.dashboard-page .dash-proj-card {
flex-wrap: wrap;
}
.dashboard-page .dash-proj-stage {
width: 100%;
}
.dashboard-page .summary-item + .summary-item { border-left: 0; }
}
+96 -59
View File
@@ -217,7 +217,7 @@ img, svg, video { display: block; max-width: 100%; }
/* ─── App shell ─── */
.app {
display: grid;
grid-template-columns: 248px 1fr;
grid-template-columns: 164px 1fr;
min-height: 100vh;
transition: grid-template-columns var(--t-base);
}
@@ -225,9 +225,9 @@ body.sidebar-collapsed .app { grid-template-columns: 96px 1fr; }
/* ─── Sidebar ─── */
aside.sidebar {
padding: 22px 16px;
padding: 0 16px 22px;
border-right: 1px solid var(--border-faint);
background: var(--background-base);
background: #f5f6f8;
position: sticky;
top: 0;
height: 100vh;
@@ -239,24 +239,40 @@ aside.sidebar {
.sidebar-head {
display: flex;
align-items: center;
padding: 2px 8px 16px;
min-height: 44px;
justify-content: center;
margin: 0 -16px;
padding: 0;
height: 116px;
min-height: 116px;
box-sizing: border-box;
overflow: hidden;
background: #fff;
border-bottom: 1px solid rgba(27, 32, 40, 0.07);
}
.brand { display: flex; align-items: center; min-width: 0; color: var(--accent-black); }
.brand { display: flex; align-items: center; justify-content: center; min-width: 0; color: var(--accent-black); }
.brand-clip {
display: block;
width: 142px;
width: 132px;
height: 40px;
overflow: hidden;
transition: width var(--t-base);
transition: width var(--t-base), height var(--t-base);
}
.brand-logo {
display: block;
width: 132px;
height: 40px;
max-width: none;
margin: 0;
object-fit: contain;
object-position: left center;
}
.brand-logo { display: block; width: 142px; max-width: none; height: auto; margin: -8px 0 -6px -8px; object-fit: contain; }
.brand-mark, .flame { width: 22px; height: 22px; color: var(--accent-black); }
.brand-mark svg, .flame svg { width: 100%; height: 100%; }
.brand .name { font-weight: 600; font-size: 18px; letter-spacing: -.012em; color: var(--accent-black); }
.sidebar-toggle {
position: fixed;
top: 0;
left: 220px;
left: 136px;
z-index: 70;
width: 28px;
height: 100vh;
@@ -392,25 +408,29 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
margin-top: 6px;
}
.yz-resource-head {
height: 54px;
height: 48px;
margin-left: -16px;
margin-right: 22px;
margin-right: 10px;
border: 0;
border-radius: 0 28px 28px 0;
border-radius: 0 24px 24px 0;
background: transparent;
color: #2d3442;
color: #17191d;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 0 18px;
gap: 6px;
padding: 0 10px 0 14px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
}
.yz-resource-head > span { white-space: nowrap; flex: 0 0 auto; }
.yz-resource-head.active {
background: var(--nav-active);
color: var(--accent-white);
box-shadow: 0 6px 18px rgba(17, 19, 24, .14);
}
.yz-resource-head .major-dot,
.yz-side-link .major-dot {
@@ -424,14 +444,16 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
.yz-resource-head .major-dot { display: none; }
.yz-side-link .major-dot { display: none; }
.yz-resource-head > svg {
width: 15px;
height: 15px;
color: #2d3442;
width: 16px;
height: 16px;
flex-shrink: 0;
color: #17191d;
}
.yz-resource-head.active > svg { color: rgba(255, 255, 255, .95); }
.yz-resource-head .chev {
margin-left: auto;
display: inline-flex;
flex-shrink: 0;
opacity: .72;
}
.yz-resource-head.active .chev { opacity: .82; }
@@ -467,15 +489,16 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
background: #d7dbe3;
}
.yz-resource-sub a {
min-height: 38px;
min-height: 34px;
border-radius: 11px;
padding: 0 12px;
color: #7f838b;
padding: 0 8px 0 10px;
color: #888888;
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
gap: 8px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
transition: background .15s, color .15s;
}
.yz-resource-sub a .dot {
@@ -485,7 +508,7 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
background: #c8c8c8;
}
.yz-resource-sub a:hover {
background: #f1f3f8;
background: #eceef2;
color: #5e6571;
}
.yz-resource-sub a.active {
@@ -516,23 +539,26 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
pointer-events: none;
}
.yz-side-link {
min-height: 54px;
min-height: 48px;
margin-left: -16px;
margin-right: 22px;
border-radius: 0 28px 28px 0;
margin-right: 10px;
border-radius: 0 24px 24px 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 0 18px;
color: #2d3442;
font-size: 15px;
gap: 8px;
padding: 0 10px 0 14px;
color: #17191d;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
transition: background .22s ease, color .22s ease, box-shadow .22s ease;
}
.yz-side-link:hover { background: #f2f4f8; }
.yz-side-link:hover { background: #eceef2; }
.yz-side-link > svg {
width: 20px;
height: 20px;
width: 18px;
height: 18px;
flex-shrink: 0;
color: #4f5562;
transition: color .22s ease;
}
@@ -576,13 +602,19 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
line-height: 1;
}
body.sidebar-collapsed aside.sidebar { padding: 22px 12px; }
body.sidebar-collapsed .sidebar-head { gap: 6px; padding: 2px 0 16px; }
body.sidebar-collapsed aside.sidebar { padding: 0 12px 22px; }
body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0 -12px; padding: 0 12px; height: 116px; min-height: 116px; }
body.sidebar-collapsed .brand-clip {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: flex-start;
}
body.sidebar-collapsed .brand-logo {
width: auto;
height: 34px;
max-width: none;
}
body.sidebar-collapsed .sidebar-toggle { left: 68px; }
body.sidebar-collapsed .sidebar-toggle-icon--collapse { display: none; }
@@ -649,11 +681,14 @@ main { position: relative; background: var(--background-base); min-width: 0; }
/* ─── Topbar ─── */
.topbar {
display: flex; align-items: center; gap: 16px;
padding: 16px 28px;
border-bottom: 1px solid var(--border-faint);
background: var(--surface);
padding: 0 24px;
border-bottom: 1px solid rgba(27, 32, 40, 0.08);
background: #fff;
position: sticky; top: 0; z-index: 50;
min-height: 72px;
height: 116px;
min-height: 116px;
box-sizing: border-box;
flex-wrap: nowrap;
}
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--black-alpha-48); }
.crumbs .sep { color: var(--black-alpha-32); }
@@ -668,25 +703,27 @@ main { position: relative; background: var(--background-base); min-width: 0; }
min-height: 44px;
}
.yz-mod {
height: 40px;
padding: 0 20px;
border: 0;
border-radius: var(--r-pill);
background: var(--black-alpha-4);
color: var(--black-alpha-56);
height: 44px;
min-width: 104px;
padding: 0 22px;
border: 1px solid #d9dde3;
border-radius: 22px;
background: rgba(31, 39, 51, 0.025);
color: #2b2e33;
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.yz-mod:hover { background: var(--black-alpha-8); color: var(--accent-black); }
.yz-mod:hover { background: rgba(31, 39, 51, 0.06); color: #2b2e33; }
.yz-mod.active {
background: var(--nav-active);
color: var(--accent-white);
font-weight: 600;
background: #101012;
color: #fff;
font-weight: 500;
border-color: transparent;
}
.top-search {
display: inline-flex;
@@ -695,16 +732,16 @@ main { position: relative; background: var(--background-base); min-width: 0; }
height: 40px;
min-width: 196px;
padding: 0 12px;
border: 1px solid var(--border-faint);
border: 1px solid #eeeeee;
border-radius: var(--r-pill);
background: var(--background-lighter);
background: #f5f5f5;
color: var(--black-alpha-48);
font: inherit;
font-size: 13px;
font-size: 14px;
cursor: pointer;
transition: border-color var(--t-base), background var(--t-base);
}
.top-search:hover { border-color: var(--black-alpha-24); background: var(--surface); }
.top-search:hover { border-color: #e0e0e0; background: #f0f0f0; }
.top-search svg { width: var(--icon-m); height: var(--icon-m); flex-shrink: 0; }
.top-search span:not(.kbd) { flex: 1; text-align: left; }
.top-search .kbd {
@@ -815,7 +852,7 @@ main { position: relative; background: var(--background-base); min-width: 0; }
padding: 24px 28px 60px;
position: relative;
z-index: 1;
min-height: calc(100vh - 64px);
min-height: calc(100vh - 116px);
}
.content > .corner-mark { display: none; }
@@ -2455,14 +2492,14 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
aside.sidebar {
display: block;
position: fixed; top: 0; left: 0; bottom: 0;
width: 248px;
width: 164px;
z-index: 1200;
transform: translateX(-100%);
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
box-shadow: 0 0 40px rgba(0, 0, 0, .18);
}
/* 抽屉里收窄态不适用,强制展开宽度,避免 body.sidebar-collapsed 把抽屉压成 96px */
body.sidebar-collapsed aside.sidebar { width: 248px; }
body.sidebar-collapsed aside.sidebar { width: 164px; }
aside.sidebar.mobile-open { transform: translateX(0); }
.sidebar-toggle { display: none; }
/* 抽屉内汉堡键无意义,藏掉,留出顶部空间给页面自己的汉堡 */
+484 -203
View File
@@ -1,48 +1,211 @@
/* 自由创作页(free-create)· 私有样式,全部 .fc- 前缀
颜色/圆角/间距全部走 design-restraint token(§8 Don't List:禁裸 hex / >12px 圆角 / 灰阴影) */
/* 自由生成页 · 对照影擎 video-free。不重写共享 .btn/.pill/.input/.chip。 */
.fc-page { display: flex; flex-direction: column; min-height: calc(100vh - 64px - 120px); }
/* —— 任务流 —— */
.fc-feed-wrap { flex: 1; padding-bottom: 24px; }
.fc-feed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
align-items: start;
.content:has(.fc-page) {
overflow: hidden;
}
.fc-loading, .fc-sentinel {
padding: 28px 0;
text-align: center;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--black-alpha-48);
}
.fc-empty { margin: 48px auto; }
/* —— 任务卡 —— */
.fc-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
.fc-page {
--klein: #002fa7;
--klein-hover: #002680;
--fc-black: #101012;
--fc-muted: #6f747c;
--fc-line: rgba(34, 42, 54, 0.10);
--fc-wash: rgba(34, 42, 54, 0.045);
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 20px;
height: calc(100vh - 116px);
height: calc(100dvh - 116px);
max-height: calc(100vh - 116px);
max-height: calc(100dvh - 116px);
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.2s;
}
.fc-card.done { cursor: pointer; }
.fc-card.done:hover { border-color: var(--black-alpha-48); }
.fc-card-media {
@media (max-width: 1100px) {
.fc-page { margin: -28px -24px -48px; }
}
.fc-page .fc-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 18px;
flex-shrink: 0;
}
.fc-page .fc-title-row {
display: flex;
align-items: center;
gap: 13px;
min-width: 0;
}
.fc-page .fc-back {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgba(34, 42, 54, 0.12);
border-radius: 12px;
color: var(--accent-black);
background: rgba(255, 255, 255, 0.82);
cursor: pointer;
transition: background-color 180ms ease, transform 180ms ease;
}
.fc-page .fc-back:hover {
transform: translateX(-2px);
background: rgba(34, 42, 54, 0.08);
}
.fc-page .fc-back svg { width: 19px; height: 19px; }
.fc-page .fc-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.fc-page .fc-head p { margin: 0; color: var(--fc-muted); font-size: 15px; }
.fc-page .fc-material {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
flex-shrink: 0;
}
.fc-page .fc-ghost {
min-width: 126px;
height: 46px;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
font: inherit;
cursor: pointer;
}
.fc-page .fc-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.fc-page .fc-ghost svg { width: 19px; height: 19px; }
.fc-page .fc-stage {
position: relative;
background: var(--background-base);
max-height: 420px;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
overscroll-behavior: contain;
}
.fc-page .fc-stage:has(.system-loading) {
display: grid;
place-items: center;
}
.fc-page .fc-stage:has(.fc-empty) {
display: grid;
place-items: center;
overflow: hidden;
}
.fc-page .fc-stage:has(.fc-empty)::before {
content: "";
position: absolute;
inset: 5% 7%;
background-image:
linear-gradient(rgba(0, 47, 167, 0.075) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 47, 167, 0.075) 1px, transparent 1px);
background-size: 30px 30px;
-webkit-mask-image: radial-gradient(circle at center, #000 0, rgba(0,0,0,.72) 35%, transparent 74%);
mask-image: radial-gradient(circle at center, #000 0, rgba(0,0,0,.72) 35%, transparent 74%);
pointer-events: none;
}
.fc-page .fc-empty {
position: relative;
z-index: 1;
text-align: center;
color: var(--fc-muted);
}
.fc-page .fc-empty-icon {
width: 48px;
height: 48px;
display: grid;
place-items: center;
margin: 0 auto 13px;
border-radius: 12px;
background: rgba(34, 42, 54, 0.07);
}
.fc-page .fc-empty-icon svg { width: 22px; height: 22px; }
.fc-page .fc-empty strong {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--accent-black);
}
.fc-page .fc-empty p {
margin: 8px 0 0;
font-size: 13px;
}
.fc-page .fc-feed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 18px;
align-items: start;
padding-bottom: 8px;
}
.fc-page .fc-sentinel {
padding: 28px 0;
text-align: center;
font-size: 12px;
color: var(--fc-muted);
}
.fc-page .fc-card {
overflow: hidden;
border: 1px solid var(--fc-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.05);
box-shadow: 0 8px 18px rgba(20, 27, 38, 0.075);
display: flex;
flex-direction: column;
transition: transform 180ms ease, background-color 180ms ease;
}
.fc-page .fc-card.done { cursor: pointer; }
.fc-page .fc-card:hover {
transform: translateY(-3px);
background: rgba(34, 42, 54, 0.08);
}
.fc-page .fc-card-media {
position: relative;
background: #e5e6e9;
max-height: 280px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-card-generating {
.fc-page .fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-page .fc-dur {
position: absolute;
right: 9px;
bottom: 9px;
padding: 4px 7px;
border-radius: 6px;
color: #fff;
background: rgba(16, 16, 18, 0.78);
font-size: 11px;
}
.fc-page .fc-card-generating {
position: absolute;
inset: 0;
display: flex;
@@ -50,25 +213,39 @@
align-items: center;
justify-content: center;
gap: 12px;
background: linear-gradient(110deg, var(--black-alpha-4) 30%, var(--black-alpha-7) 50%, var(--black-alpha-4) 70%);
background: linear-gradient(110deg, rgba(34, 42, 54, 0.04) 30%, rgba(34, 42, 54, 0.08) 50%, rgba(34, 42, 54, 0.04) 70%);
background-size: 200% 100%;
animation: fc-shimmer 1.6s linear infinite;
font-size: 11px;
color: var(--black-alpha-56);
color: var(--fc-muted);
}
@keyframes fc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.fc-progress {
.fc-page .fc-progress {
width: 60%;
height: 4px;
border-radius: 2px;
background: var(--black-alpha-12);
border-radius: 99px;
background: #e2e5ea;
overflow: hidden;
}
.fc-progress span { display: block; height: 100%; border-radius: 2px; background: var(--heat); transition: width 0.6s ease; }
.fc-card-failed {
.fc-page .fc-progress span,
.fc-page .fc-progress-mini span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--klein);
transition: width 0.6s ease;
}
.fc-page .fc-progress-mini {
height: 4px;
margin-top: 12px;
overflow: hidden;
border-radius: 99px;
background: #e2e5ea;
}
.fc-page .fc-card-failed {
position: absolute;
inset: 0;
display: flex;
@@ -78,9 +255,32 @@
gap: 10px;
padding: 16px;
text-align: center;
background: rgba(255, 255, 255, 0.82);
}
.fc-card-failed p { font-size: 12px; line-height: 1.65; color: var(--black-alpha-64); max-width: 90%; margin: 0; }
.fc-card-hover {
.fc-page .fc-card-failed p {
font-size: 12px;
line-height: 1.65;
color: var(--fc-muted);
max-width: 90%;
margin: 0;
}
.fc-page .fc-failed-actions { display: flex; gap: 8px; }
.fc-page .fc-mini {
height: 30px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 8px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.fc-page .fc-mini:hover { background: rgba(34, 42, 54, 0.06); }
.fc-page .fc-card-hover {
position: absolute;
top: 10px;
right: 10px;
@@ -89,121 +289,149 @@
opacity: 0;
transition: opacity 0.2s;
}
.fc-card:hover .fc-card-hover { opacity: 1; }
.fc-hover-btn {
.fc-page .fc-card:hover .fc-card-hover { opacity: 1; }
.fc-page .fc-hover-btn {
width: 30px;
height: 30px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
background: var(--surface);
color: var(--black-alpha-56);
border-radius: 8px;
border: 0;
background: rgba(255, 255, 255, 0.92);
color: var(--fc-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.fc-hover-btn:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.fc-hover-btn.fav { color: var(--heat); }
.fc-hover-btn.danger:hover { color: var(--accent-crimson); }
.fc-card-meta { padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
.fc-card-prompt {
font-size: 13px;
.fc-page .fc-hover-btn:hover { color: var(--accent-black); }
.fc-page .fc-hover-btn.fav { color: var(--klein); }
.fc-page .fc-hover-btn.danger:hover { color: var(--accent-crimson); }
.fc-page .fc-card-meta { padding: 15px 16px 17px; }
.fc-page .fc-card-meta h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--accent-black);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-card-sub { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
/* —— 底部输入条 —— */
.fc-inputbar {
position: sticky;
bottom: 16px;
z-index: 5;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
padding: 14px 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: var(--shadow-floating);
.fc-page .fc-card-meta p {
margin: 0;
color: var(--fc-muted);
font-size: 12px;
}
.fc-inputbar.drag { border-color: var(--heat-40); }
.fc-drop-hint {
.fc-page .fc-card-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 13px;
}
.fc-page .fc-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border-radius: 7px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
}
.fc-page .fc-tag.blue { color: var(--klein); background: rgba(0, 47, 167, 0.09); }
.fc-page .fc-tag.green { color: #138d3f; background: rgba(22, 184, 78, 0.10); }
.fc-page .fc-tag.danger { color: var(--accent-crimson); background: var(--crimson-bg); }
.fc-page .fc-composer {
position: relative;
z-index: 6;
flex: 0 0 auto;
margin-top: 14px;
padding: 16px;
border: 1px solid var(--fc-line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.10);
}
.fc-page .fc-composer.drag { border-color: var(--klein); }
.fc-page .fc-drop-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--heat-12);
border-radius: inherit;
color: var(--heat);
font-size: 12px;
background: rgba(0, 47, 167, 0.08);
color: var(--klein);
font-size: 13px;
pointer-events: none;
z-index: 2;
}
.fc-input-top { display: flex; gap: 12px; align-items: flex-start; }
/* 参考素材条(universal) */
.fc-refs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; max-width: 300px; }
.fc-add {
width: 56px;
height: 56px;
border-radius: var(--r-md);
border: 1px dashed var(--black-alpha-24);
background: var(--background-base);
color: var(--black-alpha-56);
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
.fc-page .fc-prompt-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.fc-page .fc-refs {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
max-width: 220px;
}
.fc-page .fc-ref-btn {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border: 1px dashed rgba(34, 42, 54, 0.18);
border-radius: 10px;
background: rgba(34, 42, 54, 0.035);
color: var(--accent-black);
cursor: pointer;
font-size: 12px;
transition: border-color 0.2s, color 0.2s;
}
.fc-add:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-ref {
.fc-page .fc-ref-btn:hover { background: rgba(34, 42, 54, 0.07); }
.fc-page .fc-ref-btn svg { width: 18px; height: 18px; }
.fc-page .fc-ref {
position: relative;
width: 56px;
height: 56px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
width: 46px;
height: 46px;
border-radius: 10px;
border: 1px solid var(--fc-line);
overflow: hidden;
background: var(--background-base);
background: var(--fc-wash);
display: flex;
align-items: center;
justify-content: center;
}
.fc-ref img { width: 100%; height: 100%; object-fit: cover; }
.fc-ref.uploading img { opacity: 0.4; }
.fc-ref .spinner { position: absolute; }
.fc-ref-kind { font-size: 11px; color: var(--black-alpha-48); }
.fc-ref-dur {
.fc-page .fc-ref img { width: 100%; height: 100%; object-fit: cover; }
.fc-page .fc-ref.uploading img { opacity: 0.4; }
.fc-page .fc-ref .spinner { position: absolute; }
.fc-page .fc-ref-kind { font-size: 10px; color: var(--fc-muted); }
.fc-page .fc-ref-dur {
position: absolute;
right: 3px;
bottom: 3px;
font-size: 8.5px;
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
border-radius: 4px;
background: rgba(16, 16, 18, 0.72);
color: #fff;
}
.fc-ref-label {
.fc-page .fc-ref-label {
position: absolute;
left: 0;
right: 0;
bottom: 0;
font-size: 9px;
padding: 1px 4px;
background: var(--black-alpha-56);
color: var(--surface);
background: rgba(16, 16, 18, 0.72);
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-ref-x {
.fc-page .fc-ref-x {
position: absolute;
top: 2px;
right: 2px;
@@ -211,8 +439,8 @@
height: 16px;
border-radius: 999px;
border: none;
background: var(--black-alpha-56);
color: var(--surface);
background: rgba(16, 16, 18, 0.72);
color: #fff;
font-size: 11px;
line-height: 1;
cursor: pointer;
@@ -220,19 +448,19 @@
align-items: center;
justify-content: center;
}
.fc-ref:hover .fc-ref-x, .fc-kf-slot-wrap:hover .fc-ref-x { display: inline-flex; }
.fc-page .fc-ref:hover .fc-ref-x,
.fc-page .fc-kf-slot-wrap:hover .fc-ref-x { display: inline-flex; }
/* 首尾帧(keyframe) */
.fc-keyframes { display: flex; align-items: center; gap: 8px; }
.fc-kf-slot-wrap { position: relative; width: 88px; height: 66px; flex: 0 0 auto; }
.fc-kf-slot {
.fc-page .fc-keyframes { display: flex; align-items: center; gap: 8px; }
.fc-page .fc-kf-slot-wrap { position: relative; width: 88px; height: 66px; flex: 0 0 auto; }
.fc-page .fc-kf-slot {
position: relative;
width: 88px;
height: 66px;
border-radius: var(--r-md);
border: 1px dashed var(--black-alpha-24);
background: var(--background-base);
color: var(--black-alpha-56);
border-radius: 10px;
border: 1px dashed rgba(34, 42, 54, 0.18);
background: rgba(34, 42, 54, 0.035);
color: var(--fc-muted);
display: inline-flex;
flex-direction: column;
align-items: center;
@@ -241,84 +469,79 @@
cursor: pointer;
font-size: 11px;
overflow: hidden;
transition: border-color 0.2s, color 0.2s;
}
.fc-kf-slot-wrap > .fc-kf-slot { width: 100%; height: 100%; }
.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); }
.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fc-kf-slot .spinner { position: absolute; }
.fc-kf-lib {
.fc-page .fc-kf-slot-wrap > .fc-kf-slot { width: 100%; height: 100%; }
.fc-page .fc-kf-slot:hover { color: var(--accent-black); background: rgba(34, 42, 54, 0.07); }
.fc-page .fc-kf-slot.filled { border-style: solid; border-color: var(--fc-line); }
.fc-page .fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fc-page .fc-kf-slot .spinner { position: absolute; }
.fc-page .fc-kf-lib {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
border-radius: var(--r-md);
border-radius: 10px;
background: transparent;
color: transparent;
cursor: pointer;
opacity: 0;
z-index: 1;
}
.fc-kf-slot-wrap > .fc-ref-x { z-index: 2; }
.fc-kf-tag {
.fc-page .fc-kf-slot-wrap > .fc-ref-x { z-index: 2; }
.fc-page .fc-kf-tag {
position: absolute;
left: 3px;
top: 3px;
font-size: 8.5px;
padding: 1px 4px;
border-radius: var(--r-sm);
background: var(--black-alpha-56);
color: var(--surface);
border-radius: 4px;
background: rgba(16, 16, 18, 0.72);
color: #fff;
}
.fc-kf-arrow { color: var(--black-alpha-48); font-size: 13px; }
.fc-page .fc-kf-arrow { color: var(--fc-muted); font-size: 13px; }
/* 提示词编辑器(contenteditable) */
.fc-prompt-wrap { flex: 1; min-width: 0; }
.fc-prompt {
min-height: 64px;
.fc-page .fc-prompt-wrap { min-width: 0; }
.fc-page .fc-prompt {
min-height: 72px;
max-height: 160px;
overflow-y: auto;
font-size: 13px;
font-size: 14px;
line-height: 1.65;
color: var(--accent-black);
outline: none;
padding: 6px 2px;
word-break: break-word;
}
.fc-prompt:empty::before { content: attr(data-placeholder); color: var(--black-alpha-48); pointer-events: none; }
.fc-mention {
.fc-page .fc-prompt:empty::before { content: attr(data-placeholder); color: var(--fc-muted); pointer-events: none; }
.fc-page .fc-mention {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--r-sm);
background: var(--heat-12);
border: 1px solid var(--heat-20);
color: var(--heat);
border-radius: 6px;
background: rgba(0, 47, 167, 0.09);
color: var(--klein);
font-size: 12px;
vertical-align: middle;
user-select: none;
white-space: nowrap;
}
.fc-mention img { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; }
.fc-page .fc-mention img { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; }
/* @ 候选菜单(fixed,朝上弹) */
.fc-mention-menu { position: fixed; z-index: 300; transform: translateY(-100%); }
.fc-mention-menu-inner {
min-width: 220px;
max-width: 280px;
max-height: 260px;
overflow-y: auto;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
background: #fff;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 12px;
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
padding: 6px;
}
.fc-mention-menu-head { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); padding: 4px 8px; }
.fc-mention-menu-head { font-size: 11px; color: var(--fc-muted, #6f747c); padding: 4px 8px; }
.fc-mention-item {
display: flex;
align-items: center;
@@ -326,82 +549,136 @@
width: 100%;
padding: 6px 8px;
border: none;
border-radius: var(--r-sm);
border-radius: 8px;
background: transparent;
cursor: pointer;
font-size: 12.5px;
color: var(--accent-black);
text-align: left;
}
.fc-mention-item.active { background: var(--black-alpha-4); }
.fc-mention-item img { width: 24px; height: 24px; border-radius: var(--r-sm); object-fit: cover; }
.fc-mention-ph { width: 24px; height: 24px; border-radius: var(--r-sm); background: var(--black-alpha-7); }
.fc-mention-item.active { background: rgba(34, 42, 54, 0.06); }
.fc-mention-item img { width: 24px; height: 24px; border-radius: 6px; object-fit: cover; }
.fc-mention-ph { width: 24px; height: 24px; border-radius: 6px; background: rgba(34, 42, 54, 0.08); }
.fc-mention-item .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-mention-item .lbl.lib { color: var(--black-alpha-56); }
.fc-mention-item .lbl.lib { color: #6f747c; }
/* —— 工具栏 —— */
.fc-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; border-top: 1px solid var(--border-faint); padding-top: 10px; }
.fc-toolbar-l { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.fc-toolbar-r { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.fc-estimate { font-size: 11px; letter-spacing: 0.04em; color: var(--black-alpha-48); font-variant-numeric: tabular-nums; }
.fc-page .fc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-top: 13px;
border-top: 1px solid rgba(34, 42, 54, 0.08);
}
.fc-page .fc-chips {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fc-page .fc-toolbar-r {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.fc-page .fc-estimate {
font-size: 11px;
color: var(--fc-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.fc-page .fc-clear {
height: 32px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: var(--fc-muted);
background: transparent;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.fc-page .fc-clear:hover { color: var(--accent-black); background: rgba(34, 42, 54, 0.06); }
.fc-page .fc-gen {
min-width: 100px;
height: 42px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 11px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.fc-page .fc-gen:hover:not(:disabled) { transform: translateY(-1px); background: var(--klein-hover); }
.fc-page .fc-gen:disabled { opacity: 0.38; transform: none; box-shadow: none; cursor: not-allowed; }
.fc-page .fc-gen svg { width: 16px; height: 16px; }
.fc-dd { position: relative; }
.fc-dd-btn {
height: 28px;
.fc-page .fc-dd { position: relative; }
.fc-page .fc-chip {
height: 32px;
padding: 0 10px;
border-radius: var(--r-md);
border: 1px solid var(--border-faint);
background: var(--surface);
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--accent-black);
border: 0;
border-radius: 8px;
color: #4a4f57;
background: rgba(34, 42, 54, 0.06);
font: inherit;
font-size: 11px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.fc-dd-btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.fc-dd-btn:disabled { color: var(--black-alpha-32); cursor: not-allowed; }
.fc-dd-btn svg { color: var(--black-alpha-48); transition: transform 0.2s; }
.fc-dd.open .fc-dd-btn svg { transform: rotate(180deg); }
.fc-dd-lbl { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-dd-val { font-weight: 500; }
.fc-dd-menu {
.fc-page .fc-chip strong { font-weight: 600; color: var(--accent-black); }
.fc-page .fc-chip svg { width: 12px; height: 12px; color: #4a4f57; }
.fc-page .fc-chip:hover { background: rgba(34, 42, 54, 0.10); }
.fc-page .fc-chip:disabled { opacity: 0.45; cursor: not-allowed; }
.fc-page .fc-dd.open .fc-chip svg { transform: rotate(180deg); }
.fc-page .fc-dd-menu {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
z-index: 60;
min-width: 180px;
background: var(--surface-raised);
border: 1px solid var(--border-muted);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
background: #fff;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 12px;
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
padding: 6px;
}
.fc-dd-item {
.fc-page .fc-dd-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: var(--r-sm);
border-radius: 8px;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
}
.fc-dd-item:hover { background: var(--black-alpha-4); }
.fc-dd-item.selected { background: var(--heat-12); }
.fc-dd-item.selected .ti { color: var(--heat); }
.fc-dd-item.disabled { cursor: not-allowed; }
.fc-dd-item.disabled .ti, .fc-dd-item.disabled .de { color: var(--black-alpha-32); }
.fc-dd-item .ti { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }
.fc-dd-item .de { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-seed-menu { min-width: 220px; padding: 10px; }
.fc-seed-row { display: flex; gap: 8px; align-items: center; }
.fc-seed-row .input { height: 30px; flex: 1; }
.fc-seed-hint { margin-top: 6px; font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); }
.fc-fav-on { color: var(--heat); }
.fc-page .fc-dd-item:hover { background: rgba(34, 42, 54, 0.06); }
.fc-page .fc-dd-item.selected { background: rgba(0, 47, 167, 0.08); }
.fc-page .fc-dd-item.selected .ti { color: var(--klein); }
.fc-page .fc-dd-item.disabled { cursor: not-allowed; }
.fc-page .fc-dd-item.disabled .ti,
.fc-page .fc-dd-item.disabled .de { color: rgba(28, 34, 43, 0.36); }
.fc-page .fc-dd-item .ti { font-size: 12.5px; font-weight: 500; color: var(--accent-black); }
.fc-page .fc-dd-item .de { font-size: 10.5px; color: var(--fc-muted); }
.fc-page .fc-seed-menu { min-width: 220px; padding: 10px; }
.fc-page .fc-seed-row { display: flex; gap: 8px; align-items: center; }
.fc-page .fc-seed-row .input { height: 30px; flex: 1; }
.fc-page .fc-seed-hint { margin-top: 6px; font-size: 10.5px; color: var(--fc-muted); }
.fc-fav-on { color: var(--klein); }
/* —— 全屏播放器 —— */
.fc-player-bg {
@@ -693,11 +970,15 @@
.fc-lib-model-picks .btn { height: 24px; padding: 0 8px; font-size: 11.5px; }
.fc-lib-more { display: flex; justify-content: center; padding-top: 14px; }
/* 移动端:输入条参数换行、任务流单列 */
@media (max-width: 1100px) {
.fc-page .fc-head { flex-direction: column; align-items: stretch; }
.fc-page .fc-material .fc-ghost { min-width: 0; }
}
@media (max-width: 720px) {
.fc-feed { grid-template-columns: 1fr; }
.fc-input-top { flex-direction: column; }
.fc-refs { max-width: none; }
.fc-page .fc-feed { grid-template-columns: 1fr; }
.fc-page .fc-prompt-main { grid-template-columns: 1fr; }
.fc-page .fc-refs { max-width: none; }
.fc-page .fc-toolbar { flex-wrap: wrap; }
.fc-player-bg { padding: 16px; }
.fc-player-nav.prev { left: 6px; }
.fc-player-nav.next { right: 6px; }
+411 -101
View File
@@ -1,106 +1,424 @@
/* 资产库 · public/exact/library.html 内联 <style> 移植资产网格部分,scope .library-page
tabs/chip/toolbar/search-inline/result-meta/empty-filter 走全局 design-restraint */
/* 资产库列表 · 对照影擎成品库详情/上传/素材包弹窗仍用后半段 .adx-* / .pack-modal*
不重写共享 .btn/.chip/.tabs/.toolbar */
.library-page {
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.asset-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; position: relative; }
.asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.asset-thumb { aspect-ratio: 1; }
/* width:100% 必须显式给:否则 max-height 会经 aspect-ratio 把宽度也钳到 157.5px,卡片右侧露白 */
.asset-card.video .asset-thumb { width: 100%; aspect-ratio: 9/16; max-height: 280px; }
/* 音频资产无封面:默认音符占位(居中、mono 标签),点击灯箱播放 */
.lib-audio-ph { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--black-alpha-48); }
.lib-audio-ph svg { width: 26px; height: 26px; }
.lib-audio-ph .mono { font-size: 12px; letter-spacing: .08em; }
/* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.asset-body { padding: 12px 14px; }
.asset-name {
font-size: 14px; font-weight: 500; line-height: 1.4; color: var(--accent-black);
overflow-wrap: anywhere; word-break: break-word;
}
/* 自由创作成品卡片仅显示三行标题;保留网格原有等高对齐。 */
#free-video-grid .asset-name,
#batch-grid .asset-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.asset-meta { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
.asset-card { position: relative; }
/* 视频资产缩略图上的播放角标(点击整卡弹窗播放) */
.lib-play-badge { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 40px; height: 40px; border-radius: 50%; background: rgba(0, 0, 0, .58); color: var(--accent-white); display: grid; place-items: center; pointer-events: none; transition: background .15s; }
.lib-play-badge svg { width: 18px; height: 18px; margin-left: 2px; }
.asset-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); }
--klein: #002fa7;
--klein-hover: #002680;
--lib-black: #101012;
--lib-muted: #6f747c;
--lib-line: rgba(34, 42, 54, 0.10);
--lib-wash: rgba(34, 42, 54, 0.045);
--lib-shadow: 0 8px 18px rgba(20, 27, 38, 0.075);
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
/* 编辑模式:开启「管理资产」后,批次卡/素材包卡删除按钮常显 */
body.edit-mode .library-page .pack-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; }
/*
批量编辑 · 多选 + 吸底 bulk-bar(scope 到资产库;蓝本 products-page.css)
*/
/* 卡片左上多选框(默认隐藏,edit-mode 才出) */
.library-page .asset-card .card-check {
position: absolute; top: 10px; left: 10px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--border-loud);
display: none; place-items: center; color: var(--accent-white);
z-index: 5; pointer-events: none;
@media (max-width: 1100px) {
.library-page { margin: -28px -24px -48px; }
}
.library-page .asset-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
body.edit-mode .library-page .asset-card { cursor: pointer; }
body.edit-mode .library-page .asset-card .card-check { display: grid; }
body.edit-mode .library-page .asset-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .library-page .asset-card.selected .card-check svg { visibility: visible; }
body.edit-mode .library-page .asset-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
/* 多选态隐藏单卡删除(改走 bulk-bar 批量删) */
body.edit-mode .library-page .asset-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
/* 管理资产:图片批次与视频素材包均使用 .batch-card 多选,复用同一套勾选与选中视觉。 */
.library-page .batch-card .card-check {
position: absolute; top: 10px; left: 10px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--border-loud);
display: none; place-items: center; color: var(--accent-white);
z-index: 5; pointer-events: none;
.library-page .lib-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
}
.library-page .batch-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
body.edit-mode .library-page .batch-card .card-check { display: grid; }
body.edit-mode .library-page .batch-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .library-page .batch-card.selected .card-check svg { visibility: visible; }
body.edit-mode .library-page .batch-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
.library-page .lib-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.library-page .lib-head p { margin: 0; color: var(--lib-muted); font-size: 15px; }
.library-page .lib-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.library-page .lib-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
font: inherit;
cursor: pointer;
}
.library-page .lib-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.library-page .lib-ghost.is-on { color: #fff; border-color: var(--lib-black); background: var(--lib-black); }
.library-page .lib-ghost svg { width: 19px; height: 19px; }
/* 吸底批量操作栏 · 仅 edit-mode 出现 */
.lib-bulk-bar {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md);
padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, .18); z-index: 100; font-size: 13px;
.library-page .lib-toolbar {
min-height: 66px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
background: var(--lib-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
/* 显隐绑定「是否有选中」而非编辑模式 → 退选即消失 */
.lib-bulk-bar.show { display: inline-flex; }
.lib-bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
.lib-bulk-bar .ct b { color: var(--heat); font-weight: 600; padding: 0 3px; }
.lib-bulk-bar .sep { width: 1px; height: 18px; background: rgba(255, 255, 255, .16); }
.lib-bulk-bar button {
height: 30px; padding: 0 12px; background: transparent;
border: 1px solid rgba(255, 255, 255, .24); border-radius: var(--r-sm);
color: var(--accent-white); font-size: 13px; font-family: inherit; cursor: pointer;
display: inline-flex; align-items: center; gap: 5px;
transition: background .15s, border-color .15s;
.library-page .lib-seg {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.lib-bulk-bar button:hover { background: rgba(255, 255, 255, .08); }
.lib-bulk-bar button.danger { background: var(--accent-crimson); border-color: var(--accent-crimson); }
.lib-bulk-bar button.danger:hover { filter: brightness(1.06); }
.lib-bulk-bar button:disabled { opacity: .4; cursor: not-allowed; }
.lib-bulk-bar button svg { width: 12px; height: 12px; }
.lib-bulk-bar .clear-sel { color: rgba(255, 255, 255, .6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }
.lib-bulk-bar .clear-sel:hover { color: var(--accent-white); }
.library-page .lib-seg-btn {
height: 38px;
padding: 0 14px;
border: 0;
border-radius: 9px;
color: #50555d;
background: transparent;
font: inherit;
font-size: 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.library-page .lib-seg-btn:hover { color: var(--accent-black); }
.library-page .lib-seg-btn.active { color: #fff; background: var(--lib-black); }
.library-page .lib-seg-count {
min-width: 18px;
height: 18px;
padding: 0 5px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: rgba(80, 85, 93, 0.12);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.library-page .lib-seg-btn.active .lib-seg-count { color: #fff; background: rgba(255, 255, 255, 0.16); }
/* edit-toggle 激活态(管理资产 → 完成 高亮) */
.library-page .btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
.library-page .lib-toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.library-page .lib-search {
width: 220px;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid var(--lib-line);
border-radius: 10px;
background: #fff;
}
.library-page .lib-search svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--lib-muted); }
.library-page .lib-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
color: var(--accent-black);
}
.library-page .lib-search input::placeholder { color: var(--lib-muted); }
.library-page .lib-select { position: relative; width: 136px; flex: 0 0 136px; z-index: 40; }
.library-page .lib-select.open { z-index: 50; }
.library-page .lib-select-trigger {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px;
border: 1px solid rgba(34, 42, 54, 0.12);
border-radius: 10px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.library-page .lib-select-trigger:hover,
.library-page .lib-select.open .lib-select-trigger { border-color: rgba(0, 47, 167, 0.36); }
.library-page .lib-select.open .lib-select-trigger { box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07); }
.library-page .lib-select-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.library-page .lib-select-trigger svg { width: 15px; height: 15px; flex: 0 0 auto; color: var(--lib-muted); transition: transform 180ms ease; }
.library-page .lib-select.open .lib-select-trigger svg { transform: rotate(180deg); }
.library-page .lib-select-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 120;
display: grid;
gap: 3px;
padding: 6px;
border: 1px solid var(--lib-line);
border-radius: 11px;
background: #fff;
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-5px);
}
.library-page .lib-select.open .lib-select-menu {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.library-page .lib-select-option {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.library-page .lib-select-option:hover { background: #e8f2ff; }
.library-page .lib-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
.library-page .lib-section { margin: 32px 0 17px; }
.library-page .lib-section h2 { margin: 0; font-size: 21px; font-weight: 600; color: var(--accent-black); }
.library-page .lib-section p { margin: 5px 0 0; color: var(--lib-muted); font-size: 13px; }
.library-page .lib-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.library-page .lib-card {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--lib-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--lib-shadow);
cursor: pointer;
outline: none;
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.library-page .lib-card:hover { transform: translateY(-3px); background: rgba(34, 42, 54, 0.08); }
.library-page .lib-card.selected {
border-color: var(--klein);
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--lib-shadow);
}
.library-page .lib-thumb {
position: relative;
aspect-ratio: 1 / 0.78;
overflow: hidden;
display: grid;
place-items: center;
background: #e6e7ea;
}
.library-page .lib-thumb.portrait {
width: 100%;
aspect-ratio: 9 / 16;
max-height: 280px;
}
.library-page .lib-thumb img,
.library-page .lib-thumb video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.library-page .lib-meta { padding: 15px 16px 17px; }
.library-page .lib-meta h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--accent-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-page .lib-meta p {
margin: 0;
color: var(--lib-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-page .lib-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 13px;
}
.library-page .lib-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border-radius: 7px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.library-page .lib-tag.blue { color: var(--klein); background: rgba(0, 47, 167, 0.09); }
.library-page .lib-tag.green { color: #138d3f; background: rgba(22, 184, 78, 0.10); }
.library-page .lib-count {
position: absolute;
left: 10px;
top: 10px;
z-index: 3;
padding: 3px 8px;
border-radius: 999px;
color: #fff;
background: rgba(16, 16, 18, 0.72);
font-size: 11px;
}
.library-page .lib-play-badge {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
width: 40px;
height: 40px;
display: grid;
place-items: center;
transform: translate(-50%, -50%);
border-radius: 50%;
color: #fff;
background: rgba(0, 0, 0, .58);
pointer-events: none;
}
.library-page .lib-play-badge svg { width: 18px; height: 18px; margin-left: 2px; }
.library-page .lib-thumb:hover .lib-play-badge { background: rgba(0, 0, 0, .76); }
.library-page .lib-audio-ph {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 100%;
color: var(--lib-muted);
}
.library-page .lib-audio-ph svg { width: 26px; height: 26px; }
.library-page .lib-audio-ph .mono { font-size: 12px; letter-spacing: .08em; }
.library-page .lib-check {
position: absolute;
top: 12px;
left: 12px;
z-index: 4;
width: 25px;
height: 25px;
display: none;
place-items: center;
border: 2px solid rgba(0, 47, 167, 0.38);
border-radius: 8px;
color: transparent;
background: rgba(255, 255, 255, 0.96);
pointer-events: none;
}
.library-page .lib-check svg { width: 13px; height: 13px; }
.library-page.manage-mode .lib-card .lib-check { display: grid; }
.library-page.manage-mode .lib-card.selected .lib-check {
color: #fff;
border-color: var(--klein);
background: var(--klein);
}
.library-page.manage-mode .lib-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
.library-page.manage-mode .lib-card .lib-count { left: 44px; }
.library-page .lib-empty {
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--lib-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.library-page .lib-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
.library-page .lib-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
.library-page .lib-empty span { font-size: 12px; }
.library-page .lib-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 13px 14px 13px 20px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
color: #fff;
background: rgba(16, 16, 18, 0.94);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
transform: translate(-50%, 140%);
opacity: 0;
pointer-events: none;
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease;
}
.library-page .lib-sel-bar.active { transform: translate(-50%, 0); opacity: 1; pointer-events: auto; }
.library-page .lib-sel-copy strong,
.library-page .lib-sel-copy span { display: block; }
.library-page .lib-sel-copy span { margin-top: 3px; color: rgba(255, 255, 255, 0.58); font-size: 12px; }
.library-page .lib-sel-actions { display: flex; gap: 8px; }
.library-page .lib-sel-actions button {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.library-page .lib-sel-cancel { color: #fff; background: rgba(255, 255, 255, 0.12); }
.library-page .lib-sel-confirm { color: #fff; background: #002fa7; }
.library-page .lib-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
.library-page .list-pager { margin-top: 20px; }
@media (max-width: 1100px) {
.library-page .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.library-page .lib-sel-bar { left: 50%; }
.library-page .lib-toolbar { flex-wrap: wrap; }
}
@media (max-width: 860px) {
.library-page .lib-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.library-page .lib-search { width: 100%; }
}
/*
资产详情弹窗 · .adx-*(立绘大图 + 三视图/版本历史 + 简介/标签/属性 + 商品媒体画廊)
@@ -238,14 +556,6 @@ body.edit-mode .library-page .batch-card.selected { border-color: var(--heat); b
.upload-foot-meta { flex: 1; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.upload-foot-meta .accent { color: var(--heat); font-weight: 600; }
/* ── 资产库成品化:视频成品「项目素材包」 ── */
.library-page .packs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.library-page .pack-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; }
.library-page .pack-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.library-page .pack-thumb { position: relative; width: 100%; aspect-ratio: 9 / 16; max-height: 280px; }
.library-page .pack-thumb video, .library-page .pack-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.library-page .pack-count { position: absolute; left: 8px; top: 8px; padding: 2px 8px; border-radius: var(--r-pill); background: var(--black-alpha-56); color: var(--accent-white); font-size: 11px; letter-spacing: .02em; }
/* 素材包弹窗(portal 到 body) */
.pack-modal-bg { position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, .5); display: flex; align-items: center; justify-content: center; padding: 32px; }
/* 层级顺序():.pack-modal-bg.under-confirm 990 < .modal-bg(ConfirmModal/详情/上传)999 < toast 1000 < .np-lightbox(灯箱)9999
+593
View File
@@ -0,0 +1,593 @@
/* 登录 / 注册 · 对照影擎 login-page。不重写共享 .btn/.pill/.input。 */
.login-page {
--klein: #002fa7;
--klein-hover: #002680;
position: fixed;
inset: 0;
z-index: 10000;
isolation: isolate;
overflow: auto;
min-height: 100dvh;
display: grid;
grid-template-columns: 46% 54%;
background:
linear-gradient(90deg, rgba(0, 4, 12, 0.34) 0%, rgba(0, 8, 24, 0.08) 58%, rgba(0, 7, 20, 0.12) 100%),
url("/assets/yz/welcome-background.png") center / cover no-repeat,
#050814;
color: #fff;
}
.login-page .login-visual {
position: relative;
z-index: 1;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: clamp(42px, 4.2vw, 76px);
color: #fff;
background: linear-gradient(90deg, rgba(0, 4, 12, 0.48), rgba(0, 7, 20, 0.08));
}
.login-page .login-brand-lockup {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.login-page .login-logo {
display: block;
height: 42px;
width: auto;
max-width: min(280px, 100%);
object-fit: contain;
}
.login-page .login-brand-copy {
display: flex;
flex-direction: column;
justify-content: center;
}
.login-page .login-brand-copy span {
display: block;
color: rgba(255, 255, 255, 0.58);
font-size: 13px;
line-height: 1.35;
letter-spacing: 0.16em;
}
.login-page .login-hero {
position: absolute;
top: 50%;
left: 50%;
width: min(76%, 600px);
height: 548px;
display: flex;
flex-direction: column;
justify-content: space-between;
transform: translate(-50%, -50%);
}
.login-page .login-eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: #91afff;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
}
.login-page .login-eyebrow::before {
content: "";
width: 28px;
height: 2px;
border-radius: 99px;
background: #628cff;
}
.login-page .login-hero h1 {
max-width: 600px;
margin: 0;
font-size: clamp(38px, 3.7vw, 62px);
line-height: 1.15;
letter-spacing: -0.045em;
font-weight: 600;
}
.login-page .login-hero-line { display: block; white-space: nowrap; }
.login-page .login-hero-accent { color: #7da1ff; }
.login-page .login-hero-copy > p {
max-width: 500px;
margin: 24px 0 0;
color: rgba(255, 255, 255, 0.62);
font-size: 15px;
line-height: 1.85;
}
.login-page .login-capabilities {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.login-page .login-capability {
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.045);
backdrop-filter: blur(12px);
}
.login-page .login-capability svg {
width: 18px;
height: 18px;
color: #83a5ff;
}
.login-page .login-capability strong {
display: block;
margin-top: 15px;
font-size: 13px;
font-weight: 600;
}
.login-page .login-capability span {
display: block;
margin-top: 4px;
color: rgba(255, 255, 255, 0.48);
font-size: 10px;
}
.login-page .login-visual-footer {
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(255, 255, 255, 0.42);
font-size: 10px;
}
.login-page .login-system-status {
display: inline-flex;
align-items: center;
gap: 7px;
}
.login-page .login-system-status::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: #68d59b;
box-shadow: 0 0 0 4px rgba(104, 213, 155, 0.12);
}
.login-page .login-form-panel {
position: relative;
z-index: auto;
overflow: hidden;
display: grid;
place-items: center;
padding: clamp(48px, 6vw, 106px);
background: transparent;
}
.login-page .login-plus-pattern {
position: absolute;
z-index: 0;
inset: 0;
pointer-events: none;
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
grid-template-rows: repeat(12, minmax(0, 1fr));
overflow: hidden;
-webkit-mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0.08) 0%,
rgba(0, 0, 0, 0.2) 18%,
rgba(0, 0, 0, 0.46) 42%,
rgba(0, 0, 0, 0.76) 68%,
#000 100%
);
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0.08) 0%,
rgba(0, 0, 0, 0.2) 18%,
rgba(0, 0, 0, 0.46) 42%,
rgba(0, 0, 0, 0.76) 68%,
#000 100%
);
}
.login-page .login-plus-pattern span {
display: grid;
place-items: center;
color: rgba(0, 47, 167, 0.19);
font-family: Arial, sans-serif;
font-size: 27px;
font-weight: 300;
line-height: 1;
}
.login-page .login-card {
position: relative;
z-index: 2;
width: min(100%, 520px);
height: 548px;
padding: 44px 46px 38px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 22px;
background: rgba(7, 18, 40, 0.14);
box-shadow:
0 34px 82px rgba(0, 8, 28, 0.38),
0 1px 0 rgba(255, 255, 255, 0.24) inset,
0 0 38px rgba(255, 255, 255, 0.035) inset,
0 0 0 1px rgba(130, 178, 255, 0.08) inset;
-webkit-backdrop-filter: blur(12px) saturate(1.12);
backdrop-filter: blur(12px) saturate(1.12);
}
.login-page .login-card::before {
content: "";
position: absolute;
z-index: 0;
inset: 0;
pointer-events: none;
border-radius: inherit;
background-image: radial-gradient(rgba(255, 255, 255, 0.28) 0.65px, transparent 0.65px);
background-size: 4px 4px;
opacity: 0.08;
}
.login-page .login-card > * { position: relative; z-index: 1; }
.login-page .login-card.shake { animation: login-shake 360ms ease; }
.login-page .login-card.is-register {
height: auto;
min-height: 548px;
overflow: auto;
}
.login-page .login-form-panel:has(.is-register) {
overflow: auto;
align-content: start;
justify-items: center;
}
.login-page .login-form-panel:has(.is-register) .login-extras {
position: static;
transform: none;
margin-top: 16px;
}
.login-page .login-card-heading { margin-bottom: 36px; }
.login-page .login-step {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 18px;
color: #b7ccff;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
}
.login-page .login-step svg { width: 15px; height: 15px; }
.login-page .login-card-heading h2 {
margin: 0;
color: #fff;
font-size: 34px;
line-height: 1.2;
letter-spacing: -0.04em;
font-weight: 600;
text-shadow: 0 2px 18px rgba(0, 16, 52, 0.28);
}
.login-page .login-card-heading p {
margin: 12px 0 0;
color: rgba(255, 255, 255, 0.68);
font-size: 13px;
line-height: 1.7;
}
.login-page .login-form { display: grid; gap: 18px; }
.login-page .login-field { display: grid; gap: 9px; }
.login-page .login-field > span {
color: rgba(255, 255, 255, 0.84);
font-size: 12px;
font-weight: 600;
}
.login-page .login-field > span .req {
color: #ff8a96;
margin-left: 2px;
font-style: normal;
}
.login-page .login-input-wrap {
height: 54px;
display: flex;
align-items: center;
gap: 11px;
padding: 0 15px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 11px;
background: rgba(255, 255, 255, 0.08);
box-shadow:
0 8px 22px rgba(0, 10, 38, 0.12),
0 1px 0 rgba(255, 255, 255, 0.1) inset;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.login-page .login-input-wrap:focus-within {
border-color: rgba(151, 186, 255, 0.86);
background: rgba(255, 255, 255, 0.12);
box-shadow:
0 0 0 4px rgba(116, 161, 255, 0.18),
0 10px 24px rgba(0, 10, 38, 0.18),
0 1px 0 rgba(255, 255, 255, 0.18) inset;
transform: translateY(-1px);
}
.login-page .login-field.has-error .login-input-wrap {
border-color: rgba(200, 61, 77, 0.7);
}
.login-page .login-input-wrap svg {
width: 18px;
height: 18px;
flex: 0 0 auto;
color: rgba(255, 255, 255, 0.64);
}
.login-page .login-input-wrap input {
min-width: 0;
flex: 1;
height: 100%;
padding: 0;
border: 0;
outline: 0;
color: #fff;
background: transparent;
font: inherit;
font-size: 14px;
}
.login-page .login-input-wrap input::placeholder { color: rgba(255, 255, 255, 0.46); }
.login-page .login-input-wrap input:disabled {
color: rgba(255, 255, 255, 0.72);
}
.login-page .login-input-wrap input:-webkit-autofill,
.login-page .login-input-wrap input:-webkit-autofill:hover,
.login-page .login-input-wrap input:-webkit-autofill:focus {
-webkit-text-fill-color: #fff !important;
caret-color: #fff;
-webkit-background-clip: text !important;
background-clip: text !important;
transition: background-color 99999s ease-out 0s;
}
.login-page .login-input-wrap input::-ms-reveal,
.login-page .login-input-wrap input::-ms-clear { display: none; width: 0; height: 0; }
.login-page .login-password-toggle {
width: 32px;
height: 32px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: 8px;
color: rgba(255, 255, 255, 0.62);
background: transparent;
cursor: pointer;
}
.login-page .login-password-toggle:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.login-page .login-password-toggle svg { width: 17px; height: 17px; }
.login-page .login-error {
min-height: 20px;
margin: -4px 0 -2px;
color: #c83d4d;
font-size: 11px;
opacity: 0;
transform: translateY(-3px);
transition: opacity 160ms ease, transform 160ms ease;
}
.login-page .login-error.show {
opacity: 1;
transform: translateY(0);
}
.login-page .login-submit {
height: 54px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 2px;
border: 0;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, #002d9f, #001d70 76%);
box-shadow: 0 14px 30px rgba(0, 25, 102, 0.32);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 180ms ease, box-shadow 180ms ease, filter 180ms ease;
}
.login-page .login-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 36px rgba(0, 23, 94, 0.4);
filter: brightness(1.06);
}
.login-page .login-submit:disabled {
cursor: wait;
filter: saturate(0.85);
}
.login-page .login-submit:disabled svg { animation: login-spin 800ms linear infinite; }
.login-page .login-submit svg { width: 17px; height: 17px; }
.login-page .login-extras {
position: absolute;
z-index: 2;
left: 50%;
bottom: 28px;
width: min(calc(100% - 48px), 520px);
display: grid;
gap: 10px;
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
transform: translateX(-50%);
}
.login-page .login-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-page .login-row label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.login-page .login-row input[type="checkbox"] {
width: 14px;
height: 14px;
accent-color: #7da1ff;
}
.login-page .login-row a,
.login-page .login-switch a {
color: rgba(183, 204, 255, 0.88);
text-decoration: none;
}
.login-page .login-row a:hover,
.login-page .login-switch a:hover { text-decoration: underline; }
.login-page .login-sso {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.login-page .login-sso button {
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.045);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.login-page .login-sso button:hover { background: rgba(255, 255, 255, 0.08); }
.login-page .login-sso svg { width: 14px; height: 14px; }
.login-page .login-switch {
text-align: center;
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
}
.login-page .login-card-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 26px;
color: rgba(255, 255, 255, 0.54);
font-size: 10px;
}
.login-page .login-card-footer svg { width: 13px; height: 13px; }
.login-page .login-note {
margin: 0;
color: rgba(255, 255, 255, 0.52);
font-size: 11px;
line-height: 1.55;
}
.login-page .login-note.ok { color: #68d59b; display: flex; align-items: center; gap: 6px; }
.login-page .login-note.err { color: #c83d4d; }
.login-page .login-verify {
display: flex;
gap: 8px;
}
.login-page .login-verify .login-input-wrap { flex: 1; }
.login-page .login-verify-btn {
height: 54px;
padding: 0 16px;
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 11px;
color: #fff;
background: rgba(255, 255, 255, 0.08);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.login-page .login-verify-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
.login-page .login-verify-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.login-page .login-pass-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.login-page .login-agree {
display: flex;
align-items: flex-start;
gap: 8px;
color: rgba(255, 255, 255, 0.68);
font-size: 12px;
line-height: 1.55;
}
.login-page .login-agree input { margin-top: 2px; accent-color: #7da1ff; }
.login-page .login-agree a { color: #b7ccff; text-decoration: none; }
.login-page .login-agree a:hover { text-decoration: underline; }
.login-page .login-agree.has-error { color: #c83d4d; }
.login-page .login-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 10001;
min-width: 240px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
background: rgba(7, 18, 40, 0.72);
color: #fff;
font-size: 13px;
backdrop-filter: blur(12px);
}
.login-page .login-toast span {
display: block;
margin-top: 4px;
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
}
@keyframes login-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-7px); }
55% { transform: translateX(6px); }
80% { transform: translateX(-3px); }
}
@keyframes login-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1100px) {
.login-page {
grid-template-columns: 1fr;
min-width: 0;
}
.login-page .login-visual { display: none; }
.login-page .login-form-panel {
overflow: auto;
padding: 32px 20px 28px;
}
.login-page .login-card {
height: auto;
min-height: 0;
width: min(100%, 480px);
padding: 32px 24px 28px;
}
.login-page .login-extras {
position: static;
width: min(100%, 480px);
transform: none;
margin-top: 16px;
}
}
+360 -312
View File
@@ -1,133 +1,187 @@
/* messages-page.css · 对齐 public/exact/messages.html · 仅 token,scoped 在 .msg-* */
/* 消息中心 · 对照影擎 messages。仅 scope 在 .msg-page,不改共享 .btn/.pill/.input */
.msg-page {
--klein: #002fa7;
--st-muted: #6f747c;
--st-text: #17181a;
--st-black: #101012;
--st-line: rgba(34, 42, 54, 0.09);
--st-shadow: 0 8px 20px rgba(20, 27, 38, 0.09);
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg-page .page-head {
margin-bottom: 0;
@media (max-width: 1100px) {
.msg-page { margin: -28px -24px -48px; }
}
.msg-head-actions {
.msg-page .page-head {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 14px;
margin-bottom: 6px;
flex-wrap: nowrap;
}
.msg-page .page-head h1 { font-size: 28px; }
.msg-page .page-head .sub { margin-top: 6px; font-size: 15px; color: var(--st-muted); }
.msg-back {
width: 36px;
height: 36px;
flex: 0 0 36px;
margin-top: 4px;
display: grid;
place-items: center;
padding: 0;
border-radius: 8px;
border: 1px solid rgba(28, 34, 43, 0.12);
background: #fff;
color: var(--st-text);
cursor: pointer;
}
.msg-back:hover { background: rgba(34, 42, 54, 0.05); }
.msg-back:focus-visible { outline: none; box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgba(0, 47, 167, 0.28); }
.msg-back svg { width: 18px; height: 18px; display: block; }
.msg-head-actions { display: inline-flex; align-items: center; gap: 10px; margin-left: auto; }
.msg-ghost,
.msg-primary {
display: inline-flex;
align-items: center;
gap: 10px;
justify-content: center;
gap: 9px;
border-radius: 11px;
cursor: pointer;
font: inherit;
font-size: 14px;
}
.msg-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
color: var(--st-text);
border: 1px solid rgba(28, 34, 43, 0.12);
background: rgba(34, 42, 54, 0.05);
}
.msg-ghost:hover:not(:disabled) { background: rgba(34, 42, 54, 0.10); }
.msg-ghost:disabled {
color: rgba(28, 34, 43, 0.36);
border-color: rgba(28, 34, 43, 0.07);
background: rgba(34, 42, 54, 0.025);
cursor: not-allowed;
}
.msg-ghost svg, .msg-primary svg {
display: block;
width: 18px;
height: 18px;
}
.msg-ghost-sm { min-width: 0; height: 38px; padding: 0 14px; }
.msg-primary {
min-width: 0;
height: 42px;
padding: 0 18px;
color: #fff;
border: 0;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.msg-primary:hover { background: #002680; }
.msg-primary.is-active,
.msg-ghost.is-active {
color: var(--klein);
background: rgba(0, 47, 167, 0.09);
border: 1px solid rgba(0, 47, 167, 0.18);
box-shadow: none;
}
.msg-workbench {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
/* 收件箱在面板内部滚动(.msg-list overflow:auto) 把工作台收进视口高度,
而不是让消息把整页撑高;减去 shell + content 上下内边距 + 页头/页脚 */
height: calc(100vh - 280px);
min-height: 480px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
}
.msg-panel {
min-width: 0;
background: transparent;
border: 0;
border-radius: 0;
grid-template-columns: 350px minmax(0, 1fr);
height: calc(100vh - 260px);
min-height: 620px;
overflow: hidden;
border: 1px solid var(--st-line);
border-radius: 14px;
background: rgba(34, 42, 54, 0.025);
box-shadow: var(--st-shadow);
}
.msg-inbox,
.msg-detail {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.msg-inbox { border-right: 1px solid var(--border-faint); }
.msg-panel-h {
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-faint);
background: transparent;
.msg-inbox {
border-right: 1px solid var(--st-line);
background: rgba(255, 255, 255, 0.88);
}
.msg-panel-h .ti {
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
background: none;
}
.msg-panel-h .mono {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .04em;
.msg-toolbar {
padding: 18px;
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
}
.msg-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-faint);
gap: 7px;
margin-bottom: 12px;
}
.msg-filter {
height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
background: var(--surface);
color: var(--black-alpha-56);
font-family: inherit;
padding: 0 11px;
border: 0;
border-radius: 8px;
color: var(--st-muted);
background: rgba(34, 42, 54, 0.055);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.msg-filter:hover {
border-color: var(--black-alpha-24);
color: var(--accent-black);
background: var(--black-alpha-4);
}
.msg-filter:hover { background: rgba(34, 42, 54, 0.10); color: var(--st-text); }
.msg-filter.active {
border-color: var(--heat-20);
background: var(--heat-12);
color: var(--heat);
font-weight: 600;
color: #fff;
background: var(--st-black);
}
.msg-filter .ct {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .02em;
.msg-filter.is-muted {
color: var(--st-muted);
background: rgba(34, 42, 54, 0.03);
}
.msg-filter.active.is-muted {
color: #fff;
background: var(--st-black);
}
.msg-search {
position: relative;
padding: 0 14px 12px;
border-bottom: 1px solid var(--border-faint);
}
.msg-search svg {
position: absolute;
left: 26px;
top: 10px;
width: 13px;
height: 13px;
color: var(--black-alpha-48);
pointer-events: none;
width: 100%;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 10px;
background: #fff;
color: var(--st-muted);
}
.msg-search input {
width: 100%;
height: 34px;
padding: 0 12px 0 32px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
color: var(--accent-black);
font-family: inherit;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
outline: none;
}
.msg-search input:focus {
background: var(--surface);
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
color: var(--st-text);
}
.msg-list {
flex: 1;
min-height: 0;
@@ -137,239 +191,232 @@
padding: 14px 16px 18px;
text-align: center;
font-size: 12px;
color: var(--black-alpha-48);
letter-spacing: .04em;
color: var(--st-muted);
}
.msg-item {
position: relative;
width: 100%;
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 10px;
padding: 14px 16px;
border: 0;
border-bottom: 1px solid var(--border-faint);
background: transparent;
font-family: inherit;
grid-template-columns: 38px minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
padding: 16px 18px;
text-align: left;
border: 0;
border-bottom: 1px solid rgba(34, 42, 54, 0.075);
background: transparent;
font: inherit;
cursor: pointer;
}
.msg-item:hover { background: var(--black-alpha-4); }
.msg-item.active { background: var(--heat-12); }
.msg-item.active::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3px;
background: var(--heat);
.msg-item:hover { background: rgba(34, 42, 54, 0.04); }
.msg-item.active {
background: rgba(0, 47, 167, 0.07);
box-shadow: inset 3px 0 var(--klein);
}
.msg-item.read .msg-item-title { color: var(--black-alpha-56); font-weight: 500; }
.msg-type-ic {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
background: var(--background-lighter);
color: var(--black-alpha-72);
}
.msg-type-ic svg { width: 14px; height: 14px; }
.msg-type-ic.task { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.msg-type-ic.team { background: var(--black-alpha-4); color: var(--accent-black); }
.msg-type-ic.billing { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
.msg-type-ic.system { background: var(--black-alpha-7); color: var(--black-alpha-72); }
/* display:block span 提升为块级容器,确保 flex 子行(.msg-item-row/.msg-brief/.msg-item-foot)
能正确换行堆叠;否则父 inline-span grid 结合时文字在框内错位 */
.msg-item-main { min-width: 0; display: block; }
.msg-item-row {
.msg-item-icon {
width: 34px;
height: 34px;
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
line-height: 0;
border-radius: 9px;
color: var(--klein);
background: rgba(0, 47, 167, 0.09);
flex: 0 0 34px;
align-self: center;
}
.msg-dot {
width: 7px;
height: 7px;
border-radius: var(--r-pill);
background: var(--heat);
.msg-item-icon svg {
display: block;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.msg-item.read .msg-dot { display: none; }
.msg-item-title {
flex: 1;
min-width: 0;
.msg-item-icon.billing { color: #8a6a12; background: rgba(236, 183, 48, 0.14); }
.msg-item-icon.system { color: #4c5360; background: rgba(34, 42, 54, 0.08); }
.msg-item-icon.team { color: #138d3f; background: rgba(22, 184, 78, 0.10); }
.msg-item strong {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--st-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-black);
font-size: 13px;
font-weight: 600;
}
.msg-time {
flex-shrink: 0;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .02em;
.msg-item .msg-time {
display: block;
color: var(--st-muted);
font-size: 11px;
white-space: nowrap;
align-self: start;
padding-top: 2px;
}
.msg-brief {
margin-top: 4px;
color: var(--black-alpha-56);
.msg-item.read strong { color: var(--st-muted); font-weight: 500; }
.msg-item p {
margin: 5px 0 0;
color: var(--st-muted);
font-size: 12px;
line-height: 1.55;
/* 最多两行后省略:display:-webkit-box 建立块级盒受父 grid min-width:0 约束;
overflow-wrap + word-break 保证连续长字符串在列内折行而不溢出框;
换行第二行与 .msg-item-main 左边界对齐 */
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: break-word;
word-break: break-word;
}
.msg-item-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.msg-priority {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
background: var(--background-lighter);
color: var(--black-alpha-56);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .02em;
}
/* 列表底部标签行:项目名过长时胶囊内单行省略胶囊定高 20px,若放任文字换行
第二行会溢出到胶囊框外(文字与框错位);inline-block + ellipsis 把长名收在框内 */
.msg-item-foot .msg-priority {
display: inline-block;
min-width: 0;
max-width: 100%;
line-height: 18px; /* 20px 高 - 上下 1px 边框,单行文字垂直居中 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 优先级标签(更新/成功…)内容短且固定,不许被长项目名挤压到文字竖排 */
.msg-item-foot .msg-priority:first-child { flex-shrink: 0; }
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
.msg-priority.warn { background: var(--honey-bg); border-color: var(--honey-bd); color: var(--accent-honey); }
.msg-priority.err { background: var(--crimson-bg); border-color: var(--crimson-bd); color: var(--accent-crimson); }
.msg-priority.info { background: var(--heat-12); border-color: var(--heat-20); color: var(--heat); }
.msg-empty {
min-height: 320px;
display: grid;
place-items: center;
align-content: center;
justify-items: center;
gap: 8px;
padding: 24px;
color: var(--black-alpha-48);
color: var(--st-muted);
font-size: 13px;
text-align: center;
}
.msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-40); }
.msg-detail-empty {
flex: 1;
min-height: 520px;
display: grid;
place-items: center;
gap: 8px;
color: var(--black-alpha-48);
text-align: center;
.msg-empty svg { display: block; width: 24px; height: 24px; }
.msg-empty .msg-ghost { margin-top: 6px; }
.msg-mute-note {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid rgba(34, 42, 54, 0.06);
color: var(--st-muted);
font-size: 12px;
background: rgba(34, 42, 54, 0.03);
}
.msg-detail-empty .ic {
width: 46px;
height: 46px;
.msg-mute-note .msg-ghost { flex: 0 0 auto; height: 30px; padding: 0 12px; }
.msg-detail-empty-inner .msg-ghost { margin-top: 4px; }
@keyframes msg-skel {
0% { background-position: -240px 0; }
100% { background-position: 240px 0; }
}
.msg-skel-bar,
.msg-skel-ico,
.msg-skel-time,
.msg-skel-more span {
display: block;
border-radius: 8px;
background-color: rgba(34, 42, 54, 0.06);
background-image: linear-gradient(90deg, transparent, rgba(34, 42, 54, 0.08), transparent);
background-size: 240px 100%;
background-repeat: no-repeat;
animation: msg-skel 1.2s ease-in-out infinite;
}
.msg-skel-row {
display: grid;
place-items: center;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
grid-template-columns: 38px minmax(0, 1fr) 36px;
align-items: center;
gap: 11px;
padding: 16px 18px;
border-bottom: 1px solid rgba(34, 42, 54, 0.06);
}
.msg-skel-ico { width: 34px; height: 34px; border-radius: 9px; }
.msg-skel-copy { display: grid; gap: 8px; min-width: 0; }
.msg-skel-bar { height: 12px; width: 72%; }
.msg-skel-bar-sm { height: 10px; width: 88%; opacity: .85; }
.msg-skel-row:nth-child(odd) .msg-skel-bar { width: 64%; }
.msg-skel-row:nth-child(odd) .msg-skel-bar-sm { width: 76%; }
.msg-skel-row:nth-child(3n) .msg-skel-bar { width: 80%; }
.msg-skel-time { width: 28px; height: 10px; justify-self: end; }
.msg-skel-more {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 16px;
}
.msg-skel-more span { width: 6px; height: 6px; border-radius: 999px; }
.msg-skel-more span:nth-child(2) { animation-delay: .15s; }
.msg-skel-more span:nth-child(3) { animation-delay: .3s; }
.msg-detail-skel { flex: 1; min-height: 0; padding: 28px; }
.msg-skel-title { height: 22px; width: 46%; margin-bottom: 14px; }
.msg-skel-lead { height: 12px; width: 88%; margin-bottom: 10px; }
.msg-skel-lead-2 { width: 62%; margin-bottom: 24px; }
.msg-skel-summary {
display: grid;
gap: 16px;
padding: 20px;
border-radius: 12px;
background: rgba(34, 42, 54, 0.04);
}
.msg-skel-kv {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: center;
gap: 20px;
}
.msg-skel-k { height: 11px; width: 52px; }
.msg-skel-v { height: 12px; width: 42%; }
.msg-skel-kv:nth-child(even) .msg-skel-v { width: 58%; }
.msg-skel-kv:nth-child(3n) .msg-skel-v { width: 34%; }
.msg-detail {
padding: 0;
background: rgba(255, 255, 255, 0.72);
}
.msg-detail-empty svg { width: 21px; height: 21px; }
.msg-detail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 22px 24px 24px;
padding: 28px;
}
.msg-detail-top {
display: flex;
align-items: flex-start;
gap: 12px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-faint);
}
.msg-detail-title {
min-width: 0;
flex: 1;
}
.msg-detail-title h2 {
margin: 0;
font-size: 20px;
line-height: 1.35;
.msg-detail h2 {
margin: 0 0 8px;
font-size: 22px;
font-weight: 600;
letter-spacing: -.012em;
color: var(--accent-black);
overflow-wrap: break-word;
color: var(--st-text);
line-height: 1.35;
}
.msg-detail-title .meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
}
.msg-body-text {
margin: 18px 0 0;
color: var(--accent-black);
.msg-detail-body > p {
margin: 0 0 24px;
color: var(--st-muted);
font-size: 14px;
line-height: 1.75;
overflow-wrap: break-word;
line-height: 1.7;
}
.msg-props {
.msg-summary {
display: grid;
grid-template-columns: 110px 1fr;
gap: 10px 16px;
margin-top: 18px;
padding: 14px 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px 20px;
padding: 20px;
border-radius: 12px;
background: rgba(34, 42, 54, 0.05);
}
.msg-props .k {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
}
.msg-props .v {
.msg-summary span { color: var(--st-muted); font-size: 13px; }
.msg-summary strong {
font-size: 14px;
font-weight: 600;
color: var(--st-text);
min-width: 0;
color: var(--accent-black);
font-size: 13px;
overflow-wrap: break-word;
}
.msg-props .v a { color: var(--heat); }
.msg-related {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--klein);
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.msg-related:hover { color: #002680; }
.msg-timeline {
margin-top: 18px;
padding: 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
border-radius: 12px;
background: rgba(34, 42, 54, 0.04);
}
.msg-timeline-h {
margin-bottom: 12px;
color: var(--accent-black);
color: var(--st-text);
font-size: 13px;
font-weight: 600;
}
@@ -378,71 +425,72 @@
grid-template-columns: 68px 1fr;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--border-faint);
border-top: 1px solid rgba(34, 42, 54, 0.07);
}
.msg-step:first-of-type { border-top: 0; padding-top: 0; }
.msg-step .t {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .02em;
}
.msg-step .d {
color: var(--black-alpha-72);
font-size: 12.5px;
line-height: 1.55;
overflow-wrap: break-word;
}
.msg-step .t { color: var(--st-muted); font-size: 12px; }
.msg-step .d { color: var(--st-text); font-size: 13px; line-height: 1.55; }
.msg-log {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--accent-black);
color: var(--accent-white);
border-radius: 12px;
background: var(--st-black);
color: #fff;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.7;
letter-spacing: .02em;
white-space: pre-wrap;
}
/* 查看日志:后端缺 log 字段时的占位(壳照出、内容留白,不造假) */
.msg-log-empty {
color: var(--accent-white);
opacity: .48;
}
.msg-log-empty { opacity: .48; }
.msg-detail-f {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid var(--border-faint);
background: var(--background-lighter);
padding: 14px 20px;
border-top: 1px solid rgba(34, 42, 54, 0.08);
background: rgba(255, 255, 255, 0.7);
}
.msg-detail-f .spacer { flex: 1; }
/* 「查看日志」展开中的高亮(单橙锚点,alpha 表达态,不换 hue) */
.msg-detail-f .btn.is-active {
border-color: var(--heat-20);
background: var(--heat-12);
color: var(--heat);
.msg-detail-empty {
flex: 1;
display: grid;
place-items: center;
align-content: center;
color: var(--st-muted);
text-align: center;
}
/* 底栏带图标的 ghost 动作(静音同类)图标与文字间距 */
.msg-detail-f .btn svg { margin-right: 6px; }
.msg-foot-note {
.msg-detail-empty-inner {
display: grid;
justify-items: center;
gap: 10px;
}
.msg-detail-empty .ic {
width: 46px;
height: 46px;
display: flex;
align-items: center;
gap: 8px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .04em;
justify-content: center;
line-height: 0;
border-radius: 12px;
background: rgba(34, 42, 54, 0.07);
}
.msg-foot-note a { color: var(--heat); cursor: pointer; }
@media (max-width: 1280px) {
.msg-workbench { grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); }
.msg-detail-empty .ic svg {
display: block;
width: 22px;
height: 22px;
}
@media (prefers-reduced-motion: reduce) {
.msg-skel-bar,
.msg-skel-ico,
.msg-skel-time,
.msg-skel-more span { animation: none; }
}
@media (max-width: 860px) {
.msg-workbench { grid-template-columns: 1fr; }
.msg-inbox { border-right: 0; border-bottom: 1px solid var(--border-faint); }
.msg-workbench { grid-template-columns: 1fr; height: auto; }
.msg-inbox { border-right: 0; border-bottom: 1px solid var(--st-line); }
.msg-list { max-height: 360px; }
}
+264 -105
View File
@@ -1,116 +1,280 @@
/* 模特库 · 顶级实体(形象图 + 三视图)· 仅用 design-restraint token,不写裸 hex */
.models-empty { min-height: 240px; flex-direction: column; gap: 8px; }
.models-empty-hint { font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.model-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
transition: border-color .15s;
}
.model-card:hover { border-color: var(--border-loud); }
.model-card:focus-visible { outline: 2px solid var(--heat-40); outline-offset: 2px; }
/* 形象图(9:16 氛围正面图)· 主视觉 */
.model-portrait {
position: relative;
/* 模特库列表 · 对照影擎 models。详情弹窗仍用后半段 .model-detail-*。不重写共享 .btn/.chip/.tabs。 */
.models-page {
--klein: #002fa7;
--klein-hover: #002680;
--ml-black: #101012;
--ml-muted: #6f747c;
--ml-line: rgba(34, 42, 54, 0.10);
--ml-wash: rgba(34, 42, 54, 0.045);
--ml-shadow: 0 8px 18px rgba(20, 27, 38, 0.075);
width: 100%;
aspect-ratio: 3 / 4;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
@media (max-width: 1100px) {
.models-page { margin: -28px -24px -48px; }
}
.models-page .ml-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
}
.models-page .ml-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.models-page .ml-head p { margin: 0; color: var(--ml-muted); font-size: 15px; }
.models-page .ml-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.models-page .ml-ghost,
.models-page .ml-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
font: inherit;
cursor: pointer;
}
.models-page .ml-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
}
.models-page .ml-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.models-page .ml-ghost.is-on { color: #fff; border-color: var(--ml-black); background: var(--ml-black); }
.models-page .ml-primary {
min-width: 158px;
height: 50px;
padding: 0 22px;
border-radius: 11px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.models-page .ml-primary:hover { transform: translateY(-2px); background: var(--klein-hover); }
.models-page .ml-primary:disabled { opacity: 0.38; transform: none; box-shadow: none; cursor: not-allowed; }
.models-page .ml-ghost svg,
.models-page .ml-primary svg { width: 19px; height: 19px; }
.models-page .ml-toolbar {
min-height: 66px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
background: var(--ml-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
.models-page .ml-seg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.models-page .ml-seg-btn {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
color: #50555d;
background: transparent;
font: inherit;
font-size: 14px;
cursor: pointer;
}
.models-page .ml-seg-btn:hover { color: var(--accent-black); }
.models-page .ml-seg-btn.active { color: #fff; background: var(--ml-black); }
.models-page .ml-note { color: var(--ml-muted); font-size: 12px; }
.models-page .ml-section { margin: 32px 0 17px; }
.models-page .ml-section h2 { margin: 0; font-size: 21px; font-weight: 600; color: var(--accent-black); }
.models-page .ml-section p { margin: 5px 0 0; color: var(--ml-muted); font-size: 13px; }
.models-page .ml-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.models-page .ml-card {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--ml-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--ml-shadow);
cursor: pointer;
outline: none;
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.models-page .ml-card:hover { transform: translateY(-3px); background: rgba(34, 42, 54, 0.08); }
.models-page .ml-card.selected {
border-color: var(--klein);
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--ml-shadow);
}
.models-page .ml-thumb {
position: relative;
aspect-ratio: 4 / 5;
overflow: hidden;
background: #e6e7ea;
border: 0;
border-radius: 0;
padding: 0;
}
.models-page .ml-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
.models-page .ml-thumb.has-mock-media {
background-image: var(--mock-media-url);
background-size: cover;
background-position: center;
border: 0;
border-radius: 0;
}
/* 官方模板标签:复用 .pill.info(单橙锚点),定位左上 */
.model-official {
position: absolute;
top: 8px;
left: 8px;
pointer-events: none;
}
/* 删除按钮:默认半隐,hover 卡片才显;crimson 危险态 */
.model-del-btn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--r-sm);
background: var(--black-alpha-48);
color: var(--accent-white);
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity .15s, background .15s;
}
.model-card:hover .model-del-btn { opacity: 1; }
.model-del-btn:hover { background: var(--accent-crimson); }
.model-confirm {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--black-alpha-56);
}
.model-confirm .btn {
background: var(--surface);
color: var(--accent-black);
border-color: var(--border-faint);
}
.model-confirm .btn:hover {
background: var(--surface);
border-color: var(--border-loud);
}
.model-del { background: var(--accent-crimson); border-color: var(--accent-crimson); color: var(--accent-white); }
.model-confirm .model-del,
.model-confirm .model-del:hover {
background: var(--accent-crimson);
border-color: var(--accent-crimson);
color: var(--accent-white);
}
.model-body { padding: 10px 12px 12px; }
.model-name {
font-size: 13.5px;
font-weight: 500;
color: var(--accent-black);
.models-page .ml-meta { padding: 15px 16px 17px; }
.models-page .ml-meta h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 三视图(16:9 白底)· 成套证明 */
.model-triview {
.models-page .ml-meta p { margin: 0; color: var(--ml-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.models-page .ml-triview {
width: 100%;
aspect-ratio: 16 / 9;
margin-top: 8px;
border-radius: var(--r-sm);
margin-top: 10px;
overflow: hidden;
border-radius: 8px;
background: rgba(34, 42, 54, 0.06);
}
.model-triview .ph-frame { font-size: 11px; }
.model-tags {
.models-page .ml-triview.has-mock-media {
background-image: var(--mock-media-url);
background-size: cover;
background-position: center;
border: 0;
}
.models-page .ml-triview .ph-frame { font-size: 11px; }
.models-page .ml-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
font-size: 11.5px;
color: var(--black-alpha-48);
letter-spacing: .02em;
margin-top: 13px;
}
.models-page .ml-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border-radius: 7px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.models-page .ml-tag.blue { color: var(--klein); background: rgba(0, 47, 167, 0.09); }
.models-page .ml-tag.green { color: #138d3f; background: rgba(22, 184, 78, 0.10); }
.models-page .ml-check {
position: absolute;
top: 12px;
left: 12px;
z-index: 4;
width: 25px;
height: 25px;
display: none;
place-items: center;
border: 2px solid rgba(0, 47, 167, 0.38);
border-radius: 8px;
color: transparent;
background: rgba(255, 255, 255, 0.96);
pointer-events: none;
}
.models-page .ml-check svg { width: 13px; height: 13px; }
.models-page.manage-mode .ml-card.selectable .ml-check { display: grid; }
.models-page.manage-mode .ml-card.selected .ml-check {
color: #fff;
border-color: var(--klein);
background: var(--klein);
}
.models-page.manage-mode .ml-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
.models-page .ml-empty {
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--ml-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.models-page .ml-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
.models-page .ml-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
.models-page .ml-empty span { font-size: 12px; }
.models-page .ml-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 13px 14px 13px 20px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
color: #fff;
background: rgba(16, 16, 18, 0.94);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
transform: translate(-50%, 140%);
opacity: 0;
pointer-events: none;
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease;
}
.models-page .ml-sel-bar.active { transform: translate(-50%, 0); opacity: 1; pointer-events: auto; }
.models-page .ml-sel-copy strong,
.models-page .ml-sel-copy span { display: block; }
.models-page .ml-sel-copy span { margin-top: 3px; color: rgba(255, 255, 255, 0.58); font-size: 12px; }
.models-page .ml-sel-actions { display: flex; gap: 8px; }
.models-page .ml-sel-actions button {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.models-page .ml-sel-cancel { color: #fff; background: rgba(255, 255, 255, 0.12); }
.models-page .ml-sel-confirm { color: #fff; background: #002fa7; }
.models-page .ml-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1100px) {
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.models-page .ml-sel-bar { left: 50%; }
}
@media (max-width: 860px) {
.models-page .ml-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* 模特详情弹窗:参考视频项目角色详情结构 */
@@ -139,23 +303,18 @@
.model-detail-section-h .mono { font-size: 11px; color: var(--black-alpha-48); letter-spacing: .04em; }
.model-detail-section-actions { display: flex; align-items: center; gap: 8px; }
.model-detail-section-actions .spinner { width: 13px; height: 13px; border-width: 2px; flex: 0 0 auto; }
/* 形象图大图:3:4 竖版,与卡片缩图比例一致 */
.model-detail-portrait {
width: 100%;
aspect-ratio: 3 / 4;
border-radius: var(--r-md);
}
.model-detail-portrait[role="button"] { cursor: zoom-in; }
.model-detail-tags {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
/* 三视图:16:9 单容器(不拆 3 张) */
.model-detail-triview {
width: 100%;
aspect-ratio: 16 / 9;
File diff suppressed because it is too large Load Diff
+559 -271
View File
@@ -267,306 +267,594 @@
}
/* ============================================================
新建商品 · 右侧 Drawer(在商品库页面原地打开)
像素基线: public/exact/products.html #pc-drawer + 其内联 <style>
全部 scope .pc-drawer ,与上方整页 .product-create-page 互不影响
新建商品抽屉 · 对照影擎 product-drawer不重写共享 .btn/.input/.drawer
============================================================ */
/* .drawer.pc-drawer(0,2,0)·提高特异性压过共享 .drawer{width:540px}
dev App.tsx 链路的 product-create-page.css design-restraint.css 先注入,
等特异性时基类反而后加载会赢,故必须双类提权 */
.drawer.pc-drawer { width: 820px; max-width: 100vw; }
.pc-drawer .drawer-h h3 { font-size: 16px; font-weight: 600; }
.pc-drawer .drawer-b { padding: 24px 28px; }
.pc-drawer .drawer-b .form-card { background: transparent; border: 0; padding: 0; border-radius: 0; }
.pc-drawer .drawer-f { padding: 14px 24px; background: var(--surface); align-items: center; }
.pc-drawer .drawer-f .btn-guide {
margin-right: auto;
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--black-alpha-56);
background: transparent; border: 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--r-md);
font-family: inherit;
transition: background var(--t-base), color var(--t-base);
.pcd-layer {
--klein: #002fa7;
--klein-hover: #002680;
--pcd-muted: #6f747c;
--pcd-line: rgba(34, 42, 54, 0.12);
position: fixed;
inset: 0;
z-index: 80;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: visibility 0s linear 240ms, opacity 220ms ease;
}
.pc-drawer .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }
.pc-drawer .drawer-f .btn-guide svg { width: 14px; height: 14px; }
.pcd-layer.open {
visibility: visible;
opacity: 1;
pointer-events: auto;
transition-delay: 0s;
}
.pcd-scrim {
position: absolute;
inset: 0;
border: 0;
background: rgba(15, 18, 24, 0.54);
backdrop-filter: blur(2px);
cursor: default;
}
.pcd-drawer {
position: absolute;
top: 0;
right: 0;
width: min(720px, 94vw);
height: 100%;
display: flex;
flex-direction: column;
color: var(--accent-black);
background: #fff;
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);
}
.pcd-layer.open .pcd-drawer { transform: translateX(0); }
/* form-card · 表单容器(drawer 内字段) */
.pc-drawer .form-card .field { margin-bottom: 16px; }
.pc-drawer .form-card .field:last-child { margin-bottom: 0; }
.pc-drawer .form-card .field-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px;
.pcd-head {
min-height: 82px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 28px;
border-bottom: 1px solid rgba(34, 42, 54, 0.09);
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(12px);
}
.pc-drawer .form-card .field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--accent-black); margin-bottom: 6px;
}
.pc-drawer .form-card .field-label .req { color: var(--heat); margin-left: 2px; }
.pc-drawer .form-card .field-label .opt {
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
}
.pc-drawer .pc-type-hint {
margin-top: 6px;
font-size: 12px;
color: var(--black-alpha-48);
}
.pc-drawer .form-card .input,
.pc-drawer .form-card .select {
width: 100%; height: 38px;
background: var(--background-lighter);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
padding: 0 14px;
font-size: 14px; color: var(--accent-black);
outline: none; font-family: inherit;
transition: border-color var(--t-base);
}
.pc-drawer .form-card .input:focus,
.pc-drawer .form-card .select:focus {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
/* 商品主图 · 上传(左) + 示例(右) */
.pc-drawer .form-card .pf-upload-row {
.pcd-head h2 { margin: 0 0 4px; font-size: 21px; font-weight: 600; }
.pcd-head p { margin: 0; color: var(--pcd-muted); font-size: 11px; }
.pcd-close {
width: 38px;
height: 38px;
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px; align-items: stretch;
place-items: center;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 11px;
color: var(--pcd-muted);
background: rgba(34, 42, 54, 0.045);
cursor: pointer;
}
.pc-drawer .form-card .pf-upload-zone {
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-md);
padding: 28px 20px;
background: var(--background-lighter);
cursor: pointer; text-align: center;
transition: border-color var(--t-base), background var(--t-base);
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 180px;
.pcd-close:hover { color: var(--accent-black); background: rgba(34, 42, 54, 0.09); }
.pcd-close svg { width: 18px; height: 18px; }
.pcd-body {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 26px 28px 34px;
}
.pc-drawer .form-card .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
/* 拖拽悬停:边框/底色变橙(对齐 V1 pfZone dragover) */
.pc-drawer .form-card .pf-upload-zone.is-dragover { border-color: var(--heat); background: var(--heat-8); }
.pc-drawer .form-card .pf-upload-zone .uz-ic {
width: 44px; height: 44px;
margin: 0 auto 10px;
background: var(--surface);
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
color: var(--heat);
display: grid; place-items: center;
.pcd-form { display: grid; gap: 18px; }
.pcd-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.pcd-field { min-width: 0; display: grid; gap: 8px; }
.pcd-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #303640;
font-size: 12px;
font-weight: 600;
}
.pc-drawer .form-card .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }
.pc-drawer .form-card .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }
.pc-drawer .form-card .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
.pc-drawer .form-card .pf-upload-zone .uz-d {
margin-top: 8px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
.pcd-label small { color: var(--pcd-muted); font-size: 10px; font-weight: 500; }
.pcd-req { color: #e34c4c; }
.pcd-field input,
.pcd-select-trigger {
width: 100%;
height: 48px;
padding: 0 14px;
border: 1px solid var(--pcd-line);
border-radius: 11px;
outline: 0;
color: var(--accent-black);
background: rgba(255, 255, 255, 0.92);
font: inherit;
font-size: 13px;
}
/* 示例图 · 纵向卡片 */
.pc-drawer .form-card .pf-example {
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 16px;
display: flex; flex-direction: column; gap: 10px;
.pcd-field input:focus,
.pcd-point input:focus {
border-color: rgba(0, 47, 167, 0.62);
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.08);
}
.pc-drawer .form-card .pf-example .ex-h {
font-size: 13px; font-weight: 600; color: var(--accent-black);
.pcd-field input[aria-invalid="true"],
.pcd-select-trigger.is-error { border-color: #e34c4c; }
.pcd-hint { font-size: 11px; color: var(--pcd-muted); }
.pcd-select { position: relative; z-index: 4; }
.pcd-select.open { z-index: 20; }
.pcd-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 13px;
text-align: left;
cursor: pointer;
}
.pc-drawer .form-card .pf-example .ex-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
.pcd-select-trigger .is-placeholder { color: #a8afb9; }
.pcd-select-trigger:hover,
.pcd-select.open .pcd-select-trigger { border-color: rgba(0, 47, 167, 0.36); }
.pcd-select.open .pcd-select-trigger { box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07); }
.pcd-select-trigger svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: var(--pcd-muted);
transition: transform 180ms ease;
}
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb {
aspect-ratio: 1;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
display: grid; place-items: center;
color: var(--black-alpha-32);
.pcd-select.open .pcd-select-trigger svg { transform: rotate(180deg); }
.pcd-select-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 30;
display: grid;
gap: 3px;
padding: 6px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 11px;
background: #fff;
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-5px);
}
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
.pcd-select.open .pcd-select-menu {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.pcd-select-option {
min-height: 38px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.pcd-select-option:hover { background: #e8f2ff; }
.pcd-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
.pcd-block { margin-top: 8px; padding-top: 22px; border-top: 1px solid rgba(34, 42, 54, 0.08); }
.pcd-block-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
}
.pcd-block-head strong { display: block; font-size: 13px; }
.pcd-block-head p { margin: 4px 0 0; color: var(--pcd-muted); font-size: 10px; }
.pcd-block-head > span { color: var(--pcd-muted); font-size: 10px; white-space: nowrap; }
.pcd-sp-head { align-items: center; }
.pcd-upload-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 12px;
align-items: stretch;
}
.pcd-upload {
min-height: 142px;
display: grid;
place-items: center;
padding: 18px;
border: 1px dashed rgba(0, 47, 167, 0.25);
border-radius: 13px;
color: var(--klein);
background: rgba(225, 237, 255, 0.52);
text-align: center;
cursor: pointer;
}
.pcd-upload:hover,
.pcd-upload.is-dragover {
border-color: rgba(0, 47, 167, 0.55);
background: rgba(211, 228, 255, 0.78);
}
.pcd-upload-hit {
display: grid;
place-items: center;
gap: 7px;
pointer-events: none;
}
/* 真实示例图:图片铺满,去掉斜纹底 */
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img { background: var(--background-lighter); }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img::after { display: none; }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb--img img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pc-drawer .form-card .pf-example .ex-d {
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
.pcd-upload-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
color: #fff;
background: var(--klein);
box-shadow: 0 8px 18px rgba(0, 47, 167, 0.18);
}
.pcd-upload-icon svg { width: 20px; height: 20px; }
.pcd-upload-hit strong { color: var(--accent-black); font-size: 12px; }
.pcd-upload-hit small { color: var(--pcd-muted); font-size: 9px; }
.pcd-preview {
min-height: 142px;
display: grid;
grid-template-rows: 1fr auto;
gap: 8px;
padding: 8px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 13px;
background: rgba(34, 42, 54, 0.035);
}
.pcd-preview-frame {
position: relative;
min-height: 102px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 9px;
color: var(--pcd-muted);
background:
linear-gradient(rgba(34, 42, 54, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 42, 54, 0.035) 1px, transparent 1px),
#fff;
background-size: 18px 18px;
}
.pcd-preview-frame.has-image { cursor: zoom-in; }
.pcd-preview-frame img { width: 100%; height: 100%; display: block; object-fit: cover; }
.pcd-preview-ph { display: grid; place-items: center; gap: 5px; font-size: 9px; text-align: center; }
.pcd-preview-ph svg { width: 20px; height: 20px; }
.pcd-preview > span {
overflow: hidden;
color: var(--pcd-muted);
font-size: 9px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.pcd-thumb-grid {
width: 100%;
height: 100%;
display: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 3px;
padding: 3px;
}
.pcd-preview-frame.multi-image .pcd-thumb-grid { display: grid; }
.pcd-thumb {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border-radius: 6px;
}
.pcd-thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.pcd-thumb-x,
.pcd-clear,
.pcd-gallery-x {
position: absolute;
z-index: 5;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.76);
color: #fff;
background: rgba(18, 22, 29, 0.72);
cursor: pointer;
}
.pcd-thumb-x:hover,
.pcd-clear:hover,
.pcd-gallery-x:hover { background: #d83f50; }
.pcd-thumb-x { top: 4px; right: 4px; width: 21px; height: 21px; border-radius: 7px; }
.pcd-thumb-x svg { width: 12px; height: 12px; }
.pcd-clear {
top: 7px;
right: 7px;
width: 27px;
height: 27px;
border-radius: 9px;
}
.pcd-clear svg { width: 14px; height: 14px; }
.pcd-count {
position: absolute;
right: 6px;
bottom: 6px;
padding: 4px 7px;
border-radius: 999px;
color: #fff;
background: rgba(18, 22, 29, 0.74);
font-size: 9px;
font-weight: 600;
}
.pcd-thumb-state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.48);
}
.pcd-thumb-retry,
.pcd-retry-cover {
position: absolute;
left: 50%;
bottom: 8px;
z-index: 6;
transform: translateX(-50%);
height: 22px;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: #fff;
color: var(--accent-black);
font: inherit;
font-size: 10px;
cursor: pointer;
}
.pc-drawer .form-card .pf-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px; margin-top: 12px;
.pcd-add-point {
height: 36px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 12px;
border: 1px solid rgba(0, 47, 167, 0.18);
border-radius: 10px;
color: var(--klein);
background: rgba(0, 47, 167, 0.055);
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.pc-drawer .form-card .pf-grid:empty { display: none; }
.pcd-add-point svg { width: 15px; height: 15px; }
.pcd-points { display: grid; gap: 10px; }
.pcd-point {
display: grid;
grid-template-columns: 32px minmax(0, 1fr) 38px;
align-items: center;
gap: 9px;
}
.pcd-point-idx {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--klein);
background: rgba(0, 47, 167, 0.07);
font-size: 11px;
font-weight: 600;
}
.pcd-point input {
width: 100%;
height: 46px;
padding: 0 13px;
border: 1px solid var(--pcd-line);
border-radius: 11px;
outline: 0;
background: rgba(255, 255, 255, 0.94);
font: inherit;
font-size: 13px;
}
.pcd-point-x {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
border-radius: 10px;
color: var(--pcd-muted);
background: rgba(34, 42, 54, 0.05);
cursor: pointer;
}
.pcd-point-x:disabled { opacity: 0.28; cursor: not-allowed; }
.pcd-point-x svg { width: 16px; height: 16px; }
/* 核心卖点 · bullet-list(drawer 变体) */
.pc-drawer .form-card .bullet-list { list-style: none; padding: 0; margin: 0; }
.pc-drawer .form-card .bullet-list .bl-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 14px;
.pcd-foot {
min-height: 96px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 28px 14px;
border-top: 1px solid rgba(34, 42, 54, 0.09);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 -12px 28px rgba(20, 27, 38, 0.05);
}
.pc-drawer .form-card .bullet-list .bl-item:focus-within { border-color: var(--heat-40); }
.pc-drawer .form-card .bullet-list .num {
width: 22px; height: 22px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 11px; color: var(--heat); font-weight: 700;
display: grid; place-items: center; flex-shrink: 0;
.pcd-foot > span {
display: flex;
align-items: center;
gap: 7px;
color: var(--pcd-muted);
font-size: 10px;
}
.pc-drawer .form-card .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
.pc-drawer .form-card .bullet-list .bl-input {
flex: 1; background: transparent; border: 0; outline: none;
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
.pcd-foot > span svg { width: 14px; height: 14px; flex: 0 0 auto; }
.pcd-actions { display: flex; align-items: center; gap: 10px; }
.pcd-ghost,
.pcd-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
font: inherit;
cursor: pointer;
}
.pc-drawer .form-card .bullet-list .bl-x {
width: 22px; height: 22px;
color: var(--black-alpha-48);
cursor: pointer; display: grid; place-items: center;
border-radius: var(--r-sm);
transition: color var(--t-base), background var(--t-base);
.pcd-ghost {
min-width: 92px;
height: 46px;
padding: 0 18px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
}
.pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
.pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }
/* YYX#17/R104:独立添加卖点按钮 挂在字段标题行右侧的小按钮(对齐+ 新增角色摆法),
不再是列表下方整行虚线框(测试反馈那种观感像又一个输入框)scoped 不污染全局 */
.pc-drawer .form-card .field-label-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.pc-drawer .form-card .field-label-row .field-label { margin-bottom: 0; }
.pc-drawer .form-card .bl-add-btn {
display: inline-flex; align-items: center; gap: 5px;
height: 26px; padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border-loud); border-radius: var(--r-sm);
font-size: 12px; color: var(--accent-black); font-family: inherit; cursor: pointer;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
}
.pc-drawer .form-card .bl-add-btn:hover { background: var(--heat-12); border-color: var(--heat-40); color: var(--heat); }
.pc-drawer .form-card .bl-add-btn svg { width: 12px; height: 12px; }
@media (max-width: 900px) {
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
.pcd-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.pcd-primary {
min-width: 148px;
height: 50px;
padding: 0 20px;
border-radius: 11px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.pcd-primary:hover { background: var(--klein-hover); }
.pcd-primary:disabled { opacity: 0.38; box-shadow: none; cursor: not-allowed; }
.pcd-primary svg { width: 18px; height: 18px; }
/* ─── 主图缩略网格 · 多图上传(对齐 V1 #pc-drawer .pf-thumb/.pf-x) ─── */
.pc-drawer .form-card .pf-thumb {
.pcd-toast {
position: absolute;
right: 24px;
bottom: 108px;
z-index: 8;
display: flex;
align-items: center;
gap: 10px;
max-width: 320px;
padding: 10px 14px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 11px;
background: #fff;
box-shadow: 0 12px 28px rgba(20, 27, 38, 0.12);
}
.pcd-toast-ic {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 8px;
color: #fff;
background: var(--klein);
}
.pcd-toast-ic svg { width: 13px; height: 13px; }
.pcd-toast div { font-size: 13px; font-weight: 600; }
.pcd-toast small { display: block; margin-top: 2px; color: var(--pcd-muted); font-size: 11px; font-weight: 400; }
.pcd-gallery {
position: fixed;
inset: 0;
z-index: 95;
display: grid;
grid-template-rows: 78px minmax(0, 1fr);
color: var(--accent-black);
background:
linear-gradient(rgba(24, 31, 42, 0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(24, 31, 42, 0.028) 1px, transparent 1px),
#f8f9fb;
background-size: 44px 44px;
}
.pcd-gallery-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 clamp(24px, 4vw, 64px);
border-bottom: 1px solid rgba(34, 42, 54, 0.09);
background: rgba(255, 255, 255, 0.92);
}
.pcd-gallery-head h2 { margin: 0 0 4px; font-size: 20px; font-weight: 600; }
.pcd-gallery-head p { margin: 0; color: var(--pcd-muted); font-size: 10px; }
.pcd-gallery-back {
min-width: 92px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 11px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.pcd-gallery-back svg { width: 16px; height: 16px; }
.pcd-gallery-body {
min-height: 0;
display: grid;
place-items: center;
overflow: auto;
padding: 28px;
}
.pcd-gallery-grid {
width: min(920px, 88vw);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.pcd-gallery-slot {
position: relative;
aspect-ratio: 1;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
position: relative; overflow: hidden; cursor: pointer;
overflow: hidden;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 16px;
background: rgba(255, 255, 255, 0.90);
box-shadow: 0 10px 28px rgba(20, 27, 38, 0.07);
}
.pc-drawer .form-card .pf-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pc-drawer .form-card .pf-thumb .pf-x {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px;
background: rgba(0, 0, 0, .7); color: var(--accent-white);
border: 0; border-radius: 50%; cursor: pointer;
display: grid; place-items: center;
opacity: 0; transition: opacity var(--t-base);
.pcd-gallery-slot img { width: 100%; height: 100%; display: block; object-fit: contain; background: #fff; }
.pcd-gallery-slot.empty {
display: grid;
place-items: center;
border-style: dashed;
color: #a7adb6;
background: rgba(255, 255, 255, 0.52);
box-shadow: none;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.pc-drawer .form-card .pf-thumb:hover .pf-x,
.pc-drawer .form-card .pf-thumb:focus-within .pf-x { opacity: 1; }
.pc-drawer .form-card .pf-thumb .pf-x svg { width: 11px; height: 11px; }
/* upload-first 状态:上传中遮罩转圈 / 失败红底提示 / 主图角标(第一张=cover_asset) */
.pc-drawer .form-card .pf-thumb .pf-state {
position: absolute; inset: 0; display: grid; place-items: center;
background: color-mix(in srgb, var(--surface) 58%, transparent);
font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56);
.pcd-gallery-slot > span {
position: absolute;
top: 9px;
left: 9px;
min-width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 8px;
color: #fff;
background: rgba(18, 22, 29, 0.68);
font-size: 9px;
font-weight: 600;
}
.pc-drawer .form-card .pf-thumb .pf-state-err { background: var(--crimson-bg); color: var(--accent-crimson); font-weight: 600; }
.pc-drawer .form-card .pf-thumb.pf-error { border-color: var(--crimson-bd); }
/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */
.pc-drawer .form-card .pf-thumb .pf-fail-mask {
position: absolute; inset: 0; z-index: 2;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
background: rgba(0, 0, 0, .62); color: var(--accent-white); text-align: center; padding: 4px;
.pcd-gallery-x {
top: 9px;
right: 9px;
width: 30px;
height: 30px;
border-radius: 9px;
}
.pc-drawer .form-card .pf-thumb .pf-fail-ic svg { width: 20px; height: 20px; }
.pc-drawer .form-card .pf-thumb .pf-fail-t { font-size: 11px; font-weight: 600; }
.pc-drawer .form-card .pf-thumb .pf-retry {
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 8px;
border: 0; border-radius: 999px;
background: var(--accent-white); color: var(--accent-black);
font-size: 11px; cursor: pointer;
}
.pc-drawer .form-card .pf-thumb .pf-retry:hover { background: var(--heat); color: var(--accent-white); }
.pc-drawer .form-card .pf-thumb .pf-retry svg { width: 11px; height: 11px; }
.pc-drawer .form-card .pf-thumb .pf-cover {
position: absolute; left: 4px; bottom: 4px; z-index: 1;
background: var(--heat); color: var(--accent-white);
font-family: var(--font-mono); font-size: 10px; line-height: 1;
padding: 3px 6px; border-radius: var(--r-sm);
.pcd-gallery-x svg { width: 15px; height: 15px; }
@media (max-width: 720px) {
.pcd-row,
.pcd-upload-grid { grid-template-columns: 1fr; }
.pcd-foot { flex-wrap: wrap; padding: 14px 18px; min-height: 0; }
}
/* ─── 校验态 · 必填红字 / 输入框红框(原内联 style 抽成类) ─── */
.pc-drawer .form-card .input.is-error,
.pc-drawer .form-card .input.is-error:focus {
border-color: var(--accent-crimson);
box-shadow: inset 0 0 0 1px var(--crimson-bd);
}
.pc-drawer .pc-err-note {
color: var(--accent-crimson);
font-size: 12px; margin-top: 4px;
}
/* 末尾字段去底距(替代 inline margin-bottom:0) */
.pc-drawer .form-card .field.pc-field-last { margin-bottom: 0; }
/* ─── 使用指南内联说明(原内联 style 抽成类) ─── */
.pc-drawer .pc-guide-note {
padding: 10px 14px;
margin: 0 16px 8px;
background: var(--black-alpha-4);
border-radius: var(--r-md);
font-size: 13px; line-height: 1.7;
color: var(--black-alpha-72);
}
.pc-drawer .pc-guide-note strong { color: var(--accent-black); font-weight: 600; }
/* ─── 抽屉内就地 toast(组件无全局 Shell.toast;镜像 .toast 视觉,自给一条) ─── */
.pc-drawer .pc-toast {
position: absolute; right: 24px; bottom: 80px;
display: flex; align-items: center; gap: 10px;
max-width: 340px;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating, 0 8px 24px rgba(0, 0, 0, .1));
animation: pc-toast-in .18s ease-out;
z-index: 2;
}
@keyframes pc-toast-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.pc-drawer .pc-toast .ic-t {
width: 24px; height: 24px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--heat-12); color: var(--heat);
border: 1px solid var(--heat-20);
border-radius: var(--r-sm);
}
.pc-drawer .pc-toast .ic-t svg { width: 13px; height: 13px; }
.pc-drawer .pc-toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; }
.pc-drawer .pc-toast .txt .mono {
display: block; margin-top: 2px;
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
+514 -96
View File
@@ -1,110 +1,528 @@
/* 商品库 · public/exact/products.html 内联 <style> 忠实移植,整段 scope .products-page
page-head/toolbar/chip/search-inline 走全局 design-restraint;此处覆盖 products 专属版式 */
/* 商品库列表 · 对照影擎 products整段 scope .products-page不重写共享 .btn/.chip/.input
商品详情 .pd-* / .ov-images-sub 仍在本文件后半段本轮不改 */
.products-page {
.page-head { position: sticky; top: 0; z-index: 5; background: var(--background-base); padding-top: 4px; margin-top: -4px; }
.products-main { display: flex; flex-direction: column; }
.products-main .result-meta { margin-bottom: 12px; display: flex; align-items: center; gap: 10px; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.chip-wrap { position: relative; }
--klein: #002fa7;
--klein-hover: #002680;
--pl-black: #101012;
--pl-muted: #6f747c;
--pl-line: rgba(34, 42, 54, 0.10);
--pl-wash: rgba(34, 42, 54, 0.045);
--pl-shadow: 0 8px 18px rgba(20, 27, 38, 0.075);
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
/* 商品分类多选 chip · 菜单多选项右侧计数 + chip 上橙色已选计数徽标(忠实移植 products.html)
:共享 .mi 已是 flex/align-center(design-restraint),此处只补 cat 专属计数,不重写共享类 */
.chip-wrap[data-key="cat"] .chip-menu { min-width: 220px; }
.chip-wrap[data-key="cat"] .mi .cat-count { margin-left: auto; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .02em; }
.chip-wrap[data-key="cat"] .mi.selected .cat-count { color: var(--heat); }
.chip .chip-count { display: inline-flex; align-items: center; justify-content: center; height: 18px; min-width: 18px; padding: 0 5px; background: var(--heat-12); color: var(--heat); border: 1px solid var(--heat-20); border-radius: var(--r-pill); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: .02em; margin-left: 2px; }
.pl-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
}
.pl-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.pl-head p {
margin: 0;
color: var(--pl-muted);
font-size: 15px;
}
.pl-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.pl-ghost,
.pl-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
font: inherit;
cursor: pointer;
transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
}
.pl-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
}
.pl-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.pl-ghost.is-on {
color: #fff;
border-color: var(--pl-black);
background: var(--pl-black);
}
.pl-primary {
min-width: 158px;
height: 50px;
padding: 0 22px;
border-radius: 11px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.pl-primary:hover {
transform: translateY(-2px);
background: var(--klein-hover);
}
.pl-ghost svg,
.pl-primary svg { width: 19px; height: 19px; flex: 0 0 auto; }
.product-grid-wrap { margin: 0 -8px; padding: 2px 8px 24px; }
/* 自适应铺满 + 宽屏封顶 5 (YYX#20 / R106):
窄屏 auto-fill(每张 240px)随宽度自动增列(1440 视口 4 ,维持现状);
一旦 5 列每列已 240px(网格 1264px, 1570 视口起)则锁定 5
每页 10 个商品正好铺满两整行,卡片随宽度等比放大铺满可用宽,
不再出现 1920 视口下 6/7 × 每页 10 = 末行空出两三个商品位
(缩略图走 aspect-ratio 1.4/1 max-height,放大不会被反向钳宽) */
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(240px, calc((100% - 4 * 16px) / 5)), 1fr)); gap: 16px; }
.product-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s, border-color .15s; position: relative; overflow: hidden; display: flex; flex-direction: column; }
.product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.product-thumb { aspect-ratio: 1.4 / 1; }
/* 缩略区铺满卡片宽度:去掉 .placeholder 的内描边/内圆角(否则顶部/四角露白边),
卡片本身已 overflow:hidden 负责裁切外圆角 */
.product-card .product-thumb { border: 0; border-radius: 0; }
/* 真实主图:铺满缩略区(等比裁切),不再露白底/占位文字 */
.product-thumb.has-real-media { position: relative; overflow: hidden; background: var(--background-lighter); }
.product-thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* mock 占位图同样真正铺满:cover 已在 design-restraint 定义,这里确保无内边距/无白边 */
.product-card .product-thumb.has-mock-media { background-size: cover; background-position: center; padding: 0; }
.product-body { padding: 14px 14px 12px; flex: 1; }
.product-name { font-size: 14px; font-weight: 600; color: var(--accent-black); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-cat { display: inline-flex; align-items: center; margin-top: 8px; padding: 2px 8px; background: var(--background-lighter); color: var(--black-alpha-72); border-radius: var(--r-sm); font-size: 12px; }
.product-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.product-meta .product-cat { margin-top: 0; }
.product-meta .pill { height: 20px; font-size: 11px; }
.product-date { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 10px; letter-spacing: .02em; }
.pl-toolbar {
min-height: 66px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
background: var(--pl-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
.pl-search {
width: 250px;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid var(--pl-line);
border-radius: 10px;
background: #fff;
}
.pl-search svg {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--pl-muted);
}
.pl-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
color: var(--accent-black);
}
.pl-search input::placeholder { color: var(--pl-muted); }
.pl-toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.product-footer { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-faint); font-size: 12px; color: #6f6f6f; background: var(--background-base); }
.product-footer .stat { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 3px 8px; border-radius: var(--r-sm); font-family: var(--font-mono); letter-spacing: .02em; white-space: nowrap; border: 1px solid transparent; justify-self: center; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
/* 视频计数是按钮:点击进详情视频项目 tab,hover 提示可点 */
.product-footer button.stat-link { background: transparent; cursor: pointer; color: inherit; }
.product-footer button.stat-link:hover { background: var(--background-lighter); border-color: var(--border-faint); color: var(--accent-black); }
.product-footer .stat svg { width: 14px; height: 14px; color: currentColor; flex-shrink: 0; stroke-width: 1.25; transition: color var(--t-base); }
.product-footer .stat b { color: var(--accent-black); font-weight: 600; transition: color var(--t-base); }
.product-footer .sep { color: #b8b8b8; font-family: var(--font-mono); flex-shrink: 0; }
.pl-select { position: relative; width: 150px; flex: 0 0 150px; z-index: 40; }
.pl-select.open { z-index: 50; }
.pl-select[data-key="cat"] { width: 168px; flex-basis: 168px; }
.pl-select-trigger {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.12);
border-radius: 10px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.pl-select-trigger:hover,
.pl-select.open .pl-select-trigger { border-color: rgba(0, 47, 167, 0.36); }
.pl-select.open .pl-select-trigger { box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07); }
.pl-select-trigger span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pl-select-trigger svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
color: var(--pl-muted);
transition: transform 180ms ease;
}
.pl-select.open .pl-select-trigger svg { transform: rotate(180deg); }
.pl-select-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 120;
display: grid;
gap: 3px;
padding: 6px;
border: 1px solid var(--pl-line);
border-radius: 11px;
background: #fff;
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-5px);
}
.pl-select.open .pl-select-menu {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.pl-select-option {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.pl-select-option:hover { background: #e8f2ff; }
.pl-select-option.selected {
color: var(--klein);
background: #f3f7ff;
font-weight: 600;
}
.pl-cat-count {
margin-left: auto;
font-size: 11px;
color: var(--pl-muted);
font-variant-numeric: tabular-nums;
}
.pl-select-option.selected .pl-cat-count { color: var(--klein); }
.view-tog { display: inline-flex; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 2px; flex-shrink: 0; }
.view-tog button { width: 30px; height: 28px; display: grid; place-items: center; border: 0; background: transparent; color: var(--ink-3); cursor: pointer; border-radius: 4px; transition: background var(--t-base), color var(--t-base); }
.view-tog button:hover { color: var(--accent-black); }
.view-tog button.active { background: var(--accent-black); color: var(--accent-white); }
.view-tog button svg { width: 13px; height: 13px; }
.pl-clear {
height: 40px;
padding: 0 12px;
border: 0;
border-radius: 10px;
color: var(--pl-muted);
background: transparent;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.pl-clear:hover { color: var(--accent-black); background: rgba(34, 42, 54, 0.06); }
/* 列表视图:网格→单列,每行横排(缩图 100×70 + 信息行 + 底栏统计)·忠实移植 products.html */
.product-grid.list-view { display: flex; flex-direction: column; gap: 8px; }
.product-grid.list-view .product-card { display: grid; grid-template-columns: 100px 1fr auto; gap: 16px; padding: 12px 16px; align-items: center; }
.product-grid.list-view .product-thumb { width: 100px; height: 70px; aspect-ratio: auto; margin: 0; border-radius: var(--r-sm); flex-shrink: 0; }
.product-grid.list-view .product-card .product-thumb { border: 0; }
.product-grid.list-view .product-body { padding: 0; display: flex; align-items: center; gap: 14px; }
.product-grid.list-view .product-name { font-size: 14px; }
.product-grid.list-view .product-cat { margin-top: 0; }
.product-grid.list-view .product-meta { margin-top: 0; }
.product-grid.list-view .product-date { font-family: var(--font-mono); font-size: 11.5px; color: var(--black-alpha-48); letter-spacing: .02em; margin-top: 0; }
.product-grid.list-view .product-footer { border-top: 0; padding: 0; background: transparent; column-gap: 6px; }
/* 列表态卡片左上 checkbox 贴合更紧 */
.product-grid.list-view .product-card .card-check { top: 50%; left: 6px; transform: translateY(-50%); }
.pl-view {
display: flex;
align-items: center;
gap: 8px;
}
.pl-view-btn {
width: 40px;
height: 40px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 10px;
color: #393d44;
background: rgba(34, 42, 54, 0.055);
cursor: pointer;
}
.pl-view-btn:hover,
.pl-view-btn.active { background: rgba(34, 42, 54, 0.11); }
.pl-view-btn svg { width: 16px; height: 16px; }
/* 编辑模式 checkbox(默认隐藏) */
.product-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--border-loud); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; }
.product-card .card-check svg { width: 11px; height: 11px; visibility: hidden; }
.pl-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: 22px;
margin: 32px 0 17px;
}
.pl-section h2 {
margin: 0;
font-size: 21px;
font-weight: 600;
color: var(--accent-black);
}
.pl-section p {
margin: 5px 0 0;
color: var(--pl-muted);
font-size: 13px;
}
.pl-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.pl-card {
position: relative;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--pl-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--pl-shadow);
cursor: pointer;
outline: none;
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.pl-card:hover {
transform: translateY(-3px);
background: rgba(34, 42, 54, 0.08);
}
.pl-card.selected {
border-color: var(--klein);
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--pl-shadow);
}
.pl-thumb {
position: relative;
aspect-ratio: 1 / 0.78;
overflow: hidden;
background: #e6e7ea;
border: 0;
border-radius: 0;
padding: 0;
}
.pl-thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.pl-thumb.has-mock-media {
background-size: cover;
background-position: center;
background-image: var(--mock-media-url);
}
.pl-thumb.placeholder {
border: 0;
border-radius: 0;
}
.pl-thumb .ph-frame { display: none; }
.pl-meta { padding: 15px 16px 17px; }
.pl-meta h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
color: var(--accent-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pl-meta p {
margin: 0;
color: var(--pl-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pl-meta-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 13px;
}
.pl-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border: 0;
border-radius: 7px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font: inherit;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.pl-tag.blue {
color: var(--klein);
background: rgba(0, 47, 167, 0.09);
}
.pl-tag.green {
color: #138d3f;
background: rgba(22, 184, 78, 0.10);
}
button.pl-tag {
cursor: pointer;
transition: background-color 160ms ease;
}
button.pl-tag:hover { filter: brightness(0.97); }
.pl-check {
position: absolute;
top: 12px;
left: 12px;
z-index: 4;
width: 25px;
height: 25px;
display: none;
place-items: center;
border: 2px solid rgba(0, 47, 167, 0.38);
border-radius: 8px;
color: transparent;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 3px 10px rgba(0, 47, 167, 0.12);
pointer-events: none;
}
.pl-check svg { width: 13px; height: 13px; }
.pl-grid.list { grid-template-columns: 1fr; }
.pl-grid.list .pl-card {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
align-items: stretch;
}
.pl-grid.list .pl-thumb {
width: 190px;
height: 130px;
aspect-ratio: auto;
}
.pl-grid.list .pl-meta {
display: flex;
min-width: 0;
padding: 18px 22px;
flex-direction: column;
justify-content: center;
}
.pl-empty {
min-height: 190px;
display: grid;
grid-column: 1 / -1;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--pl-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.pl-empty svg {
width: 28px;
height: 28px;
color: var(--klein);
opacity: 0.72;
}
.pl-empty strong {
color: var(--accent-black);
font-size: 14px;
font-weight: 600;
}
.pl-empty span { font-size: 12px; }
.list-pager { margin-top: 20px; }
}
/* 批量编辑模式(忠实移植自 products.html · body.edit-mode 全局态,控件仅商品库出现) */
body.edit-mode .product-card { cursor: pointer; }
body.edit-mode .product-card .card-check { display: grid; }
body.edit-mode .product-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .product-card.selected .card-check svg { visibility: visible; }
body.edit-mode .product-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
/* 编辑模式只隐藏单卡删除按钮(改走多选 + 批量删除);底部素材N/视频N统计必须保留,
且视频计数在编辑态不可点击(避免误触跳详情),仅作展示 */
body.edit-mode .product-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
body.edit-mode .product-footer button.stat-link { pointer-events: none; }
.bulk-bar {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md);
padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 100; font-size: 13px;
@media (max-width: 1100px) {
.products-page { margin: -28px -24px -48px; }
.products-page .pl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 860px) {
.products-page .pl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.products-page .pl-toolbar { flex-wrap: wrap; }
.products-page .pl-search { width: 100%; }
}
body.edit-mode .bulk-bar { display: inline-flex; }
.bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
.bulk-bar .ct b { color: var(--heat); font-weight: 600; padding: 0 3px; }
.bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }
.bulk-bar button { height: 30px; padding: 0 12px; background: transparent; border: 1px solid rgba(255,255,255,.24); border-radius: var(--r-sm); color: var(--accent-white); font-size: 13px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: background .15s, border-color .15s; }
.bulk-bar button:hover { background: rgba(255,255,255,.08); }
.bulk-bar button.danger { background: var(--accent-crimson, #c43d3d); border-color: var(--accent-crimson, #c43d3d); }
.bulk-bar button.danger:hover { filter: brightness(1.06); }
.bulk-bar button:disabled { opacity: .4; cursor: not-allowed; }
.bulk-bar button svg { width: 12px; height: 12px; }
.bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }
.bulk-bar .clear-sel:hover { color: var(--accent-white); }
.btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
/* 管理态勾选 · 仅商品库 */
.products-page.manage-mode .pl-card .pl-check { display: grid; }
.products-page.manage-mode .pl-card.selected .pl-check {
color: #fff;
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; }
.products-page.manage-mode .pl-card button.pl-tag { pointer-events: none; }
/* 影擎吸底选择条 · 管理模式即出现 */
.products-page .pl-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 13px 14px 13px 20px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
color: #fff;
background: rgba(16, 16, 18, 0.94);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
transform: translate(-50%, 140%);
opacity: 0;
pointer-events: none;
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease;
}
.products-page .pl-sel-bar.active {
transform: translate(-50%, 0);
opacity: 1;
pointer-events: auto;
}
.products-page .pl-sel-copy strong,
.products-page .pl-sel-copy span { display: block; }
.products-page .pl-sel-copy span {
margin-top: 3px;
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
}
.products-page .pl-sel-actions { display: flex; gap: 8px; }
.products-page .pl-sel-actions button {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.products-page .pl-sel-cancel {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.products-page .pl-sel-confirm {
color: #fff;
background: #002fa7;
}
.products-page .pl-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1100px) {
.products-page .pl-sel-bar { left: 50%; }
}
/* ── 商品详情·素材成组(A) ── */
.pd-group-card .thumb { position: relative; }
+608 -331
View File
@@ -1,339 +1,616 @@
/* 新建视频项目 · 向导页 · public/exact/projects-new.html 内联 <style> 忠实移植
整段 scope .project-wizard-page,避免污染同文件的 ProjectsPage(.projects-page)
token + 共享组件(.btn/.field/.input/.textarea/.select/.placeholder/.pp-chip/.pp-menu .mi)走全局 design-restraint;
此处只覆盖向导专属:.wizard 网格 / .steps 步骤轨 / .pp- 商品选择器 / .opt-card / .source-card / .wiz-start-bar */
/* 新建项目 · 对照影擎 project-create。不重写共享 .btn/.chip/.input。 */
.project-wizard-page {
/* ── 两栏栅格:左 sticky 步骤轨 + 右主体 ── */
.wizard { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 36px; align-items: start; max-width: 1400px; }
.steps {
position: sticky;
top: calc(64px + 24px);
align-self: start;
max-height: calc(100vh - 64px - 48px);
overflow-y: auto;
z-index: 2;
}
/* ── 单页式主体:Step 1 + Step 2 同时显示,底部「开始」CTA ── */
.wiz-body { display: flex; flex-direction: column; gap: 14px; }
.step-pane-wrap { display: block; }
/* ── 左侧步骤轨 .step ── */
.step { display: flex; gap: 12px; padding: 12px 0; position: relative; }
.step:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 36px; width: 1px; height: calc(100% - 24px); background: var(--border-faint); }
.step .num { width: 24px; height: 24px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); display: grid; place-items: center; font-size: 12px; font-weight: 600; color: var(--black-alpha-48); flex-shrink: 0; z-index: 1; font-family: var(--font-mono); }
.step .num svg { width: 12px; height: 12px; }
.step.done .num { background: var(--accent-black); border-color: var(--accent-black); color: var(--accent-white); }
.step.active .num { background: var(--heat); border-color: var(--heat); color: var(--accent-white); }
.step .label { font-size: 14px; font-weight: 500; color: var(--black-alpha-56); padding-top: 2px; }
.step .desc { font-size: 12px; color: var(--black-alpha-48); padding-top: 3px; line-height: 1.4; font-family: var(--font-mono); letter-spacing: .02em; }
.step.active .label { color: var(--accent-black); font-weight: 600; }
.step.done .label { color: var(--black-alpha-56); }
.step.done:not(:last-child)::after { background: var(--accent-black); }
.step.clickable { cursor: pointer; }
.step.clickable:hover .label { color: var(--heat); }
.step.clickable:hover .num { border-color: var(--heat); }
/* ── 主体卡片 .wiz-pane ── */
.wiz-pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 22px 24px; margin-bottom: 14px; }
.wiz-pane:last-child { margin-bottom: 0; }
.wiz-step-h { margin-bottom: 18px; }
.wiz-step-h h2 { font-size: 20px; font-weight: 600; letter-spacing: -.015em; }
.wiz-step-h p { font-size: 13px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.6; }
/* ── Step 1 · 商品选择器 toolbar(沿用商品库视觉,.pp- 命名空间)── */
.pp-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.pp-toolbar .search-inline {
flex: 1; min-width: 220px; max-width: 340px;
display: inline-flex; align-items: center; gap: 8px;
height: 34px; padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
transition: border-color var(--t-base);
}
.pp-toolbar .search-inline:focus-within { border-color: var(--heat-40); }
.pp-toolbar .search-inline svg { width: 14px; height: 14px; color: var(--ink-3); flex-shrink: 0; }
.pp-toolbar .search-inline input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; font-size: 13px; color: var(--accent-black); font-family: inherit; }
.pp-toolbar .search-inline input::placeholder { color: var(--black-alpha-48); }
.pp-toolbar .pp-chip-wrap { position: relative; }
.pp-toolbar .pp-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 34px; padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
font-size: 13px; font-family: inherit;
color: var(--black-alpha-72);
cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.pp-toolbar .pp-chip:hover { border-color: var(--black-alpha-32); color: var(--accent-black); }
.pp-toolbar .pp-chip.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); }
.pp-toolbar .pp-chip svg { width: 11px; height: 11px; color: var(--ink-3); }
.pp-toolbar .pp-menu {
position: absolute; top: calc(100% + 4px); left: 0;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
padding: 4px;
display: none;
z-index: 20;
}
.pp-toolbar .pp-chip-wrap.open .pp-menu { display: block; }
.pp-toolbar .pp-menu .mi {
display: flex; align-items: center; gap: 6px;
padding: 7px 10px;
border-radius: var(--r-sm);
font-size: 13px;
color: var(--accent-black);
cursor: pointer;
}
.pp-toolbar .pp-menu .mi:hover { background: var(--background-lighter); }
.pp-toolbar .pp-menu .mi.selected { color: var(--heat); font-weight: 600; }
.pp-toolbar .pp-menu .mi-check { width: 12px; height: 12px; visibility: hidden; flex-shrink: 0; }
.pp-toolbar .pp-menu .mi.selected .mi-check { visibility: visible; }
.pp-toolbar .pp-clear {
display: inline-flex; align-items: center; gap: 4px;
height: 30px; padding: 0 10px;
background: transparent; border: 0; border-radius: var(--r-sm);
color: var(--black-alpha-56); font-size: 13px; font-family: inherit;
cursor: pointer;
}
.pp-toolbar .pp-clear:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
.pp-toolbar .pp-clear svg { width: 11px; height: 11px; }
.pp-result-meta {
font-family: var(--font-mono); font-size: 12px;
color: var(--black-alpha-48); letter-spacing: .02em;
margin: 4px 0 12px;
}
/* ── Step 1 · 商品网格(固定 4 列,沿用 .product-card 视觉)── */
.pp-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.pp-grid .product-card {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
cursor: pointer; position: relative; overflow: hidden;
display: flex; flex-direction: column;
transition: background .15s, border-color .15s, transform .15s;
}
.pp-grid .product-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.pp-grid .product-card.selected { border-color: var(--heat); background: var(--heat-12); }
.pp-grid .product-card.selected::after {
content: ''; position: absolute; top: 0; right: 0;
width: 0; height: 0;
border-top: 28px solid var(--heat);
border-left: 28px solid transparent;
z-index: 2;
}
.pp-grid .product-card.selected::before {
content: ''; position: absolute; top: 4px; right: 4px;
width: 10px; height: 10px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='3 8 7 12 13 4'/%3E%3C/svg%3E") no-repeat center / contain;
z-index: 3;
}
/* 锁死封面区高度:固定 1.4:1 比例 + 裁切溢出,真图用 object-fit:cover 填充,
避免竖图/截图(如交易明细截图)按原始比例把卡片无限拉高 */
.pp-grid .product-thumb { aspect-ratio: 1.4 / 1; overflow: hidden; flex-shrink: 0; }
.pp-grid .product-thumb.has-real-media { position: relative; background: var(--background-lighter); }
.pp-grid .product-thumb.has-real-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pp-grid .product-body { padding: 14px 14px 12px; flex: 1; }
.pp-grid .product-name {
font-size: 14px; font-weight: 600; color: var(--accent-black);
line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pp-grid .product-cat {
display: inline-flex; align-items: center;
margin-top: 8px; padding: 2px 8px;
background: var(--background-lighter); color: var(--black-alpha-72);
border-radius: var(--r-sm); font-size: 12px;
}
.pp-grid .product-date {
font-family: var(--font-mono);
font-size: 12px; color: var(--black-alpha-48);
margin-top: 10px; letter-spacing: .02em;
}
.pp-grid .product-card.selected .product-cat { background: var(--surface); color: var(--heat); }
/* ── Step 1 · 创建新商品 空卡 ── */
.pp-grid .pp-create-card {
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-md);
background: transparent;
cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; min-height: 220px;
color: var(--black-alpha-48);
font-family: inherit;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
}
.pp-grid .pp-create-card:hover { border-color: var(--heat); color: var(--heat); background: var(--heat-12); }
.pp-grid .pp-create-card .pc-plus {
width: 44px; height: 44px;
border-radius: 50%;
background: var(--heat); color: var(--accent-white);
display: grid; place-items: center;
transition: filter var(--t-base);
}
.pp-grid .pp-create-card:hover .pc-plus { filter: brightness(1.06); }
.pp-grid .pp-create-card .pc-plus svg { width: 18px; height: 18px; }
.pp-grid .pp-create-card .pc-t { font-size: 13px; font-weight: 600; }
.pp-grid .pp-create-card .pc-d { font-family: var(--font-mono); font-size: 12px; letter-spacing: .02em; }
/* ── Step 1 · 列表视图 ── */
.pp-grid.list-view { display: flex; flex-direction: column; gap: 6px; }
.pp-grid.list-view .product-card { flex-direction: row; align-items: center; }
.pp-grid.list-view .product-thumb { width: 96px; aspect-ratio: 1.4 / 1; flex-shrink: 0; }
.pp-grid.list-view .product-body { flex: 1; padding: 10px 14px; }
.pp-grid.list-view .pp-create-card { flex-direction: row; min-height: 56px; gap: 12px; }
.pp-grid.list-view .pp-create-card .pc-plus { width: 32px; height: 32px; }
/* ── Step 1 · 空筛选结果 ── */
.pp-empty {
grid-column: 1 / -1;
padding: 48px 24px; text-align: center;
border: 1px dashed var(--border-faint);
border-radius: var(--r-md);
background: var(--background-lighter);
color: var(--black-alpha-48);
font-size: 13px; font-family: var(--font-mono); letter-spacing: .02em;
line-height: 1.7;
}
.pp-empty .reset { display: inline-block; margin-top: 8px; color: var(--heat); cursor: pointer; }
/* ── Step 1 · 分页 ── */
.pp-pager {
display: flex; align-items: center; gap: 16px;
margin-top: 18px; padding-top: 14px;
border-top: 1px solid var(--border-faint);
font-size: 13px; color: var(--black-alpha-56);
}
.pp-pager .total { font-family: var(--font-mono); letter-spacing: .02em; }
.pp-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
.pp-pager .pages button {
min-width: 28px; height: 28px; padding: 0 8px;
border: 1px solid var(--border-faint); background: var(--surface);
border-radius: var(--r-sm);
cursor: pointer; font-size: 13px; color: var(--black-alpha-72); font-family: inherit;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.pp-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
.pp-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
.pp-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
.pp-pager .pages .ellipsis {
min-width: 22px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--black-alpha-48); font-family: var(--font-mono);
}
.pp-pager .page-size {
display: inline-flex; align-items: center; gap: 4px;
height: 28px; padding: 0 10px;
background: var(--surface); border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: inherit; font-size: 13px; color: var(--black-alpha-72);
}
/* ── Step 1 · 底部提示 ── */
.pp-bottom-tip {
margin-top: 14px;
padding: 10px 14px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
font-size: 13px; color: var(--black-alpha-56);
display: flex; align-items: center; gap: 8px;
}
.pp-bottom-tip svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--ink-3); }
.pp-bottom-tip a { color: var(--heat); cursor: pointer; text-decoration: none; }
.pp-bottom-tip a:hover { text-decoration: underline; }
/* ── Step 2 · 配置字段(共享 .field/.input/.select 走全局)── */
.config-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; margin-bottom: 16px; }
.config-row .field { margin-bottom: 0; }
.duration-select { cursor: pointer; }
/* ── Step 2 · 选项卡 .opt-card ── */
.opt-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.opt-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
.opt-row.cols-6 { grid-template-columns: repeat(3, 1fr); }
.opt-card { border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; background: var(--surface); cursor: pointer; position: relative; display: flex; flex-direction: column; min-width: 0; transition: background var(--t-base), border-color var(--t-base); }
.opt-card:hover { background: var(--background-lighter); }
.opt-card.selected { border-color: var(--heat); background: var(--heat-12); }
.opt-card.selected::after { content: ''; position: absolute; top: 8px; right: 10px; width: 16px; height: 16px; background-color: var(--heat); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 10px 10px; border-radius: var(--r-sm); }
.opt-card h4 { font-size: 13px; font-weight: 600; }
.opt-card .sub { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; letter-spacing: .02em; }
.opt-card .note { font-size: 12px; color: var(--black-alpha-56); margin-top: 6px; line-height: 1.5; }
.opt-card .metric { margin-top: auto; padding-top: 10px; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.opt-card .metric .val { color: var(--accent-black); font-weight: 500; }
.opt-card.selected .metric .val { color: var(--heat); }
.opt-card .badge { font-family: var(--font-mono); font-size: 12px; padding: 1px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-48); display: inline-block; margin-top: 8px; letter-spacing: .04em; align-self: flex-start; }
.opt-card.selected .badge { color: var(--heat); border-color: var(--heat-20); }
@media (min-width: 1280px) { .opt-row.cols-6 { grid-template-columns: repeat(6, 1fr); } }
/* ── Step 2 · 卖点胶囊 .theme-pill ── */
.theme-pill-row { display: flex; gap: 8px; flex-wrap: wrap; }
.theme-pill { display: inline-flex; gap: 6px; align-items: center; height: 36px; padding: 0 16px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; color: var(--accent-black); transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.theme-pill:hover { background: var(--background-lighter); }
.theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat); font-weight: 600; }
.theme-pill svg { width: 13px; height: 13px; }
/* ── Step 2 · 人设推荐气泡 .reco-bubble ── */
.reco-bubble { position: relative; margin-top: 10px; padding: 10px 14px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-md); display: flex; align-items: center; gap: 12px; font-size: 13px; color: var(--accent-black); }
.reco-bubble::before { content: ''; position: absolute; top: -5px; left: 28px; width: 9px; height: 9px; background: var(--heat-12); border-left: 1px solid var(--heat-20); border-top: 1px solid var(--heat-20); transform: rotate(45deg); }
.reco-bubble .ic { color: var(--heat); flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
.reco-bubble .ic svg, .reco-bubble .dismiss svg { display: block; width: 16px; height: 16px; }
.reco-bubble .txt { flex: 1; line-height: 1.5; }
.reco-bubble .txt strong { color: var(--heat); font-weight: 600; }
.reco-bubble .txt .meta { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
.reco-bubble .btn-apply { height: 28px; padding: 0 12px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-md); font-size: 12px; font-weight: 600; cursor: pointer; flex-shrink: 0; box-shadow: var(--shadow-cta); transition: box-shadow var(--t-base); font-family: inherit; }
.reco-bubble .btn-apply:hover { box-shadow: var(--shadow-cta-hover); }
.reco-bubble .dismiss { background: transparent; color: var(--black-alpha-48); border: 0; width: 24px; height: 24px; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.reco-bubble .dismiss:hover { color: var(--accent-black); }
/* ── 底部「开始」CTA(sticky 固定:随内容滚动时常驻视口底部,不被卷走)── */
.wiz-start-bar {
display: flex; justify-content: flex-end;
position: sticky; bottom: 0; z-index: 3;
margin-top: 6px; padding: 16px 0;
background: linear-gradient(to top, var(--background-base) 60%, transparent);
}
.wiz-start-bar .btn-start { height: 44px; padding: 0 36px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: 999px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--shadow-cta); display: inline-flex; align-items: center; gap: 8px; font-family: inherit; transition: box-shadow var(--t-base), opacity var(--t-base); }
.wiz-start-bar .btn-start:hover:not(.disabled) { box-shadow: var(--shadow-cta-hover); }
.wiz-start-bar .btn-start.disabled { opacity: .4; cursor: not-allowed; }
.wiz-start-bar .btn-start svg { width: 14px; height: 14px; }
/* ── 5.2/5.3 套路模板 · 选中后的预期说明块 ── */
.tpl-expect { margin-top: 10px; padding: 12px 14px; border-radius: var(--r-md); background: var(--heat-8); box-shadow: inset 0 0 0 1px var(--heat-12); }
.tpl-expect .te-head { font-size: 13px; line-height: 1.5; color: var(--accent-black); }
.tpl-expect .mono { font-family: var(--font-mono); font-size: 10px; color: var(--heat); letter-spacing: .04em; margin-right: 8px; }
.tpl-expect .te-outline { margin-top: 8px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.75; color: var(--black-alpha-56); white-space: pre-wrap; }
.tpl-expect .te-note { margin-top: 10px; font-size: 12px; line-height: 1.65; color: var(--black-alpha-56); }
/* ── 响应式:窄屏单列 ── */
@media (max-width: 1100px) {
.pp-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.config-row { grid-template-columns: 1fr; }
}
@media (max-width: 800px) {
.pp-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
--klein: #002fa7;
--klein-hover: #002680;
--nw-black: #101012;
--nw-muted: #6f747c;
--nw-line: rgba(34, 42, 54, 0.10);
--project-workspace-height: max(640px, calc(100vh - 116px - 76px));
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 28px clamp(40px, 3.2vw, 68px) 32px;
}
@media (max-width: 1100px) {
.project-wizard-page { margin: -28px -24px -48px; }
}
/* ── 新建商品抽屉(Drawer portal 到 body,样式不能 scope 进 .project-wizard-page)── */
.project-wizard-page .nw-head {
min-height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 16px;
}
.project-wizard-page .nw-title {
display: flex;
align-items: center;
gap: 14px;
}
.project-wizard-page .nw-title h1 {
margin: 0 0 5px;
font-size: 28px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.project-wizard-page .nw-title p {
margin: 0;
color: var(--nw-muted);
font-size: 13px;
}
.project-wizard-page .nw-back {
width: 38px;
height: 38px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid rgba(34, 42, 54, 0.12);
border-radius: 12px;
color: var(--accent-black);
background: rgba(255, 255, 255, 0.82);
cursor: pointer;
}
.project-wizard-page .nw-back:hover { transform: translateX(-2px); background: rgba(34, 42, 54, 0.08); }
.project-wizard-page .nw-back svg { width: 19px; height: 19px; }
.project-wizard-page .nw-status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 999px;
color: var(--nw-muted);
background: rgba(255, 255, 255, 0.76);
font-size: 12px;
}
.project-wizard-page .nw-status strong { color: var(--klein); font-size: 13px; }
.project-wizard-page .nw-shell {
display: grid;
grid-template-columns: 286px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
height: var(--project-workspace-height);
min-height: 0;
}
.project-wizard-page .nw-rail,
.project-wizard-page .nw-surface {
border: 1px solid var(--nw-line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 8px 18px rgba(20, 27, 38, 0.075);
}
.project-wizard-page .nw-rail {
display: flex;
flex-direction: column;
padding: 22px;
background: rgba(249, 250, 252, 0.94);
}
.project-wizard-page .nw-rail-head {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: center;
gap: 11px;
}
.project-wizard-page .nw-rail-icon {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 12px;
color: var(--klein);
background: rgba(0, 47, 167, 0.07);
}
.project-wizard-page .nw-rail-icon svg { width: 18px; height: 18px; }
.project-wizard-page .nw-rail-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
.project-wizard-page .nw-rail-head p { margin: 4px 0 0; color: var(--nw-muted); font-size: 10px; }
.project-wizard-page .nw-rail-summary {
margin-top: 20px;
padding: 12px;
border: 1px solid rgba(34, 42, 54, 0.08);
border-radius: 12px;
background: #fff;
}
.project-wizard-page .nw-rail-summary > span { color: var(--nw-muted); font-size: 10px; }
.project-wizard-page .nw-rail-track {
height: 5px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(34, 42, 54, 0.075);
}
.project-wizard-page .nw-rail-track span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--klein);
}
.project-wizard-page .nw-rail-steps {
display: grid;
gap: 9px;
margin-top: 16px;
}
.project-wizard-page .nw-step {
width: 100%;
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 16px;
align-items: center;
gap: 10px;
min-height: 76px;
padding: 10px;
border: 1px solid rgba(34, 42, 54, 0.085);
border-radius: 12px;
background: #fff;
}
.project-wizard-page .nw-step[hidden] {
display: none;
}
.project-wizard-page .nw-step.current {
border-color: rgba(0, 47, 167, 0.28);
background: rgba(0, 47, 167, 0.055);
box-shadow: inset 3px 0 0 var(--klein);
}
.project-wizard-page .nw-step-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
color: #6f7783;
background: #eef0f4;
font-size: 9px;
font-weight: 600;
}
.project-wizard-page .nw-step.current .nw-step-mark {
color: #fff;
background: var(--klein);
box-shadow: 0 7px 16px rgba(0, 47, 167, 0.18);
}
.project-wizard-page .nw-step-mark svg { width: 15px; height: 15px; }
.project-wizard-page .nw-step-copy { min-width: 0; display: grid; gap: 4px; }
.project-wizard-page .nw-step-copy strong { font-size: 13px; }
.project-wizard-page .nw-step-copy > span {
overflow: hidden;
color: var(--nw-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-wizard-page .nw-step-chev { width: 15px; height: 15px; color: var(--nw-muted); }
.project-wizard-page .nw-picked { margin-top: auto; padding-top: 18px; }
.project-wizard-page .nw-picked > span { color: var(--nw-muted); font-size: 9px; }
.project-wizard-page .nw-picked-card {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
align-items: center;
gap: 10px;
margin-top: 8px;
padding: 10px;
border: 1px solid rgba(0, 47, 167, 0.10);
border-radius: 12px;
background: rgba(0, 47, 167, 0.04);
}
.project-wizard-page .nw-picked-card img,
.project-wizard-page .nw-picked-ph {
width: 46px;
height: 46px;
border-radius: 10px;
object-fit: cover;
background: #eceef2;
}
.project-wizard-page .nw-picked-card strong,
.project-wizard-page .nw-picked-card small { display: block; }
.project-wizard-page .nw-picked-card strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-wizard-page .nw-picked-card small {
margin-top: 4px;
color: var(--nw-muted);
font-size: 8px;
line-height: 1.35;
}
.project-wizard-page .nw-surface {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 24px;
overflow: auto;
}
.project-wizard-page .nw-select-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.project-wizard-page .nw-select-head h2 { margin: 0 0 7px; font-size: 22px; font-weight: 600; }
.project-wizard-page .nw-select-head p {
max-width: 720px;
margin: 0;
color: var(--nw-muted);
font-size: 13px;
line-height: 1.65;
}
.project-wizard-page .nw-count {
padding: 7px 10px;
border-radius: 999px;
color: var(--klein);
background: rgba(0, 47, 167, 0.07);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.project-wizard-page .nw-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 20px;
}
.project-wizard-page .nw-search {
width: min(440px, 100%);
height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid var(--nw-line);
border-radius: 10px;
background: #fff;
}
.project-wizard-page .nw-search svg { width: 16px; height: 16px; color: var(--nw-muted); }
.project-wizard-page .nw-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
}
.project-wizard-page .nw-search input::placeholder { color: var(--nw-muted); }
.project-wizard-page .nw-select { position: relative; width: 176px; flex: 0 0 176px; z-index: 40; }
.project-wizard-page .nw-select.open { z-index: 50; }
.project-wizard-page .nw-select-trigger {
width: 100%;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 13px;
border: 1px solid rgba(0, 47, 167, 0.15);
border-radius: 10px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.project-wizard-page .nw-select.open .nw-select-trigger { box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07); }
.project-wizard-page .nw-select-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.project-wizard-page .nw-select-trigger svg { width: 15px; height: 15px; color: var(--nw-muted); }
.project-wizard-page .nw-select.open .nw-select-trigger svg { transform: rotate(180deg); }
.project-wizard-page .nw-select-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
display: grid;
gap: 3px;
padding: 6px;
border: 1px solid var(--nw-line);
border-radius: 11px;
background: #fff;
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.project-wizard-page .nw-select.open .nw-select-menu {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.project-wizard-page .nw-select-option {
min-height: 38px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.project-wizard-page .nw-select-option:hover { background: #e8f2ff; }
.project-wizard-page .nw-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
.project-wizard-page .nw-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}
.project-wizard-page .nw-card {
position: relative;
min-width: 0;
min-height: 272px;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 14px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.035);
text-align: left;
font: inherit;
cursor: pointer;
transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
.project-wizard-page .nw-card:hover {
transform: translateY(-3px);
border-color: rgba(0, 47, 167, 0.30);
box-shadow: 0 12px 26px rgba(20, 27, 38, 0.10);
}
.project-wizard-page .nw-card.active {
border-color: var(--klein);
background: rgba(0, 47, 167, 0.055);
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.08), 0 14px 30px rgba(20, 27, 38, 0.10);
}
.project-wizard-page .nw-check {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
display: grid;
place-items: center;
z-index: 2;
border: 1px solid rgba(34, 42, 54, 0.14);
border-radius: 50%;
color: transparent;
background: rgba(255, 255, 255, 0.90);
}
.project-wizard-page .nw-check svg { width: 14px; height: 14px; }
.project-wizard-page .nw-card.active .nw-check {
border-color: var(--klein);
color: #fff;
background: var(--klein);
}
.project-wizard-page .nw-cover {
height: 174px;
overflow: hidden;
background: #eceef2;
}
.project-wizard-page .nw-cover img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
transition: transform 260ms ease;
}
.project-wizard-page .nw-card:hover .nw-cover img { transform: scale(1.025); }
.project-wizard-page .nw-meta { flex: 1; padding: 14px 15px 15px; }
.project-wizard-page .nw-meta h3 { margin: 0; font-size: 15px; font-weight: 600; }
.project-wizard-page .nw-meta p { margin: 6px 0 11px; color: var(--nw-muted); font-size: 11px; }
.project-wizard-page .nw-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.project-wizard-page .nw-tags span {
padding: 4px 7px;
border-radius: 999px;
color: #565d68;
background: rgba(34, 42, 54, 0.055);
font-size: 10px;
}
.project-wizard-page .nw-create {
align-items: center;
justify-content: center;
gap: 12px;
border-style: dashed;
color: var(--klein);
background: linear-gradient(145deg, rgba(0, 47, 167, 0.035), rgba(255, 255, 255, 0.72));
text-align: center;
}
.project-wizard-page .nw-create-icon {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: 18px;
color: #fff;
background: var(--klein);
box-shadow: 0 12px 26px rgba(0, 47, 167, 0.20);
}
.project-wizard-page .nw-create-icon svg { width: 25px; height: 25px; }
.project-wizard-page .nw-create strong { color: var(--accent-black); font-size: 15px; }
.project-wizard-page .nw-create > span:last-child {
max-width: 150px;
color: var(--nw-muted);
font-size: 11px;
line-height: 1.55;
}
.project-wizard-page .nw-empty {
grid-column: 1 / -1;
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 28px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--nw-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.project-wizard-page .nw-empty strong { color: var(--accent-black); font-size: 14px; }
.project-wizard-page .nw-empty-reset {
height: 36px;
padding: 0 14px;
border: 0;
border-radius: 9px;
color: #fff;
background: var(--klein);
font: inherit;
cursor: pointer;
}
.project-wizard-page .list-pager { margin-top: 18px; }
.project-wizard-page .list-pager .pages button.active {
background: var(--klein);
border-color: var(--klein);
}
.project-wizard-page .nw-config {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid rgba(34, 42, 54, 0.08);
}
.project-wizard-page .nw-config-h { margin-bottom: 16px; }
.project-wizard-page .nw-config-h h2 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
.project-wizard-page .nw-config-h p { margin: 0; color: var(--nw-muted); font-size: 12px; line-height: 1.6; }
.project-wizard-page .nw-field { display: grid; gap: 8px; margin-bottom: 16px; }
.project-wizard-page .nw-field > span {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 13px;
font-weight: 500;
}
.project-wizard-page .nw-field small { color: var(--nw-muted); font-size: 11px; font-weight: 400; }
.project-wizard-page .nw-field input,
.project-wizard-page .nw-field select {
height: 44px;
padding: 0 13px;
border: 1px solid var(--nw-line);
border-radius: 10px;
background: #fff;
font: inherit;
font-size: 14px;
color: var(--accent-black);
}
.project-wizard-page .nw-field input:focus,
.project-wizard-page .nw-field select:focus {
outline: 0;
border-color: rgba(0, 47, 167, 0.36);
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07);
}
.project-wizard-page .nw-tpl {
padding: 12px 14px;
border: 1px solid rgba(0, 47, 167, 0.12);
border-radius: 12px;
background: rgba(0, 47, 167, 0.04);
}
.project-wizard-page .nw-tpl strong { display: block; font-size: 13px; }
.project-wizard-page .nw-tpl p { margin: 6px 0 0; color: var(--nw-muted); font-size: 12px; line-height: 1.6; }
.project-wizard-page .nw-tpl pre {
margin: 8px 0 0;
color: var(--nw-muted);
font-size: 11px;
line-height: 1.7;
white-space: pre-wrap;
}
.project-wizard-page .nw-pills { display: flex; flex-wrap: wrap; gap: 8px; }
.project-wizard-page .nw-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--nw-line);
border-radius: 999px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.project-wizard-page .nw-pill.on {
color: var(--klein);
border-color: rgba(0, 47, 167, 0.28);
background: rgba(0, 47, 167, 0.07);
font-weight: 600;
}
.project-wizard-page .nw-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: auto;
padding-top: 22px;
}
.project-wizard-page .nw-tip {
display: flex;
align-items: center;
gap: 8px;
color: var(--nw-muted);
font-size: 11px;
}
.project-wizard-page .nw-tip svg { width: 15px; height: 15px; flex: 0 0 auto; }
.project-wizard-page .nw-next {
min-width: 196px;
height: 50px;
padding: 0 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 0;
border-radius: 11px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
font: inherit;
cursor: pointer;
}
.project-wizard-page .nw-next:hover:not(:disabled) {
transform: translateY(-2px);
background: var(--klein-hover);
}
.project-wizard-page .nw-next:disabled { opacity: 0.38; transform: none; box-shadow: none; cursor: not-allowed; }
.project-wizard-page .nw-next svg { width: 19px; height: 19px; }
@media (max-width: 1500px) {
.project-wizard-page .nw-shell { grid-template-columns: 230px minmax(0, 1fr); }
.project-wizard-page .nw-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1120px) {
.project-wizard-page .nw-shell { grid-template-columns: 1fr; }
.project-wizard-page .nw-rail { display: none; }
.project-wizard-page .nw-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.project-wizard-page .nw-grid { grid-template-columns: 1fr; }
.project-wizard-page .nw-toolbar { flex-wrap: wrap; }
.project-wizard-page .nw-search,
.project-wizard-page .nw-select { width: 100%; flex: 1 1 100%; }
.project-wizard-page .nw-actions { flex-direction: column; align-items: stretch; }
}
/* 新建商品抽屉(portal 到 body) */
.drawer .np-point-add { display: flex; gap: 8px; align-items: center; }
.drawer .np-point-add .input { flex: 1; min-width: 0; }
.drawer .np-drawer-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; padding-top: 16px; border-top: 1px solid var(--border-faint); }
.drawer .field-hint.err { color: var(--accent-crimson); margin-top: 4px; }
.drawer .input.error { border-color: var(--accent-crimson); }
.drawer .theme-pill { display: inline-flex; gap: 6px; align-items: center; height: 32px; padding: 0 14px; border: 1px solid var(--border-faint); border-radius: 999px; background: var(--surface); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; color: var(--accent-black); transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.drawer .theme-pill:hover { background: var(--background-lighter); }
.drawer .theme-pill.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat); font-weight: 600; }
.drawer .theme-pill svg { width: 12px; height: 12px; }
+507 -246
View File
@@ -1,258 +1,519 @@
/* 视频项目页 · public/exact/projects.html 内联 <style> 忠实移植,整段 scope .projects-page
tabs/chip/toolbar/search-inline/result-meta 走全局 design-restraint(restraint );此处只覆盖 proj-* / view-toggle 等页面专属 */
/* 视频创作入口 · 对照影擎 video-hub不重写共享 .btn/.chip/.tabs
后半段 list/legacy 样式保留本轮列表走 .vc-* */
.projects-page {
/* ─── YZ 视频创作 · 页头 + 入口卡 + 项目网格 ─── */
.vc-head { margin: 4px 0 28px; }
.vc-head h1 {
font-size: 32px;
font-weight: 600;
letter-spacing: -.03em;
line-height: 1.2;
color: var(--accent-black);
}
.vc-head p { margin-top: 10px; font-size: 14px; color: var(--black-alpha-48); }
--klein: #002fa7;
--klein-hover: #002680;
--vc-black: #101012;
--vc-muted: #6f747c;
--vc-line: rgba(34, 42, 54, 0.10);
--vc-wash: rgba(34, 42, 54, 0.045);
--vc-shadow: 0 8px 18px rgba(20, 27, 38, 0.075);
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
}
.vc-sec { margin-bottom: 40px; }
.vc-sec-h { margin-bottom: 16px; }
.vc-sec-h h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.vc-sec-h p { margin-top: 6px; font-size: 13px; color: var(--black-alpha-48); }
@media (max-width: 1100px) {
.projects-page { margin: -28px -24px -48px; }
}
.vc-row { display: grid; gap: 20px; }
.vc-row.cols-2 { grid-template-columns: 1fr 1fr; }
.vc-row.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.projects-page .vc-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 26px;
}
.projects-page .vc-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.projects-page .vc-head p { margin: 0; color: var(--vc-muted); font-size: 15px; }
.projects-page .vc-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.projects-page .vc-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
font: inherit;
cursor: pointer;
}
.projects-page .vc-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.projects-page .vc-ghost.is-on { color: #fff; border-color: var(--vc-black); background: var(--vc-black); }
.projects-page .vc-ghost svg { width: 19px; height: 19px; }
.vc-card {
min-width: 0;
height: 240px;
position: relative;
display: flex;
flex-direction: column;
padding: 0;
border: 0;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow-floating), inset 0 0 0 1px var(--border-faint);
overflow: hidden;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
transition: transform var(--t-fast), box-shadow var(--t-base);
}
.vc-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(17, 19, 24, .08), inset 0 0 0 1px var(--border-faint);
}
.vc-visual {
flex: 1.65 1 0;
min-height: 0;
width: 100%;
background: var(--black-alpha-4);
overflow: hidden;
}
.vc-visual img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vc-copy {
flex: 1 1 0;
min-height: 0;
position: relative;
padding: 16px 40px 16px 18px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.vc-copy .t { font-size: 16px; font-weight: 600; letter-spacing: -.01em; color: var(--accent-black); }
.vc-copy .d { font-size: 13px; line-height: 1.5; color: var(--black-alpha-48); }
.vc-arrow {
position: absolute;
top: 14px;
right: 14px;
width: 22px;
height: 22px;
color: var(--accent-bluetron);
display: grid;
place-items: center;
pointer-events: none;
}
.vc-arrow svg { width: 16px; height: 16px; }
.projects-page .vc-sec { margin-bottom: 26px; }
.projects-page .vc-sec-h { margin-bottom: 13px; }
.projects-page .vc-sec-h h2,
.projects-page .vc-list-h h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--accent-black);
}
.projects-page .vc-sec-h p,
.projects-page .vc-list-h p {
margin: 5px 0 0;
color: var(--vc-muted);
font-size: 12px;
}
.projects-page .vc-list-h { margin: 32px 0 17px; }
.projects-page .vc-list-h h2 { font-size: 21px; }
.projects-page .vc-list-h p { font-size: 13px; }
.vc-projects { margin-top: 8px; }
.vc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.vc-pills { display: flex; flex-direction: row; gap: 8px; }
.vc-pill {
height: 36px;
padding: 0 16px;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--black-alpha-56);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.vc-pill:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.vc-pill.active { background: var(--nav-active); color: var(--accent-white); }
.vc-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vc-search { position: relative; width: 200px; }
.vc-search svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--black-alpha-40);
pointer-events: none;
}
.vc-search input {
width: 100%;
height: 36px;
padding: 0 12px 0 34px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
font: inherit;
font-size: 13px;
color: var(--accent-black);
}
.vc-search input::placeholder { color: var(--black-alpha-40); }
.vc-search input:focus { outline: none; border-color: var(--heat-40); box-shadow: inset 0 0 0 1px var(--heat-40); }
.vc-view {
display: inline-flex;
height: 36px;
padding: 3px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
gap: 2px;
}
.vc-view button {
width: 30px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--black-alpha-40);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.vc-view button.active { background: var(--black-alpha-7); color: var(--accent-black); }
.vc-view button:hover { color: var(--accent-black); }
.projects-page .vc-tools {
display: grid;
gap: 20px;
}
.projects-page .vc-tools.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.projects-page .vc-tools.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.projects-page .vc-tool {
position: relative;
width: 100%;
min-height: 224px;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 1px solid var(--vc-line);
border-radius: 14px;
color: var(--accent-black);
background: var(--vc-wash);
box-shadow: var(--vc-shadow);
font: inherit;
text-align: left;
cursor: pointer;
transition: transform 200ms ease, box-shadow 200ms ease, background-color 200ms ease;
}
.projects-page .vc-tool:hover {
transform: translateY(-4px);
background: rgba(34, 42, 54, 0.075);
box-shadow: 0 15px 32px rgba(20, 27, 38, 0.13);
}
.projects-page .vc-tool.primary {
border-color: rgba(0, 47, 167, 0.24);
background: rgba(0, 47, 167, 0.055);
}
.projects-page .vc-tool.primary:hover { background: rgba(0, 47, 167, 0.08); }
.projects-page .vc-tool-cover {
width: calc(100% + 2px);
height: 122px;
flex: 0 0 122px;
margin: -1px -1px 0;
overflow: hidden;
border-radius: 14px 14px 0 0;
background: #e8e9ec;
}
.projects-page .vc-tool-cover img,
.projects-page .vc-thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.projects-page .vc-tool-body { flex: 1; padding: 17px 18px 19px; }
.projects-page .vc-tool-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.projects-page .vc-tool-title h3 { margin: 0; font-size: 18px; font-weight: 600; }
.projects-page .vc-tool-title svg { width: 21px; height: 21px; flex: 0 0 auto; color: var(--klein); }
.projects-page .vc-tool-body p {
margin: 9px 0 0;
color: var(--vc-muted);
font-size: 13px;
line-height: 1.65;
}
.vc-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.vc-proj {
background: var(--surface);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-floating), inset 0 0 0 1px var(--border-faint);
outline: none;
display: flex;
flex-direction: column;
aspect-ratio: 3 / 4;
}
.vc-proj:hover { box-shadow: 0 12px 32px rgba(17, 19, 24, .08), inset 0 0 0 1px var(--border-faint); }
.vc-proj-visual {
flex: 1.7 1 0;
min-height: 0;
position: relative;
background: var(--black-alpha-4);
overflow: hidden;
}
.vc-proj-visual img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vc-proj-body {
flex: 1 1 0;
min-height: 0;
padding: 14px 14px 16px;
display: flex;
flex-direction: column;
}
.vc-proj-name {
font-size: 14px;
font-weight: 600;
color: var(--accent-black);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-proj-sub {
margin-top: 6px;
font-size: 12px;
color: var(--black-alpha-48);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vc-proj-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding-top: 12px;
}
.vc-tag {
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.vc-tag.done { background: var(--forest-bg); color: var(--accent-forest); }
.vc-tag.wip { background: var(--heat-12); color: var(--accent-bluetron); }
.vc-tag.draft { background: var(--black-alpha-4); color: var(--black-alpha-56); }
.vc-tag.fail { background: var(--crimson-bg); color: var(--accent-crimson); }
.vc-proj-cta {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: var(--r-pill);
background: var(--black-alpha-4);
color: var(--accent-black);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.vc-proj-cta:hover { background: var(--black-alpha-7); }
.projects-page .vc-toolbar {
min-height: 66px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
background: var(--vc-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
.projects-page .vc-seg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.projects-page .vc-seg-btn {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
color: #50555d;
background: transparent;
font: inherit;
font-size: 14px;
cursor: pointer;
}
.projects-page .vc-seg-btn:hover { color: var(--accent-black); }
.projects-page .vc-seg-btn.active { color: #fff; background: var(--vc-black); }
.projects-page .vc-toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.projects-page .vc-search {
width: 250px;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid var(--vc-line);
border-radius: 10px;
background: #fff;
}
.projects-page .vc-search svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--vc-muted); }
.projects-page .vc-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
color: var(--accent-black);
}
.projects-page .vc-search input::placeholder { color: var(--vc-muted); }
.list-pager { margin-top: 20px; }
.projects-page .vc-select { position: relative; width: 150px; flex: 0 0 150px; z-index: 40; }
.projects-page .vc-select.open { z-index: 50; }
.projects-page .vc-select-trigger {
width: 100%;
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.12);
border-radius: 10px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.projects-page .vc-select-trigger:hover,
.projects-page .vc-select.open .vc-select-trigger { border-color: rgba(0, 47, 167, 0.36); }
.projects-page .vc-select.open .vc-select-trigger { box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.07); }
.projects-page .vc-select-trigger span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.projects-page .vc-select-trigger svg { width: 15px; height: 15px; flex: 0 0 auto; color: var(--vc-muted); transition: transform 180ms ease; }
.projects-page .vc-select.open .vc-select-trigger svg { transform: rotate(180deg); }
.projects-page .vc-select-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 120;
display: grid;
gap: 3px;
padding: 6px;
border: 1px solid var(--vc-line);
border-radius: 11px;
background: #fff;
box-shadow: 0 14px 34px rgba(20, 27, 38, 0.14);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-5px);
}
.projects-page .vc-select.open .vc-select-menu {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
.projects-page .vc-select-option {
min-height: 38px;
padding: 0 10px;
border: 0;
border-radius: 8px;
color: var(--accent-black);
background: #fff;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.projects-page .vc-select-option:hover { background: #e8f2ff; }
.projects-page .vc-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
@media (max-width: 1280px) {
.vc-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.vc-row.cols-2, .vc-row.cols-3 { grid-template-columns: 1fr; }
.vc-card { height: 220px; }
.vc-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.vc-grid { grid-template-columns: 1fr; }
}
.projects-page .vc-view { display: flex; align-items: center; gap: 8px; }
.projects-page .vc-view-btn {
width: 40px;
height: 40px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 10px;
color: #393d44;
background: rgba(34, 42, 54, 0.055);
cursor: pointer;
}
.projects-page .vc-view-btn:hover,
.projects-page .vc-view-btn.active { background: rgba(34, 42, 54, 0.11); }
.projects-page .vc-view-btn svg { width: 16px; height: 16px; }
.projects-page .vc-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
margin-top: 18px;
}
.projects-page .vc-card {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--vc-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--vc-shadow);
cursor: pointer;
outline: none;
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.projects-page .vc-card:hover { transform: translateY(-3px); background: rgba(34, 42, 54, 0.08); }
.projects-page .vc-card.selected {
border-color: var(--klein);
box-shadow: 0 0 0 2px rgba(0, 47, 167, 0.16), var(--vc-shadow);
}
.projects-page .vc-thumb {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
background: #e5e6e9;
}
.projects-page .vc-play {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
width: 48px;
height: 48px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.58);
border-radius: 50%;
color: #fff;
background: rgba(8, 15, 29, 0.42);
box-shadow: 0 10px 22px rgba(8, 15, 29, 0.16);
cursor: pointer;
transform: translate(-50%, -50%);
backdrop-filter: blur(7px);
}
.projects-page .vc-play:hover { background: rgba(0, 47, 167, 0.66); }
.projects-page .vc-play svg {
width: 22px;
height: 22px;
margin-left: 2px;
fill: currentColor;
}
.projects-page .vc-meta { padding: 15px 16px 17px; }
.projects-page .vc-meta h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.projects-page .vc-meta p {
margin: 0;
color: var(--vc-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.projects-page .vc-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 13px;
}
.projects-page .vc-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border-radius: 7px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.projects-page .vc-tag.done { color: #138d3f; background: rgba(22, 184, 78, 0.10); }
.projects-page .vc-tag.wip { color: var(--klein); background: rgba(0, 47, 167, 0.09); }
.projects-page .vc-tag.fail { color: #c73737; background: rgba(34, 42, 54, 0.08); }
.projects-page .vc-tag.type { color: var(--klein); background: rgba(0, 47, 167, 0.09); }
.projects-page .vc-progress {
height: 4px;
margin-top: 12px;
overflow: hidden;
border-radius: 99px;
background: #e2e5ea;
}
.projects-page .vc-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--klein);
}
.projects-page .vc-check {
position: absolute;
top: 12px;
left: 12px;
z-index: 4;
width: 25px;
height: 25px;
display: none;
place-items: center;
border: 2px solid rgba(0, 47, 167, 0.38);
border-radius: 8px;
color: transparent;
background: rgba(255, 255, 255, 0.96);
pointer-events: none;
}
.projects-page .vc-check svg { width: 13px; height: 13px; }
.projects-page.manage-mode .vc-card .vc-check { display: grid; }
.projects-page.manage-mode .vc-card.selected .vc-check {
color: #fff;
border-color: var(--klein);
background: var(--klein);
}
.projects-page.manage-mode .vc-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
.projects-page .vc-grid.list { grid-template-columns: 1fr; }
.projects-page .vc-grid.list .vc-card {
display: grid;
grid-template-columns: 184px minmax(0, 1fr);
align-items: stretch;
min-height: 132px;
}
.projects-page .vc-grid.list .vc-thumb {
width: 184px;
height: 132px;
aspect-ratio: auto;
}
.projects-page .vc-grid.list .vc-play {
width: 40px;
height: 40px;
}
.projects-page .vc-grid.list .vc-play svg { width: 18px; height: 18px; }
display: flex;
min-width: 0;
padding: 18px 22px;
flex-direction: column;
justify-content: center;
}
.projects-page .vc-empty {
min-height: 190px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
margin-top: 18px;
padding: 28px;
border: 1px dashed rgba(0, 47, 167, 0.20);
border-radius: 16px;
color: var(--vc-muted);
background: rgba(0, 47, 167, 0.03);
text-align: center;
}
.projects-page .vc-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
.projects-page .vc-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
.projects-page .vc-empty span { font-size: 12px; }
.projects-page .vc-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 13px 14px 13px 20px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 14px;
color: #fff;
background: rgba(16, 16, 18, 0.94);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
transform: translate(-50%, 140%);
opacity: 0;
pointer-events: none;
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 180ms ease;
}
.projects-page .vc-sel-bar.active { transform: translate(-50%, 0); opacity: 1; pointer-events: auto; }
.projects-page .vc-sel-copy strong,
.projects-page .vc-sel-copy span { display: block; }
.projects-page .vc-sel-copy span { margin-top: 3px; color: rgba(255, 255, 255, 0.58); font-size: 12px; }
.projects-page .vc-sel-actions { display: flex; gap: 8px; }
.projects-page .vc-sel-actions button {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.projects-page .vc-sel-cancel { color: #fff; background: rgba(255, 255, 255, 0.12); }
.projects-page .vc-sel-confirm { color: #fff; background: #002fa7; }
.projects-page .vc-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
.projects-page .list-pager { margin-top: 20px; }
.projects-page .list-pager .pages button.active {
background: var(--klein);
border-color: var(--klein);
}
@media (max-width: 1280px) {
.projects-page .vc-grid:not(.list) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.projects-page .vc-tools.two,
.projects-page .vc-tools.three { grid-template-columns: 1fr; }
.projects-page .vc-toolbar { flex-wrap: wrap; }
.projects-page .vc-grid:not(.list) { grid-template-columns: 1fr 1fr; }
.projects-page .vc-sel-bar { left: 50%; }
}
@media (max-width: 720px) {
.projects-page .vc-grid:not(.list) { grid-template-columns: 1fr; }
.projects-page .vc-search { width: 100%; }
}
.projects-page {
/* ─── YZ 预览 · 筛选胶囊(全部 / 进行中 / 已成片) ─── */
.proj-pills {
display: flex;
+35 -30
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { CreditCard, X } from "lucide-react";
import { ArrowLeft, CreditCard, X } from "lucide-react";
import { createPortal } from "react-dom";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
import type { NavigateFn } from "./route-config";
import { money, pts, stageMeta, yuan } from "./stage-config";
import { pageWindow } from "../components/pager";
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
@@ -14,9 +15,9 @@ const ROLE_PILL: Record<string, string> = { owner: "role-super", admin: "role-ad
type TrendRange = "day" | "week" | "month";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
day: { chip: "日", sub: "近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
};
type Tab = "overview" | "by-project" | "by-member" | "bills";
@@ -63,7 +64,7 @@ const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: num
const MIN_RECHARGE_AMOUNT = 50;
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)", bucket: "video" },
{ k: "视频片段(Seedance)", color: "var(--klein)", bucket: "video" },
{ k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" },
{ k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
@@ -92,18 +93,18 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><CreditCard size={16} /></div>
<div className="ti"><span>// {channelLabel}</span></div>
<div className="ti"><span>{channelLabel}</span></div>
<button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b">
<div className="topup-info"></div>
<div className="topup-amt">{yuan(amount)}</div>
<div className="topup-note">{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
<div className="topup-note">{`到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
<div className="topup-qr" aria-label="二维码占位">
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
</div>
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
<div className="topup-valid">// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
<div className="topup-valid">5 · </div>
<div className="topup-valid"> · · </div>
</div>
<div className="modal-f">
<button className="btn" type="button" onClick={close}></button>
@@ -115,11 +116,12 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
);
}
export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
export function AccountPage({ billing, projects, team, navigate, onRecharge, onNotify }: {
billing: BillingSummary | null;
projects: Project[];
// team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14)
team?: Team | null;
navigate: NavigateFn;
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
onNotify: (type: "success" | "error" | "info", text: string) => void;
}) {
@@ -281,9 +283,12 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
return (
<section className="account-page">
<div className="page-head">
<button className="ac-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
<ArrowLeft size={18} strokeWidth={1.75} />
</button>
<div>
<h1></h1>
<div className="sub"><span className="mono">// 余额 · 充值 · 4 维消费视图 + 账单流水</span></div>
<h1></h1>
<div className="sub">{money(balance)} · {money(used)} · </div>
</div>
</div>
@@ -293,18 +298,18 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className="balance-hero">
<div className="lbl"></div>
<div className="v">{money(balance)}</div>
<div className="meta">// 充值累加 · 不重置</div>
<div className="meta"> · </div>
</div>
<div className="balance-sub">
<div className="col">
<div className="lbl"></div>
<div className="v">{money(limit)}</div>
<div className="meta">// 按自然月重置</div>
<div className="meta"></div>
</div>
<div className="col">
<div className="lbl"></div>
<div className="v">{money(used)}</div>
<div className="meta">// 占比 {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
<div className="meta"> {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
</div>
</div>
<div className="balance-foot">
@@ -320,7 +325,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className="topup-head">
<div>
<h3></h3>
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
<div className="desc">, · </div>
</div>
<div className="topup-selected"> ¥{effectiveAmount}( {Math.round(effectiveAmount * pointsRate + effectiveBonus)} ){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
</div>
@@ -377,10 +382,10 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
</div>
<div className="billing-tabs" role="tablist">
<button className={`tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}></button>
<button className={`tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}> <span className="count">{projects.length}</span></button>
<button className={`tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}> <span className="count">{teamMembers.length}</span></button>
<button className={`tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}> <span className="count">{ledgerCount}</span></button>
<button className={`ac-tab ${tab === "overview" ? "active" : ""}`} type="button" onClick={() => setTab("overview")}></button>
<button className={`ac-tab ${tab === "by-project" ? "active" : ""}`} type="button" onClick={() => setTab("by-project")}> <span className="count">{projects.length}</span></button>
<button className={`ac-tab ${tab === "by-member" ? "active" : ""}`} type="button" onClick={() => setTab("by-member")}> <span className="count">{teamMembers.length}</span></button>
<button className={`ac-tab ${tab === "bills" ? "active" : ""}`} type="button" onClick={() => setTab("bills")}> <span className="count">{ledgerCount}</span></button>
</div>
<div className={`tab-panel ${tab === "overview" ? "active" : ""}`}>
@@ -391,7 +396,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<span className="sub">{meta.sub}</span>
<span className="spacer"></span>
{(["day", "week", "month"] as TrendRange[]).map((r) => (
<button key={r} className={`chip${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
<button key={r} className={`ac-seg${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
))}
</div>
<div className="trend-chart">
@@ -422,7 +427,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className="pane stage-pane">
<h3></h3>
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
<div className="desc">PRD §5.3.5 · </div>
{STAGES.map((s) => {
const amt = Number(trend?.by_stage[s.bucket] || 0);
const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0;
@@ -439,7 +444,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<div className="pane rule-pane" style={{ marginTop: 16 }}>
<h3> + </h3>
<div className="desc">// PRD §5.3.5 + §10.3 · 对接团队请以此页为准</div>
<div className="desc">PRD §5.3.5 + §10.3 · </div>
<div className="rule-list">
<strong> </strong>: / / <br />
<strong> </strong>:,<br />
@@ -447,7 +452,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<strong> </strong>, token
</div>
<div className="quota-rules">
<div className="qr-head">// 任务确认前 · 四层额度预检(任一不通过即拦截)</div>
<div className="qr-head"> · ()</div>
<div className="step"><span className="num">1</span><span><strong></strong> × <span className="formula">1.2</span></span></div>
<div className="step"><span className="num">2</span><span><strong></strong> </span></div>
<div className="step"><span className="num">3</span><span><strong></strong> </span></div>
@@ -482,9 +487,9 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<thead><tr><th></th><th> / </th><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{ledgersLoading && ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 加载中…</td></tr>
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}></td></tr>
) : ledgerRows.length === 0 ? (
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "// 无匹配筛选条件的账单" : "// 暂无账单流水"}</td></tr>
<tr><td colSpan={6} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>{billFiltered ? "无匹配筛选条件的账单" : "暂无账单流水"}</td></tr>
) : ledgerRows.map((l) => (
<tr key={l.id}>
<td className="ts">{new Date(l.created_at).toLocaleString("zh-CN")}</td>
@@ -502,7 +507,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
</div>
{ledgerCount > BILLS_PER_PAGE && (
<div className="bill-pager">
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
<span className="total"> {ledgerCount} · {safeBillPage} / {billTotalPages} </span>
<div className="pages">
<button type="button" disabled={safeBillPage <= 1} onClick={() => setBillPage(safeBillPage - 1)}></button>
{pageWindow(safeBillPage, billTotalPages).map((p, i) => (
@@ -548,7 +553,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<thead><tr><th></th><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{filteredProjects.length === 0 ? (
<tr><td colSpan={5} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 当前筛选条件下没有项目</td></tr>
<tr><td colSpan={5} className="muted" style={{ textAlign: "center", padding: "24px 0" }}></td></tr>
) : filteredProjects.map((p) => {
const spend = projectSpend(p.id);
const stg = projStage(p);
@@ -587,7 +592,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<thead><tr><th></th><th></th><th> / </th><th></th></tr></thead>
<tbody>
{filteredMembers.length === 0 ? (
<tr><td colSpan={4} className="muted" style={{ textAlign: "center", padding: "24px 0" }}>// 当前筛选条件下没有成员</td></tr>
<tr><td colSpan={4} className="muted" style={{ textAlign: "center", padding: "24px 0" }}></td></tr>
) : filteredMembers.map((m) => {
const monthly = Number(m.monthly_credit_limit || 0);
const monthUsed = Number(m.month_charged || 0);
@@ -600,7 +605,7 @@ export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
<td className="quota">
<span className="used">{money(monthUsed)}</span> <span className="lim">/ {monthly > 0 ? money(monthly) : "不限"}{monthly > 0 ? ` · ${usedPct.toFixed(1)}%` : ""}</span>
{monthly > 0 && (
<span className="progress-mini"><span style={{ width: `${usedPct}%`, background: usedPct >= 85 ? "var(--accent-honey)" : "var(--heat)" }} /></span>
<span className="progress-mini"><span style={{ width: `${usedPct}%`, background: usedPct >= 85 ? "var(--accent-honey)" : "var(--klein)" }} /></span>
)}
</td>
<td>{STATUS_LABEL[m.status] || m.status}</td>
+6 -6
View File
@@ -82,7 +82,7 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
navigateAdmin("");
}}
>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
</a>
</div>
<div className="admin-badge mono">[ PLATFORM ADMIN ]</div>
@@ -127,7 +127,7 @@ export function AdminApp({ section, user, team, navigateAdmin, navigate, logout
<div className="av">{(user.username || "A").slice(0, 1).toUpperCase()}</div>
<div className="admin-user-meta">
<span className="nm">{user.username}</span>
<span className="rl mono">// 平台超管</span>
<span className="rl mono"></span>
</div>
<button type="button" className="icon-btn admin-logout" title="退出登录" aria-label="退出登录" onClick={logout}>
<IconKitSvg name="logOut" size={16} />
@@ -218,7 +218,7 @@ function AdminOverview({ navigateAdmin }: { navigateAdmin: (s: string) => void }
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 跨团队后台</span> · 邀请码、团队用户、内容审核、生成与计费治理
<span className="mono"></span> ·
</div>
</div>
</div>
@@ -232,7 +232,7 @@ function AdminOverview({ navigateAdmin }: { navigateAdmin: (s: string) => void }
<span className="ic"><IconKitSvg name={s.icon} size={16} /></span>
<span className="admin-shortcut-text">
<span className="t">{s.label}</span>
<span className="d">// {s.slug}</span>
<span className="d">{s.slug}</span>
</span>
</button>
))}
@@ -248,14 +248,14 @@ function AdminPlaceholder({ section }: { section: AdminSection }) {
<div>
<h1>{section.label}</h1>
<div className="sub">
<span className="mono">// admin · {section.slug}</span>
<span className="mono">admin · {section.slug}</span>
</div>
</div>
</div>
<div className="empty-state show">
<div className="ic-empty"><IconKitSvg name={section.icon} size={24} /></div>
<h3>线</h3>
<p>// 计划于 Phase {section.phase} 落地</p>
<p> Phase {section.phase} </p>
</div>
</>
);
@@ -159,7 +159,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 条流水</span> · 全局信用流水 · 手动调额</div>
<div className="sub"><span className="mono">{count} </span> · · </div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ </button>
@@ -178,7 +178,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载计费数据" description="正在同步最新数据,请稍候。" icon="creditCard" />
) : ledgers.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3></h3><p>// no ledgers</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3></h3></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -207,7 +207,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Wallet size={16} /></div>
<div className="ti"><span>// credit adjust</span></div>
<div className="ti"></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -324,7 +324,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 条</span> · 4 层额度(团队月度 / 项目 / 单任务)· 设了才拦,留空不限</div>
<div className="sub"><span className="mono">{count} </span> · 4 ( / / )· ,</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={openNew}>+ </button>
@@ -334,7 +334,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载额度配置" description="正在同步最新数据,请稍候。" icon="gauge" />
) : policies.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3></h3><p>// 默认仅余额管控 · 点右上新建</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3></h3><p> · </p></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -365,7 +365,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Gauge size={16} /></div>
<div className="ti">{editing.id ? "编辑额度策略" : "新建额度策略"}<span>// quota policy</span></div>
<div className="ti">{editing.id ? "编辑额度策略" : "新建额度策略"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -77,7 +77,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 项目监控 + 数据完整性</span> · 跨团队项目流水线 · 孤儿记录体检</div>
<div className="sub"><span className="mono"> + </span> · 线 · </div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={checking} onClick={() => void runCheck()}>
@@ -110,7 +110,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载项目" description="正在同步最新数据,请稍候。" icon="clapperboard" />
) : projects.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3></h3><p>// no projects</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3></h3></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -104,7 +104,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
<div>
<h1></h1>
<div className="sub">
<span className="mono">// {count} 个</span> · 平台发「开团队码」开新团队;团管发「加入码」拉成员
<span className="mono">{count} </span> · ;
</div>
</div>
<div className="actions">
@@ -135,7 +135,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
<div className="empty-state show">
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
<h3></h3>
<p>// 点右上「发开团队码」生成第一个</p>
<p></p>
</div>
) : (
<div className="admin-table-wrap">
@@ -191,7 +191,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Ticket size={16} /></div>
<div className="ti"><span>// issue create_team</span></div>
<div className="ti"></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModalOpen(false)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -119,7 +119,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {providers.length} 供应商 · {models.length} 模型</span> · 热插拔中转站 · 定价 · 设默认</div>
<div className="sub"><span className="mono">{providers.length} · {models.length} </span> · · · </div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => setProvModal({ ...EMPTY_PROVIDER })}>+ </button>
@@ -188,7 +188,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Server size={16} /></div>
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}<span>// model provider</span></div>
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setProvModal(null)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -223,7 +223,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Server size={16} /></div>
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}<span>// model config</span></div>
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModelModal(null)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -64,7 +64,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 视频线 6 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
<div className="sub"><span className="mono">线 6 </span> · / + ; </div>
</div>
</div>
{loading ? (
@@ -79,7 +79,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
<div key={r.id} className={`card-hard pt-card${r.enabled ? "" : " pt-off"}`}>
<div className="pt-head">
<span className="pt-title">{r.label}</span>
<span className="mono pt-key">// {r.key}</span>
<span className="mono pt-key">{r.key}</span>
<span className="spacer" />
<label className="pt-enabled">
<input type="checkbox" checked={r.enabled} onChange={(e) => void toggle(r, e.target.checked)} />
@@ -74,7 +74,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 平台单层</span> · 各生成阶段的画质 / 风格词,留空则用默认</div>
<div className="sub"><span className="mono"></span> · / ,</div>
</div>
<div className="actions">
<button className={`btn${editMode ? " btn-primary" : ""}`} type="button" onClick={() => setEditMode((v) => !v)}>
@@ -93,7 +93,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
<div key={s.key} className="card-hard qw-stage">
<div className="qw-stage-h">
<span className="qw-stage-title">{s.label}</span>
<span className="mono qw-stage-hint">// {s.key}</span>
<span className="mono qw-stage-hint">{s.key}</span>
</div>
{ws.length === 0 && !editMode && <div className="qw-empty mono"> · 使</div>}
<div className="qw-chips">
@@ -98,7 +98,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个真人资产</span> · 火山人像素材库绿盾 / 红标</div>
<div className="sub"><span className="mono">{count} </span> · 绿 / </div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
@@ -118,7 +118,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载审核数据" description="正在同步最新数据,请稍候。" icon="shield" />
) : assets.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3></h3><p>// no person assets</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3></h3></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -112,7 +112,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个任务</span> · 全局 AI 任务 · 成本异常 · 失败重投</div>
<div className="sub"><span className="mono">{count} </span> · AI · · </div>
</div>
</div>
@@ -130,7 +130,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载任务" description="正在同步最新数据,请稍候。" icon="activity" />
) : tasks.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3></h3><p>// no tasks</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3></h3></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -201,7 +201,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
{detail.attempts.map((attempt) => (
<div className="admin-attempt" key={attempt.id}>
<div className="admin-attempt-head">
<span className="admin-attempt-seq mono">// {String(attempt.sequence).padStart(2, "0")}</span>
<span className="admin-attempt-seq mono">{String(attempt.sequence).padStart(2, "0")}</span>
{statusPill(attempt.status)}
<span className="admin-attempt-kind mono">{attemptKind(attempt)}</span>
</div>
@@ -115,7 +115,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个团队</span> · 跨团队总览 · 启停 / 详情</div>
<div className="sub"><span className="mono">{count} </span> · · / </div>
</div>
</div>
@@ -131,7 +131,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载团队数据" description="正在同步最新数据,请稍候。" icon="building" />
) : teams.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3></h3><p>// no teams</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3></h3></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -169,7 +169,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Percent size={16} /></div>
<div className="ti"><span>// {pricingTarget.name}</span></div>
<div className="ti"><span>{pricingTarget.name}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPricingTarget(null)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -177,7 +177,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<div className="field">
<label className="field-label"> <span className="lbl-note">(0.10 ~ 10.00,1.00 = )</span></label>
<input className="input num-input" type="number" step="0.05" min="0.1" max="10" value={pricingValue} onChange={(e) => setPricingValue(e.target.value)} />
{Number(pricingValue) < lossThreshold && <div className="field-hint">// ⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警</div>}
{Number(pricingValue) < lossThreshold && <div className="field-hint"> ×{lossThreshold.toFixed(2)} ( ×{videoMargin} ),I9 </div>}
</div>
</div>
<div className="modal-f">
@@ -194,7 +194,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><Building size={16} /></div>
<div className="ti">{detail?.name || "团队详情"}<span>// team detail</span></div>
<div className="ti">{detail?.name || "团队详情"}</div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setDetail(null)}><X size={14} /></button>
</div>
<div className="modal-b">
@@ -298,7 +298,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {count} 个用户</span> · 跨团队 · 启停 / 强制改密</div>
<div className="sub"><span className="mono">{count} </span> · · / </div>
</div>
</div>
@@ -314,7 +314,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
{loading ? (
<SystemLoading title="正在加载用户数据" description="正在同步最新数据,请稍候。" icon="users" />
) : users.length === 0 ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3></h3><p>// no users</p></div>
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3></h3></div>
) : (
<div className="admin-table-wrap">
<table className="t admin-table">
@@ -361,7 +361,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h">
<div className="ic-m"><KeyRound size={16} /></div>
<div className="ti"><span>// {pwdTarget.username}</span></div>
<div className="ti"><span>{pwdTarget.username}</span></div>
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPwdTarget(null)}><X size={14} /></button>
</div>
<div className="modal-b">
File diff suppressed because it is too large Load Diff
+193 -171
View File
@@ -2,19 +2,24 @@ import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import {
ArrowRight,
Boxes,
Eye,
EyeOff,
Info,
LoaderCircle,
Lock,
LockKeyhole,
ScanLine,
ShieldCheck,
Sparkles,
Ticket,
UserRound,
Users
Users,
Workflow
} from "lucide-react";
import { api, getRemember, setRemember as persistRemember } from "../api";
import type { Team, User } from "../types";
import type { AuthMode } from "./route-config";
import "../login-page.css";
type LoginProgress = "checking" | "entering";
@@ -23,6 +28,10 @@ const LOGIN_PROGRESS_COPY: Record<LoginProgress, string> = {
entering: "登录成功,正在进入工作台…"
};
const LOGIN_PLUS_MARKS = Array.from({ length: 168 }, (_, index) => (
<span key={index}>+</span>
));
type FieldErrors = {
username?: string;
password?: string;
@@ -252,48 +261,76 @@ export function AuthScreen({
}
const brand = (
<aside className="auth-brand">
<div className="logo"><img className="logo-img" src="/assets/logo-dark.png" alt="Airshelf" /></div>
<div className="tag">// SHORT-VIDEO COMMERCE PLATFORM</div>
<div className="hero">
{mode === "register" ? (
<>
<h1><br /> <span className="h"></span></h1>
<p>1-2 </p>
</>
) : (
<>
<h1><br /><span className="h"></span></h1>
<p> </p>
</>
)}
<aside className="login-visual">
<div className="login-brand-lockup">
<img className="login-logo" src="/assets/yz/logo-on-dark.png" alt="影擎" />
<div className="login-brand-copy"><span>YINGQING AIGC STUDIO</span></div>
</div>
<div className="login-hero">
<p className="login-eyebrow">// AIGC COMMERCE CONTENT ENGINE</p>
<div className="login-hero-copy">
<h1>
<span className="login-hero-line"></span>
<span className="login-hero-line login-hero-accent"></span>
</h1>
<p></p>
</div>
<div className="login-capabilities" aria-label="平台能力">
<div className="login-capability">
<Sparkles />
<strong></strong>
<span></span>
</div>
<div className="login-capability">
<Workflow />
<strong></strong>
<span></span>
</div>
<div className="login-capability">
<Boxes />
<strong></strong>
<span></span>
</div>
</div>
</div>
<div className="login-visual-footer">
<span>© 2026 </span>
<span className="login-system-status"></span>
</div>
<div className="foot"><a href="#"></a><a href="#"></a><a href="#"></a><a href="#"></a></div>
</aside>
);
const toastNode = toast ? (
<div className="login-toast" role="status" aria-live="polite">
{toast.title}
<span>{toast.sub}</span>
</div>
) : null;
if (mode === "register") {
return (
<main className="auth-exact-page auth-register-page">
<div className="auth-wrap">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
{brand}
<section className="auth-form" aria-label="注册">
<div className="h-row"><h2></h2><span className="sub">// /auth/register</span></div>
<p className="lead">
{inviteState.status === "valid"
? inviteState.kind === "create_team"
? "邀请码有效 · 开新团队,你将成为团队超管。"
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
</p>
<form id="register-form" autoComplete="off" onSubmit={submitRegister} noValidate>
{/* 第一步:邀请码(必填)· 验过才展开后续表单 */}
<div className={`field${inviteState.status === "invalid" ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-invite"> <span className="req">*</span></label>
<div className="invite-verify-row">
<div className="field-input-wrap">
<Ticket className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<section className="login-page" aria-labelledby="loginTitle">
{brand}
<main className="login-form-panel">
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
<div className="login-card is-register">
<div className="login-card-heading">
<span className="login-step"><ShieldCheck />访</span>
<h2 id="loginTitle"></h2>
<p>
{inviteState.status === "valid"
? inviteState.kind === "create_team"
? "邀请码有效 · 开新团队,你将成为团队超管。"
: `邀请码有效 · 加入「${inviteState.teamName || "团队"}」。`
: "输入邀请码开通账户 —— 没有码请联系团队管理员或平台。"}
</p>
</div>
<form className="login-form" autoComplete="off" onSubmit={submitRegister} noValidate>
<label className={`login-field${inviteState.status === "invalid" ? " has-error" : ""}`}>
<span> <em className="req">*</em></span>
<div className="login-verify">
<span className="login-input-wrap">
<Ticket />
<input
id="reg-invite"
type="text"
@@ -304,33 +341,32 @@ export function AuthScreen({
onChange={(event) => onInviteChange(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void checkInvite(); } }}
/>
</div>
</span>
<button
type="button"
className="btn invite-verify-btn"
className="login-verify-btn"
disabled={inviteState.status === "checking" || !regInvite.trim()}
onClick={() => void checkInvite()}
>
{inviteState.status === "checking" ? "验证中…" : inviteState.status === "valid" ? "重新验证" : "验证"}
</button>
</div>
{inviteState.status === "invalid" && <p className="field-note err">{inviteState.detail}</p>}
{inviteState.status === "invalid" && <p className="login-note err">{inviteState.detail}</p>}
{inviteState.status === "valid" && (
<p className="field-note ok">
<ShieldCheck size={13} strokeWidth={1.8} aria-hidden="true" />
<p className="login-note ok">
<ShieldCheck size={13} />
{inviteState.kind === "create_team" ? "可开新团队" : `将加入「${inviteState.teamName || "团队"}`}
</p>
)}
</div>
</label>
{/* 第二步:验过码后展开 —— 不暴露任何团队列表 */}
{inviteState.status === "valid" && (
<>
{inviteState.kind === "create_team" ? (
<div className={`field${regErrors.team ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-team"> <span className="req">*</span></label>
<div className="field-input-wrap">
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<label className={`login-field${regErrors.team ? " has-error" : ""}`}>
<span> <em className="req">*</em></span>
<span className="login-input-wrap">
<Users />
<input
id="reg-team"
type="text"
@@ -339,23 +375,23 @@ export function AuthScreen({
aria-invalid={Boolean(regErrors.team)}
onChange={(event) => setRegValue("team", event.target.value)}
/>
</div>
</div>
</span>
</label>
) : (
<div className="field">
<label className="field-label"></label>
<div className="field-input-wrap is-readonly">
<Users className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<label className="login-field">
<span></span>
<span className="login-input-wrap">
<Users />
<input type="text" value={inviteState.teamName || "已绑定团队"} readOnly disabled />
</div>
<p className="field-note"></p>
</div>
</span>
<p className="login-note"></p>
</label>
)}
<div className={`field${regErrors.username ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-username"> <span className="req">*</span></label>
<div className="field-input-wrap">
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<label className={`login-field${regErrors.username ? " has-error" : ""}`}>
<span> <em className="req">*</em></span>
<span className="login-input-wrap">
<UserRound />
<input
id="reg-username"
type="text"
@@ -365,14 +401,14 @@ export function AuthScreen({
aria-invalid={Boolean(regErrors.username)}
onChange={(event) => setRegValue("username", event.target.value)}
/>
</div>
</div>
</span>
</label>
<div className="field-row">
<div className={`field${regErrors.password ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-pwd"> <span className="req">*</span></label>
<div className="field-input-wrap">
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<div className="login-pass-row">
<label className={`login-field${regErrors.password ? " has-error" : ""}`}>
<span> <em className="req">*</em></span>
<span className="login-input-wrap">
<LockKeyhole />
<input
id="reg-pwd"
type={regShowPwd ? "text" : "password"}
@@ -384,20 +420,18 @@ export function AuthScreen({
/>
<button
type="button"
className={`toggle-pwd${regShowPwd ? " active" : ""}`}
className="login-password-toggle"
aria-label={regShowPwd ? "隐藏密码" : "显示密码"}
aria-pressed={regShowPwd}
title={regShowPwd ? "隐藏密码" : "显示密码"}
onClick={() => setRegShowPwd((value) => !value)}
>
{regShowPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
{regShowPwd ? <EyeOff /> : <Eye />}
</button>
</div>
</div>
<div className={`field${regErrors.password2 ? " has-error" : ""}`}>
<label className="field-label" htmlFor="reg-pwd2"> <span className="req">*</span></label>
<div className="field-input-wrap">
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
</span>
</label>
<label className={`login-field${regErrors.password2 ? " has-error" : ""}`}>
<span> <em className="req">*</em></span>
<span className="login-input-wrap">
<LockKeyhole />
<input
id="reg-pwd2"
type={regShowPwd2 ? "text" : "password"}
@@ -409,19 +443,17 @@ export function AuthScreen({
/>
<button
type="button"
className={`toggle-pwd${regShowPwd2 ? " active" : ""}`}
className="login-password-toggle"
aria-label={regShowPwd2 ? "隐藏密码" : "显示密码"}
aria-pressed={regShowPwd2}
title={regShowPwd2 ? "隐藏密码" : "显示密码"}
onClick={() => setRegShowPwd2((value) => !value)}
>
{regShowPwd2 ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
{regShowPwd2 ? <EyeOff /> : <Eye />}
</button>
</div>
</div>
</span>
</label>
</div>
<label className={`agree${regErrors.agree ? " has-error" : ""}`}>
<label className={`login-agree${regErrors.agree ? " has-error" : ""}`}>
<input
type="checkbox"
checked={regAgree}
@@ -429,76 +461,72 @@ export function AuthScreen({
/>
<span>
{" "}
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}></a>{" "}
{" "}
<a href="#" onClick={(event) => { event.preventDefault(); showToast("用户协议", "含数据处理 / 内容生成版权 / 计费规则三章 · 完整文本将在正式版接入"); }}></a>
{" "}{" "}
<a href="#" onClick={(event) => { event.preventDefault(); showToast("隐私政策", "遵循《个人信息保护法》· 团队数据存中国境内 · 默认不用于模型训练"); }}></a>
</span>
</label>
{loginProgress && !error && (
<div className="login-progress" role="status" aria-live="polite">
<span className="login-progress-spinner" aria-hidden="true"></span>
<span>{inviteState.kind === "create_team" ? "正在创建团队,进入工作台…" : "正在加入团队,进入工作台…"}</span>
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? <LoaderCircle /> : <ArrowRight />}
{busy
? "提交中"
: inviteState.kind === "create_team"
? <> · 使 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>
: <> · 使 <ArrowRight size={15} strokeWidth={2} aria-hidden="true" /></>}
? "创建团队 · 开始使用"
: "加入团队 · 开始使用"}
</button>
</>
)}
{inviteState.status !== "valid" && error && <div className="form-error" role="alert">{error}</div>}
<div className="switch-row">
? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}> </a>
</div>
{inviteState.status !== "valid" && (
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
)}
</form>
</section>
</div>
{toast && (
<div className="toast show" role="status" aria-live="polite">
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
<p className="login-card-footer"><Lock /></p>
</div>
)}
</main>
<div className="login-extras">
<p className="login-switch">
? <a href="/login" onClick={(event) => { event.preventDefault(); switchMode("login"); }}> </a>
</p>
</div>
</main>
{toastNode}
</section>
);
}
return (
<main className="auth-exact-page auth-login-page">
<div className="auth-wrap">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
{brand}
<section className="auth-form" aria-label="登录">
<div className="h-row"><h2></h2><span className="sub">// /auth/login</span></div>
<form id="login-form" autoComplete="off" onSubmit={submitLogin} noValidate>
<p className="lead">线</p>
<div className={`field${fieldErrors.username ? " has-error" : ""}`}>
<label className="field-label" htmlFor="auth-username"> <span className="req">*</span></label>
<div className="field-input-wrap">
<UserRound className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
<section className="login-page" aria-labelledby="loginTitle">
{brand}
<main className="login-form-panel">
<div className="login-plus-pattern" aria-hidden="true">{LOGIN_PLUS_MARKS}</div>
<div className={`login-card${error ? " shake" : ""}`}>
<div className="login-card-heading">
<span className="login-step"><ShieldCheck />访</span>
<h2 id="loginTitle"></h2>
<p></p>
</div>
<form className="login-form" autoComplete="off" onSubmit={submitLogin} noValidate>
<label className={`login-field${fieldErrors.username ? " has-error" : ""}`}>
<span></span>
<span className="login-input-wrap">
<UserRound />
<input
id="auth-username"
type="text"
placeholder="请输入用户名"
placeholder="请输入账号"
value={username}
autoComplete="username"
aria-invalid={Boolean(fieldErrors.username)}
onChange={(event) => setFieldValue("username", event.target.value)}
/>
</div>
</div>
<div className={`field${fieldErrors.password ? " has-error" : ""}`}>
<label className="field-label" htmlFor="auth-pwd"> <span className="req">*</span></label>
<div className="field-input-wrap">
<LockKeyhole className="ic-l" size={16} strokeWidth={1.5} aria-hidden="true" />
</span>
</label>
<label className={`login-field${fieldErrors.password ? " has-error" : ""}`}>
<span></span>
<span className="login-input-wrap">
<LockKeyhole />
<input
id="auth-pwd"
type={showPwd ? "text" : "password"}
@@ -510,53 +538,47 @@ export function AuthScreen({
/>
<button
type="button"
className={`toggle-pwd${showPwd ? " active" : ""}`}
className="login-password-toggle"
aria-label={showPwd ? "隐藏密码" : "显示密码"}
aria-pressed={showPwd}
title={showPwd ? "隐藏密码" : "显示密码"}
onClick={() => setShowPwd((value) => !value)}
>
{showPwd ? <EyeOff size={18} strokeWidth={1.5} aria-hidden="true" /> : <Eye size={18} strokeWidth={1.5} aria-hidden="true" />}
{showPwd ? <EyeOff /> : <Eye />}
</button>
</div>
</div>
<div className="row-between">
<label><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} /> 7 </label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>?</a>
</div>
<div className="auth-submit-zone">
{loginProgress && !error && (
<div className="login-progress" role="status" aria-live="polite">
<span className="login-progress-spinner" aria-hidden="true"></span>
<span>{LOGIN_PROGRESS_COPY[loginProgress]}</span>
</div>
)}
{error && <div className="form-error" role="alert">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
{busy ? "登录中" : "登录"}
</button>
<div className="divider"><span className="line"></span><span className="txt">OR</span><span className="line"></span></div>
<div className="sso-row">
<button type="button" className="sso-btn" onClick={() => showToast("微信扫码", "对接中 · 当前请用用户名 + 密码登录")}>
<ScanLine size={14} strokeWidth={1.6} aria-hidden="true" />
</span>
</label>
<p className={`login-error${error ? " show" : ""}`} role="alert">
{error || "账号或密码不正确,请重新输入。"}
</p>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
{busy ? <LoaderCircle /> : <ArrowRight />}
</button>
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用用户名 + 密码登录")}>
<ShieldCheck size={14} strokeWidth={1.6} aria-hidden="true" /> SSO
</button>
</div>
<div className="switch-row">
? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a>
</div>
</div>
</form>
</section>
</div>
{toast && (
<div className="toast show" role="status" aria-live="polite">
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
<p className="login-card-footer"><Lock /></p>
</div>
)}
</main>
<div className="login-extras">
<div className="login-row">
<label>
<input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} />
7
</label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>?</a>
</div>
<div className="login-sso">
<button type="button" onClick={() => showToast("微信扫码", "对接中 · 当前请用账号 + 密码登录")}>
<ScanLine />
</button>
<button type="button" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用账号 + 密码登录")}>
<ShieldCheck /> SSO
</button>
</div>
<p className="login-switch">
? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a>
</p>
</div>
</main>
{toastNode}
</section>
);
}
+134 -113
View File
@@ -1,34 +1,43 @@
import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import {
ArrowRight,
ChevronRight,
FolderKanban,
Replace,
ScanSearch,
WandSparkles,
} from "lucide-react";
import type { BillingSummary, Product, Project } from "../types";
import type { NavigateFn, Page } from "./route-config";
import { IconKitSvg } from "../components/IconKitSvg";
type CreateTone = "blue-a" | "blue-b" | "light";
type DashTab = "all" | "wip" | "done";
type EntryTone = "primary" | "subtle";
const CREATE_GROUPS: Array<{
label: string;
hint: string;
cards: Array<{
title: string;
desc: string;
icon: string;
tone: CreateTone;
tone: EntryTone;
page: Page;
icon: "wand" | "folder" | "scan" | "replace";
}>;
}> = [
{
label: "从商品开始",
hint: "围绕商品卖点生成完整带货内容",
cards: [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", icon: "wand", tone: "blue-a", page: "projectWizard" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", icon: "clapperboard", tone: "blue-b", page: "projectWizard" },
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "projectWizard", icon: "wand" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
],
},
{
label: "从参考视频开始",
hint: "复用成熟视频的镜头表达与节奏",
cards: [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", icon: "scan", tone: "light", page: "freeCreate" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", icon: "swap", tone: "light", page: "freeCreate" },
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "freeCreate", icon: "scan" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
],
},
];
@@ -50,7 +59,7 @@ function dashStageNo(project: Project) {
function dashProgClass(project: Project, i: number, no: number): string {
if (project.status === "completed") return i <= no ? "done" : "";
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
return i < no ? "done" : i === no ? "warn" : "";
}
function dashStageLabel(project: Project): string {
if (project.status === "failed") return "失败";
@@ -67,7 +76,13 @@ function dashCardMeta(project: Project, productTitle: string): string {
const shots = project.video_segment_count ?? project.video_segments?.length ?? 0;
return ["专业创作", productTitle, shots ? `${shots}` : null].filter(Boolean).join(" / ");
}
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
function EntryIcon({ name }: { name: "wand" | "folder" | "scan" | "replace" }) {
if (name === "folder") return <FolderKanban />;
if (name === "scan") return <ScanSearch />;
if (name === "replace") return <Replace />;
return <WandSparkles />;
}
export function Dashboard({
products,
@@ -109,82 +124,84 @@ export function Dashboard({
const listed = useMemo(() => {
const rows = tab === "all" ? projects : projects.filter((project) => dashBucket(project) === tab);
return rows.slice(0, 3);
return rows.slice(0, 7);
}, [projects, tab]);
return (
<section className="dashboard-page">
<header className="dash-hero">
<section className="welcome">
<h1>{greetName ? `${greetName}` : ""}</h1>
<p className="dash-hero-sub">
{dateLabel} · {running}
</p>
</header>
<p>{dateLabel} · {running}</p>
</section>
<section className="dash-create" aria-label="开始创作">
<h2></h2>
{CREATE_GROUPS.map((group) => (
<div className="dash-create-group" key={group.label}>
<div className="dash-create-label">{group.label}</div>
<div className="dash-create-row">
{group.cards.map((card) => (
<button
key={card.title}
className={`dash-create-card ${card.tone}`}
type="button"
onClick={() => navigate(card.page)}
>
<span className="dash-create-ic" aria-hidden="true">
<IconKitSvg name={card.icon} size={22} strokeWidth={1.7} />
</span>
<span className="dash-create-copy">
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</span>
<span className="dash-create-arrow" aria-hidden="true">
<IconKitSvg name="arrowRight" size={18} strokeWidth={1.8} />
</span>
</button>
))}
<section className="creation" aria-labelledby="creationTitle">
<div className="creation-head">
<span className="section-label" id="creationTitle"></span>
</div>
<div className="creation-groups">
{CREATE_GROUPS.map((group) => (
<div className="creation-group-block" key={group.label}>
<div className="creation-group-label">
<strong>{group.label}</strong>
<span>{group.hint}</span>
</div>
<div className="creation-entry-pair">
{group.cards.map((card) => (
<button
key={card.title}
className={`entry entry-${card.tone}`}
type="button"
onClick={() => navigate(card.page)}
>
<EntryIcon name={card.icon} />
<span className="entry-copy">
<strong>{card.title}</strong>
<small>{card.desc}</small>
</span>
<ArrowRight />
</button>
))}
</div>
</div>
</div>
))}
</section>
<section className="dash-overview" aria-label="项目概览">
<div className="dash-overview-h"></div>
<div className="dash-overview-bar">
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "all" })}>
<span className="lbl"></span>
<span className="v">{projCount}</span>
</button>
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
<span className="lbl"></span>
<span className="v">{running}</span>
</button>
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects", { tab: "done" })}>
<span className="lbl"></span>
<span className="v">{completed}</span>
</button>
<button className="dash-overview-cell" type="button" onClick={() => navigate("projects")}>
<span className="lbl"></span>
<span className="v">+{newThisMonth}</span>
</button>
))}
</div>
</section>
<section className="dash-mine" aria-label="我的项目">
<div className="dash-mine-head">
<div className="workbench-section-divider"><span></span></div>
<section className="status-summary" aria-label="项目概览">
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "all" })}>
<span></span>
<strong>{projCount}</strong>
</button>
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "wip" })}>
<span></span>
<strong>{running}</strong>
</button>
<button className="summary-item" type="button" onClick={() => navigate("projects", { tab: "done" })}>
<span></span>
<strong>{completed}</strong>
</button>
<button className="summary-item" type="button" onClick={() => navigate("projects")}>
<span></span>
<strong className="positive">+{newThisMonth}</strong>
</button>
</section>
<section className="projects">
<div className="projects-head">
<h2></h2>
<button className="dash-mine-all" type="button" onClick={() => navigate("projects")}>
<IconKitSvg name="chevronRight" size={14} />
<button className="text-action" type="button" onClick={() => navigate("projects")}>
<span></span>
<ChevronRight />
</button>
</div>
<div className="dash-mine-pills" role="tablist" aria-label="项目筛选">
<div className="filters" role="tablist" aria-label="项目筛选">
{DASH_TABS.map((item) => (
<button
key={item.filter}
className={`dash-mine-pill${tab === item.filter ? " active" : ""}`}
className={`filter${tab === item.filter ? " active" : ""}`}
type="button"
role="tab"
aria-selected={tab === item.filter}
@@ -194,49 +211,53 @@ export function Dashboard({
</button>
))}
</div>
<div className="dash-mine-list">
{listed.length === 0 ? (
<div className="dash-mine-empty"></div>
) : listed.map((project) => {
const no = dashStageNo(project);
const cover = project.cover_preview_url || "";
const openProject = () => navigate("pipeline", { projectId: project.id });
return (
<article
key={project.id}
className="dash-proj-card"
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
<div className={`placeholder dash-proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}>
<span className="ph-frame">9:16</span>
</div>
<div className="dash-proj-main">
<div className="dash-proj-title">{project.name}</div>
<div className="dash-proj-sub">{dashCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="dash-proj-stage">
<div className="dash-proj-stage-lbl"></div>
<div className="dash-proj-stage-name">{dashStageLabel(project)}</div>
<div className="dash-proj-prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
</div>
<button
className="dash-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
{listed.length === 0 ? (
<div className="empty-state"></div>
) : (
<div className="project-list">
{listed.map((project) => {
const no = dashStageNo(project);
const cover = project.cover_preview_url || "";
const openProject = () => navigate("pipeline", { projectId: project.id });
return (
<article
key={project.id}
className="project-card"
data-status={dashBucket(project)}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
{project.status === "completed" ? "查看" : "继续"}
</button>
</article>
);
})}
</div>
<div className="product-thumb">
{cover ? <img src={cover} alt="" /> : <span className="ph-frame">9:16</span>}
</div>
<div className="project-info">
<h3>{project.name}</h3>
<p>{dashCardMeta(project, productTitle(project.product))}</p>
</div>
<div className="stage">
<span className="stage-label"></span>
<strong>{dashStageLabel(project)}</strong>
<div className="progress" aria-label="项目进度">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={`segment ${dashProgClass(project, i, no)}`} />
))}
</div>
</div>
<button
className="continue-button"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{project.status === "completed" ? "查看" : "继续"}
</button>
</article>
);
})}
</div>
)}
</section>
</section>
);
+33 -23
View File
@@ -2,7 +2,7 @@
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Images, Users } from "lucide-react";
import { ArrowLeft, Clapperboard, Images, Users } from "lucide-react";
import { api, ApiError } from "../api";
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
import {
@@ -49,10 +49,11 @@ function pollDelay(count: number): number {
let refKeySeq = 0;
const nextRefKey = () => `ref-${Date.now()}-${refKeySeq++}`;
export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }: {
modelConfigs: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
onTaskSettled: () => void;
onBack?: () => void;
}) {
// App 每次渲染会重建 onNotify 箭头函数;用 ref 稳定身份,否则依赖它的 effect(首屏拉取/轮询)
// 会随 App 任意 setState(如 30s 未读轮询)反复重跑 → 任务流无谓全量刷新(实测踩坑)。
@@ -78,11 +79,12 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
// —— 任务流 ——
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
const [total, setTotal] = useState(0);
const [, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const loadingMoreRef = useRef(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
// —— 输入条 ——
const [mode, setMode] = useState<FreeMode>("universal");
@@ -191,9 +193,10 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
};
}, [schedulePoll, notify]);
// 滚动加载更多(旧任务)
// 滚动加载更多(旧任务)· 滚动容器是中间作品区,不是整页
useEffect(() => {
const sentinel = sentinelRef.current;
const root = stageRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting || loadingMoreRef.current) return;
@@ -209,7 +212,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
})
.catch(() => undefined)
.finally(() => { loadingMoreRef.current = false; });
}, { rootMargin: "200px" });
}, { root, rootMargin: "200px" });
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore]);
@@ -526,31 +529,38 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
return (
<div className="fc-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// {total} 个视频</span> · AI 视频生成 · 全能参考 / 首尾帧
<header className="fc-head">
<div className="fc-title-row">
{onBack ? (
<button type="button" className="fc-back" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
) : null}
<div>
<h1></h1>
<p>使</p>
</div>
</div>
<div className="actions">
<button type="button" className="btn" onClick={() => openPlatformLibrary()}>
<Images size={14} />
<div className="fc-material">
<button type="button" className="fc-ghost" onClick={() => openLibrary()}>
<Users />
<span></span>
</button>
<button type="button" className="btn" onClick={() => openLibrary()}>
<Users size={14} />
<button type="button" className="fc-ghost" onClick={() => openPlatformLibrary()}>
<Images />
<span></span>
</button>
</div>
</div>
</header>
<div className="fc-feed-wrap">
<div className="fc-stage" ref={stageRef}>
{loading ? (
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
) : tasks.length === 0 ? (
<div className="empty-state show fc-empty">
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
<h3></h3>
<p>// 在下方输入提示词,生成你的第一条视频</p>
<div className="fc-empty">
<div className="fc-empty-icon"><Clapperboard /></div>
<strong></strong>
<p></p>
</div>
) : (
<div className="fc-feed">
@@ -568,7 +578,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="fc-sentinel mono">// 下滑加载更多</div>}
{hasMore && <div ref={sentinelRef} className="fc-sentinel"></div>}
</div>
<FreeInputBar
@@ -625,7 +635,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
<ConfirmModal
open={deleteTarget !== null}
title="删除视频"
detail={`确定删除这条视频吗?删除后可在资产库垃圾桶找回素材,任务记录不可恢复。`}
detail={`确定删除这条视频吗?删除后可在垃圾桶找回素材,任务记录不可恢复。`}
confirmText="确认删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
+329 -307
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2, Upload, X } from "lucide-react";
import { Check, ChevronDown, Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Search, Settings2, Trash2, Upload, X } from "lucide-react";
import { api } from "../api";
import { SkeletonGrid } from "../components/loading";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
@@ -103,6 +103,19 @@ function freeVideoToPack(asset: Asset): VideoPack {
};
}
function createdLabel(iso?: string): string {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startThat = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.round((startToday.getTime() - startThat.getTime()) / 86400000);
if (diffDays === 0) return "今天";
if (diffDays === 1) return "昨天";
return `${String(date.getMonth() + 1).padStart(2, "0")}.${String(date.getDate()).padStart(2, "0")}`;
}
function videoPackKey(pack: VideoPack): string {
return pack.project_id || pack.clips[0]?.id || pack.project_name;
}
@@ -266,7 +279,7 @@ function AssetDetailModal({ asset, close, onZoom }: {
<span className="ic"><Info size={14} /></span>
<span className="t"></span>
</div>
<p className="adx-intro">{intro || <span className="adx-muted">// 暂无简介</span>}</p>
<p className="adx-intro">{intro || <span className="adx-muted"></span>}</p>
{tags.length > 0 && (
<div className="adx-tags">
{tags.map((t) => <span className="adx-tag" key={t}>{t}</span>)}
@@ -316,7 +329,7 @@ function AssetDetailModal({ asset, close, onZoom }: {
<Download size={13} />
</a>
)}
<span className="adx-foot-meta mono">// {catLabel} · {srcLabel} · {createdAt}</span>
<span className="adx-foot-meta mono">{catLabel} · {srcLabel} · {createdAt}</span>
<button className="btn btn-primary" type="button" onClick={close}></button>
</div>
</div>
@@ -376,7 +389,7 @@ function UploadModal({ open, close, onSubmit }: {
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
<div className="modal upload-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti"><span>// UPLOAD</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="modal-h"><div className="ic-m"><Upload size={16} /></div><div className="ti"></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<form className="upload-modal-form" onSubmit={submit}>
<div className="modal-b">
<div className="field">
@@ -424,7 +437,7 @@ function UploadModal({ open, close, onSubmit }: {
</div>
</div>
<div className="modal-f">
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "// 选择一个文件"}</span>
<span className="upload-foot-meta mono">{file ? <><span className="accent">{file.name}</span> · {(file.size / 1024 / 1024).toFixed(2)} MB</> : "选择一个文件"}</span>
<button className="btn" type="button" onClick={close}></button>
<button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button>
</div>
@@ -460,10 +473,6 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
// 资产详情弹窗 + 灯箱(详情里点大图再放大)
const [detail, setDetail] = useState<Asset | null>(null);
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
@@ -593,7 +602,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
if (!(event.target as HTMLElement).closest(".lib-select")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
@@ -643,325 +652,350 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
}
}
const tabLabel = LIB_TABS.find((item) => item.key === tab)?.label || "资产";
const shownCount = isVideoPackTab ? shownPacks.length : (isBatchTab ? batches.length : shown.length);
const shownTotal = isVideoPackTab ? filteredPacks.length : total;
const emptyHint = isVideoPackTab
? (debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段")
: (hasFilter ? "没有找到符合条件的资产,请调整筛选后重试" : "当前分类暂无真实资产");
return (
<section className="library-page">
<div className="page-head">
<section className={`library-page${editMode ? " manage-mode" : ""}`}>
<header className="lib-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</span></div>
<h1></h1>
<p> · {imageBatchCount} · {counts.video_creations} · {counts.videopacks} </p>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
<div className="lib-actions">
<button className={`lib-ghost${editMode ? " is-on" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理资产"}</span>
</button>
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
</div>
</header>
<div className="lib-toolbar">
<div className="lib-seg" id="asset-tabs" role="tablist" aria-label="资产类型">
{LIB_TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
data-tab={t.key}
aria-selected={tab === t.key}
className={`lib-seg-btn${tab === t.key ? " active" : ""}`}
onClick={() => setTab(t.key)}
>
{t.label}
<span className="lib-seg-count">{counts[t.key]}</span>
</button>
))}
</div>
<div className="lib-toolbar-right">
<label className="lib-search">
<Search />
<input id="search-input" placeholder={isVideoPackTab ? "搜索项目名" : "搜索资产名称、标签"} value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
if (chip.key === "source") {
return (
<div className={`lib-select${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "source"} onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span>{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span>
<ChevronDown />
</button>
<div className="lib-select-menu" role="listbox">
<button className={`lib-select-option${!srcFilter ? " selected" : ""}`} type="button" onClick={() => { setSrcFilter(""); setOpenChip(""); }}></button>
{srcOptions.map((src) => (
<button className={`lib-select-option${srcFilter === src ? " selected" : ""}`} type="button" key={src} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>{SOURCE_LABELS[src] || src}</button>
))}
</div>
</div>
);
}
if (chip.key === "kind") {
return (
<div className={`lib-select${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "kind"} onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
<span>{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span>
<ChevronDown />
</button>
<div className="lib-select-menu" role="listbox">
<button className={`lib-select-option${!kindFilter ? " selected" : ""}`} type="button" onClick={() => { setKindFilter(""); setOpenChip(""); }}></button>
{kindOptions.map((k) => (
<button className={`lib-select-option${kindFilter === k ? " selected" : ""}`} type="button" key={k} onClick={() => { setKindFilter(k); setOpenChip(""); }}>{KIND_LABELS[k] || k}</button>
))}
</div>
</div>
);
}
if (chip.key === "product") {
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
return (
<div className={`lib-select${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "product"} onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span>{productFilter ? (curTitle || "关联商品") : "关联商品"}</span>
<ChevronDown />
</button>
<div className="lib-select-menu" role="listbox">
<button className={`lib-select-option${!productFilter ? " selected" : ""}`} type="button" onClick={() => { setProductFilter(""); setOpenChip(""); }}></button>
{facets.products.map((p) => (
<button className={`lib-select-option${productFilter === p.id ? " selected" : ""}`} type="button" key={p.id} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>{p.title}</button>
))}
</div>
</div>
);
}
const opts = metaOptions(chip.key);
const cur = metaFilter[chip.key] || "";
return (
<div className={`lib-select${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === chip.key} onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
<span>{cur || chip.label}</span>
<ChevronDown />
</button>
<div className="lib-select-menu" role="listbox">
<button className={`lib-select-option${!cur ? " selected" : ""}`} type="button" onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>{chip.label}</button>
{opts.map((v) => (
<button className={`lib-select-option${cur === v ? " selected" : ""}`} type="button" key={v} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>{v}</button>
))}
</div>
</div>
);
})}
<div className={`lib-select${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="lib-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "sort"} onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span>{isVideoPackTab ? (sortDesc ? "片段最多" : "片段最少") : (sortDesc ? "最近添加" : "最早添加")}</span>
<ChevronDown />
</button>
<div className="lib-select-menu" role="listbox">
{isVideoPackTab ? (
<>
<button className={`lib-select-option${sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(true); setOpenChip(""); }}></button>
<button className={`lib-select-option${!sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(false); setOpenChip(""); }}></button>
</>
) : (
<>
<button className={`lib-select-option${sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(true); setOpenChip(""); }}></button>
<button className={`lib-select-option${!sortDesc ? " selected" : ""}`} type="button" onClick={() => { setSortDesc(false); setOpenChip(""); }}></button>
</>
)}
</div>
</div>
</div>
</div>
<div className="tabs" id="asset-tabs">
{LIB_TABS.map((t) => (
<div className={`tab${tab === t.key ? " active" : ""}`} key={t.key} data-tab={t.key} onClick={() => setTab(t.key)}>{t.label} <span className="count">{counts[t.key]}</span></div>
))}
<div className="lib-section">
<h2>{tabLabel}</h2>
<p> · {shownCount} / {shownTotal} {resultUnit}{hasFilter || (isVideoPackTab && debouncedQuery) ? "(已筛选)" : ""}{loading && tab !== "videopacks" ? " · 加载中…" : ""}</p>
</div>
{tab === "videopacks" ? (
<>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" placeholder="搜索项目名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<span className="spacer"></span>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "片段最多" : "片段最少"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
</div>
</div>
</div>
<div className="result-meta">// 显示 <span className="count">{shownPacks.length}</span> / {filteredPacks.length} 个素材包{debouncedQuery ? "(已筛选)" : ""}</div>
<div className="packs-grid">
{filteredPacks.length === 0 ? (
<div className="empty-filter">// {debouncedQuery ? "没有匹配的视频成品" : "还没有视频成品 · 去视频项目生成片段"}</div>
) : (
shownPacks.map((pack) => {
const first = pack.clips[0];
// 视频成品的所有片段 id:批量删除整个素材包
const packId = videoPackKey(pack);
const isSelected = selectedVideoPacks.has(packId);
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
return (
<article className={`pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
<div className="placeholder asset-thumb pack-thumb">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : (
<span className="ph-frame"></span>
)}
<span className="pack-count mono">{pack.clips.length} </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono"></div></div>
</article>
);
})
)}
</div>
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : (
<>
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)Asset 无对应字段,保持静态
if (chip.key === "source") {
return (
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
<button className={`chip${srcFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!srcFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSrcFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{srcOptions.length > 0 && <div className="mi-sep" />}
{srcOptions.map((src) => (
<div className={`mi${srcFilter === src ? " selected" : ""}`} key={src} role="button" tabIndex={0} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{SOURCE_LABELS[src] || src}
filteredPacks.length === 0 ? (
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span></span></div>
) : (
<>
<div className="lib-grid" id="pack-grid">
{shownPacks.map((pack) => {
const first = pack.clips[0];
const packId = videoPackKey(pack);
const isSelected = selectedVideoPacks.has(packId);
const onPackClick = editMode ? () => toggleVideoPack(packId) : () => setOpenPack(pack);
return (
<article className={`lib-card pack-card batch-card${editMode && isSelected ? " selected" : ""}`} key={packId} onClick={onPackClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPackClick(); } }}>
<span className="lib-check" aria-hidden="true"><Check /></span>
<div className="lib-thumb portrait">
{first?.url ? (
<video src={first.url} muted playsInline preload="metadata" />
) : pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : (
<span className="ph-frame"></span>
)}
<span className="lib-count">{pack.clips.length} </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
))}
</div>
</div>
);
}
if (chip.key === "kind") {
return (
<div className={`chip-wrap${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
<button className={`chip${kindFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
<span className="chip-label">{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!kindFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setKindFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{kindOptions.length > 0 && <div className="mi-sep" />}
{kindOptions.map((k) => (
<div className={`mi${kindFilter === k ? " selected" : ""}`} key={k} role="button" tabIndex={0} onClick={() => { setKindFilter(k); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{KIND_LABELS[k] || k}
<div className="lib-meta">
<h3>{pack.project_name}</h3>
<p> · {pack.clips.length} </p>
<div className="lib-line">
<span className="lib-tag blue"></span>
</div>
</div>
))}
</div>
</div>
);
}
if (chip.key === "product") {
// 关联商品:选项走 facets.products(本 tab 下真实关联到的商品),选中后 product=<id> 进列表/批次请求
const curTitle = facets.products.find((p) => p.id === productFilter)?.title || "";
return (
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product" key="product">
<button className={`chip${productFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{productFilter ? (curTitle || "关联商品") : "关联商品"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!productFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setProductFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{facets.products.length > 0 && <div className="mi-sep" />}
{facets.products.map((p) => (
<div className={`mi${productFilter === p.id ? " selected" : ""}`} key={p.id} role="button" tabIndex={0} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{p.title}
</div>
))}
</div>
</div>
);
}
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
const opts = metaOptions(chip.key);
const cur = metaFilter[chip.key] || "";
return (
<div className={`chip-wrap${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
<button className={`chip${cur ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
<span className="chip-label">{cur || chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!cur ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{chip.label}
</div>
{opts.length > 0 && <div className="mi-sep" />}
{opts.map((v) => (
<div className={`mi${cur === v ? " selected" : ""}`} key={v} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{v}
</div>
))}
</div>
</article>
);
})}
</div>
);
})}
<span className="spacer"></span>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "最近添加" : "最早添加"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
</div>
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{isBatchTab ? (
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
)
) : isBatchTab ? (
batches.length ? (
<div className="packs-grid" id="batch-grid">
{batches.map((batch) => {
const isSel = selectedBatches.has(batch.batch_id);
// 编辑态:整卡点击 = 选中/取消该批次(走 lib-bulk-bar 批量删);非编辑态:点开批次弹窗
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
return (
<article className={`pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
<div className="placeholder asset-thumb pack-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="pack-count mono">{batch.count} </span>
</div>
<div className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono"></div></div>
</article>
);
})}
</div>
<>
<div className="lib-grid" id="batch-grid">
{batches.map((batch) => {
const isSel = selectedBatches.has(batch.batch_id);
const onBatchClick = editMode ? () => toggleBatch(batch.batch_id) : () => setOpenBatch(batch);
return (
<article className={`lib-card pack-card batch-card${editMode && isSel ? " selected" : ""}`} key={batch.batch_id} onClick={onBatchClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBatchClick(); } }}>
<span className="lib-check" aria-hidden="true"><Check /></span>
<div className="lib-thumb">
{batch.cover ? <img src={batch.cover} alt={batch.name} loading="lazy" /> : <span className="ph-frame"></span>}
<span className="lib-count">{batch.count} </span>
</div>
<div className="lib-meta">
<h3>{batch.name}</h3>
<p>{tabLabel} · {batch.count} </p>
<div className="lib-line">
<span className="lib-tag blue"></span>
<span className="lib-tag">{createdLabel(batch.created_at)}</span>
</div>
</div>
</article>
);
})}
</div>
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span></span></div>
)
) : isVideoCreationTab ? (
shown.length ? (
<div className="packs-grid" id="free-video-grid">
<>
<div className="lib-grid" id="free-video-grid">
{shown.map((asset) => {
const pack = freeVideoToPack(asset);
const first = pack.clips[0];
const isSelected = selected.has(asset.id);
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
return (
<article className={`lib-card asset-card pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
<span className="lib-check" aria-hidden="true"><Check /></span>
{onDeleteMany && (
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="lib-thumb portrait">
{pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : first?.url ? (
<video src={first.url} muted playsInline preload="metadata" />
) : (
<span className="ph-frame"></span>
)}
<span className="lib-count">1 </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="lib-meta">
<h3>{pack.project_name}</h3>
<p> · 1 </p>
<div className="lib-line">
<span className="lib-tag blue"></span>
<span className="lib-tag">{createdLabel(asset.created_at)}</span>
</div>
</div>
</article>
);
})}
</div>
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span></span></div>
)
) : shown.length ? (
<>
<div className="lib-grid" id="asset-grid">
{shown.map((asset) => {
const pack = freeVideoToPack(asset);
const first = pack.clips[0];
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
const isVideo = asset.asset_type === "video";
const isAudio = asset.asset_type === "audio";
const isSelected = selected.has(asset.id);
const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack);
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
const missingTri = personMissingTri(asset);
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
return (
<article className={`pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDeleteMany && (
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<article className={`lib-card asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
<span className="lib-check" aria-hidden="true"><Check /></span>
{onDeleteMany && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb pack-thumb">
{pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : (
<span className="ph-frame"></span>
<div className={`lib-thumb${isVideo ? " portrait" : ""}`} style={{ position: "relative" }}>
{missingTri && (
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono"></span>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body"> <b> / / </b> ,/</span>
<span className="pop-tip">建议:前往 <b></b> ,</span>
</span>
</span>
)}
<span className="pack-count mono">1 </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
{cover && isVideo && (
<>
<video src={cover} muted playsInline preload="metadata" />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
)}
{isAudio && (
<span className="lib-audio-ph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
<span className="mono">AUDIO</span>
</span>
)}
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
</div>
<div className="lib-meta">
<h3>{asset.name}</h3>
<p>{catLabel} · {srcLabel}</p>
<div className="lib-line">
<span className="lib-tag blue">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>
<span className="lib-tag">{createdLabel(asset.created_at) || (asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : srcLabel)}</span>
</div>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono"></div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : shown.length ? (
<div className="asset-grid" id="asset-grid">
{shown.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
const isVideo = asset.asset_type === "video";
const isAudio = asset.asset_type === "audio";
const isSelected = selected.has(asset.id);
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
const missingTri = personMissingTri(asset);
// 编辑态:整卡点击 = 多选;非编辑态:整卡点击 = 开详情
const onCardClick = editMode ? () => toggleSelect(asset.id) : () => setDetail(asset);
return (
<article className={`asset-card ${asset.asset_type}${isSelected ? " selected" : ""}`} key={asset.id} onClick={onCardClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDeleteMany && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb" style={{ position: "relative" }}>
{missingTri && (
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(e) => e.stopPropagation()}>
<span className="ico" aria-hidden="true"></span>
<span className="lbl-mono"></span>
<span className="tri-missing-pop" role="tooltip">
<span className="pop-h">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
MISSING TRI-VIEW
</span>
<span className="pop-body"> <b> / / </b> ,/</span>
<span className="pop-tip">建议:前往 <b></b> ,</span>
</span>
</span>
)}
{cover && isVideo && (
<>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
)}
{isAudio && (
// 音频没有可视封面,preview_url 是音频文件本身,塞 <img> 必裂图 → 用默认音符占位
<span className="lib-audio-ph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
<span className="mono">AUDIO</span>
</span>
)}
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
</div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{catLabel} · {srcLabel}{exportedAt && <span className="asset-meta-time"> · {exportedAt}</span>}</div></div>
</article>
);
})}
</div>
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span></span></div>
)}
<Pager page={curPage} total={total} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
</>
)}
<div className={`lib-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="lib-sel-copy">
<strong> {selCount} </strong>
<span></span>
</div>
<div className="lib-sel-actions">
<button className="lib-sel-cancel" type="button" onClick={exitEdit}></button>
<button
className="lib-sel-confirm"
type="button"
disabled={selCount === 0}
onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}
>
</button>
</div>
</div>
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
{openPack && createPortal(
@@ -970,7 +1004,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
<div className="pack-modal-h">
<div>
<h2>{openPack.project_name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
@@ -997,7 +1031,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
<div className="pack-modal-h">
<div>
<h2>{openBatch.name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 生成批次 · {openBatch.count} 张</span>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}> · {openBatch.count} </span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenBatch(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
@@ -1033,18 +1067,6 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
document.body
)}
{/* 编辑模式浮动批量操作栏(scope 到资产库;删除/清空/完成) */}
<div className={`lib-bulk-bar${selCount > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selCount}</b> {isBatchTab || isVideoPackTab ? "批" : "项"}</span>
<button className="clear-sel" type="button" onClick={clearSelection}></button>
<span className="sep" />
<button className="danger" type="button" disabled={selCount === 0} onClick={() => { const ids = bulkDeleteIds(); if (ids.length) setConfirmIds(ids); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
<button type="button" onClick={exitEdit}></button>
</div>
<AssetDetailModal
asset={detail}
close={() => setDetail(null)}
@@ -1056,7 +1078,7 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
<ConfirmModal
open={Boolean(confirmIds && confirmIds.length)}
title="删除资产"
subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />}
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
confirmText="删除"
+230 -108
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bell, BellOff, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
import { ArrowLeft, Bell, BellOff, CheckCheck, Clapperboard, CreditCard, Info, Search, Settings2, Users } from "lucide-react";
import { api } from "../api";
import type { Notification, NotificationTypeCounts } from "../types";
import type { Page } from "./route-config";
@@ -10,22 +10,46 @@ type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
const PAGE_SIZE = 10; // 每次滚到底加载一批
const ZERO_COUNTS: NotificationTypeCounts = { all: 0, unread: 0, task: 0, team: 0, billing: 0, system: 0 };
const TYPE_TABS = new Set<TabKey>(["task", "team", "billing", "system"]);
// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
function tabParams(tab: TabKey): { type?: string; unread?: boolean } {
if (tab === "unread") return { unread: true };
if (TYPE_TABS.has(tab)) return { type: tab };
return {};
}
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
const MUTE_TYPES = ["task", "team", "billing", "system"] as const;
function mutePrefKey(type: string): string {
return `mute-${type}`;
}
function isMutedFlag(value: unknown): boolean {
return value === true || value === "true" || value === 1 || value === "1";
}
function mutedFromNotify(notify: Record<string, boolean> | undefined): Set<string> {
const next = new Set<string>();
for (const type of MUTE_TYPES) {
if (isMutedFlag(notify?.[mutePrefKey(type)])) next.add(type);
}
return next;
}
function notifyFromMuted(types: Set<string>): Record<string, boolean> {
const notify: Record<string, boolean> = {};
for (const type of MUTE_TYPES) notify[mutePrefKey(type)] = types.has(type);
return notify;
}
// tab → 服务端查询参数(tab/未读/搜索全部走后端,滚动逐页拉)
// 静音只从「全部 / 未读」里藏掉;点进该类仍可查看,不会因此取消静音
function tabParams(tab: TabKey, mutedTypes: Set<string>): { type?: string; unread?: boolean; excludeTypes?: string[] } {
const excludeTypes = [...mutedTypes].filter((type) => type !== tab);
if (tab === "unread") return { unread: true, excludeTypes };
if (TYPE_TABS.has(tab)) return { type: tab, excludeTypes };
return { excludeTypes };
}
function typeIcon(type: string): ReactNode {
if (type === "task") return <Clapperboard size={14} />;
if (type === "team") return <Users size={14} />;
if (type === "billing") return <CreditCard size={14} />;
return <Info size={14} />;
if (type === "task") return <Clapperboard size={16} />;
if (type === "team") return <Users size={16} />;
if (type === "billing") return <CreditCard size={16} />;
return <Info size={16} />;
}
// 通知 related_url(.html 风格)→ 应用内 Page
@@ -120,8 +144,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const [selectedId, setSelectedId] = useState("");
// 静音同类:纯前端本地态(记被静音的 notification_type),命中的消息从列表里隐藏;不落后端
// 静音同类:记被静音的 notification_type,请求带 exclude_type,服务端不再返回该类
const [mutedTypes, setMutedTypes] = useState<Set<string>>(() => new Set());
const [prefsReady, setPrefsReady] = useState(false);
const [lastMutedType, setLastMutedType] = useState("");
const persistQueue = useRef(Promise.resolve());
// 查看日志:记当前展开日志的消息 id(单条展开),切换其它消息自动收起
const [showLogId, setShowLogId] = useState<string>("");
// 轻量本地 toast(复用 design-restraint 的 .toast 组件;本页没有全局 toast 注入,故就地渲染)
@@ -146,6 +173,30 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
useEffect(() => () => { if (toastTimer.current) clearTimeout(toastTimer.current); }, []);
useEffect(() => {
let cancelled = false;
void api.preferences()
.then((pref) => {
if (cancelled) return;
const next = mutedFromNotify(pref.notify);
setMutedTypes(next);
setLastMutedType([...next][0] || "");
})
.catch(() => undefined)
.finally(() => { if (!cancelled) setPrefsReady(true); });
return () => { cancelled = true; };
}, []);
function persistMutedTypes(types: Set<string>) {
const notify = notifyFromMuted(types);
persistQueue.current = persistQueue.current
.then(() => api.updatePreferences({ notify }))
.then(() => undefined)
.catch(() => undefined);
}
const mutedKey = [...mutedTypes].sort().join(",");
function showToast(title: string, sub: string) {
setToast({ title, sub });
if (toastTimer.current) clearTimeout(toastTimer.current);
@@ -161,10 +212,18 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
setLoading(true);
try {
const res = await api
.listNotifications({ ...tabParams(tab), search: debounced || undefined, page: pageToLoad, pageSize: PAGE_SIZE })
.listNotifications({
...tabParams(tab, new Set(mutedKey ? mutedKey.split(",") : [])),
search: debounced || undefined,
page: pageToLoad,
pageSize: PAGE_SIZE,
})
.catch(() => null);
if (gen !== genRef.current) return; // tab/搜索已切换,丢弃过期结果
if (!res) return;
if (!res) {
if (!replace) setHasMore(false);
return;
}
setItems((prev) => (replace ? res.results : [...prev, ...res.results]));
setHasMore(Boolean(res.next));
setPage(pageToLoad + 1);
@@ -177,16 +236,17 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
}
}
},
[tab, debounced]
[tab, debounced, mutedKey]
);
// tab / 搜索变化 → 清空重拉第 1 页
// tab / 搜索 / 静音变化 → 清空重拉第 1 页(等偏好就绪,避免先拉全量再排除)
useEffect(() => {
if (!prefsReady) return;
setItems([]);
setSelectedId("");
setHasMore(false);
void load(1, true);
}, [load]);
}, [load, prefsReady]);
// 滚到接近底部就拉下一批
const onScroll = useCallback(() => {
@@ -201,9 +261,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
if (el && hasMore && !loadingRef.current && el.scrollHeight <= el.clientHeight) void load(page, false);
}, [items, hasMore, page, load]);
// 静音同类只在前端隐藏(不打后端);其余照常分页/搜索
const visibleItems = mutedTypes.size === 0 ? items : items.filter((n) => !mutedTypes.has(n.notification_type));
const selected = visibleItems.find((n) => n.id === selectedId) || visibleItems[0] || null;
const selected = items.find((n) => n.id === selectedId) || items[0] || null;
// 标记单条已读:同步后端/侧边栏徽标 + 本地乐观更新(列表与未读计数)
function markOne(id: string) {
@@ -240,15 +298,33 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
showToast("已归档", n.title);
}
// 静音同类:把该 notification_type 记进本地静音集,列表即时隐藏同类;再点对应 chip / 此处可恢复
// 静音同类:写入 mute-* 偏好;再点同一按钮或对应分类即可恢复
function unmuteType(type: string) {
if (!type || !mutedTypes.has(type)) return;
const next = new Set(mutedTypes);
next.delete(type);
setMutedTypes(next);
setLastMutedType((cur) => (cur === type ? "" : cur));
persistMutedTypes(next);
showToast("已取消静音", `${ZH_TYPE[type] || type} 类提醒已恢复显示`);
}
function muteType(n: Notification) {
setMutedTypes((prev) => {
const next = new Set(prev);
next.add(n.notification_type);
return next;
});
setSelectedId(""); // 当前条会被隐藏,落回首条
showToast("已静音同类", `${ZH_TYPE[n.notification_type] || n.notification_type} 类提醒已隐藏 · 可在通知设置恢复`);
const type = n.notification_type;
if (!type) return;
if (mutedTypes.has(type) || (lastMutedType && mutedTypes.has(lastMutedType))) {
unmuteType(mutedTypes.has(type) ? type : lastMutedType);
return;
}
const next = new Set(mutedTypes);
next.add(type);
setMutedTypes(next);
setLastMutedType(type);
persistMutedTypes(next);
setSelectedId("");
if (tab === type) setTab("all");
const label = ZH_TYPE[type] || type;
showToast("已静音同类", `${label} 类已从「全部」隐藏 · 点「${label}」仍可查看,点取消静音才恢复`);
}
// 详情底栏派生动作的统一分发
@@ -279,91 +355,136 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
const detailActions = selected ? inferActions(selected) : [];
// 「标为已读」与派生的「我已了解」(ack)同义,二者择一,避免底栏重复
const hasAck = detailActions.some((a) => a.kind === "ack");
const bootLoading = !prefsReady || (loading && items.length === 0);
const tabMuted = TYPE_TABS.has(tab) && mutedTypes.has(tab);
const unmuteTarget = (selected && mutedTypes.has(selected.notification_type) && selected.notification_type)
|| (tabMuted ? tab : "")
|| (lastMutedType && mutedTypes.has(lastMutedType) ? lastMutedType : "");
return (
<div className="msg-page">
<div className="page-head">
<button className="msg-back" type="button" onClick={() => navigate("dashboard")} aria-label="返回">
<ArrowLeft size={18} strokeWidth={1.75} />
</button>
<div>
<h1></h1>
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {counts.all} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
<div className="sub">{counts.unread} · {counts.all} · </div>
</div>
<div className="msg-head-actions">
<button className="btn" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}></button>
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}></button>
<button className="msg-ghost" type="button" onClick={() => void markAll()} disabled={counts.unread === 0}>
<CheckCheck size={18} />
</button>
<button className="msg-ghost" type="button" onClick={() => navigate("settingsNotify")}>
<Settings2 size={18} />
</button>
</div>
</div>
<div className="msg-workbench">
<section className="msg-panel msg-inbox">
<div className="msg-panel-h"><span className="ti"></span><span className="mono">// 显示 {visibleItems.length} 条{total > visibleItems.length ? ` / ${total}` : ""}</span></div>
<div className="msg-filters">
{filters.map(([id, label, ct]) => (
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
{label}<span className="ct">{ct}</span>
</button>
))}
</div>
<div className="msg-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
<section className="msg-inbox">
<div className="msg-toolbar">
<div className="msg-filters">
{filters.map(([id, label, ct]) => {
const muted = TYPE_TABS.has(id) && mutedTypes.has(id);
return (
<button
key={id}
className={`msg-filter${tab === id ? " active" : ""}${muted ? " is-muted" : ""}`}
type="button"
onClick={() => setTab(id)}
>
{label} {ct}{muted ? " · 静音" : ""}
</button>
);
})}
</div>
<label className="msg-search">
<Search size={16} />
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源或内容" />
</label>
</div>
<div className="msg-list" ref={listRef} onScroll={onScroll}>
{visibleItems.length === 0 && !loading ? (
<div className="msg-empty"><Search /><span></span></div>
{bootLoading ? (
<div className="msg-skel" aria-busy="true" aria-live="polite">
{Array.from({ length: 7 }, (_, i) => (
<div className="msg-skel-row" key={i}>
<span className="msg-skel-ico" />
<div className="msg-skel-copy">
<span className="msg-skel-bar" />
<span className="msg-skel-bar msg-skel-bar-sm" />
</div>
<span className="msg-skel-time" />
</div>
))}
</div>
) : (
<>
{visibleItems.map((n) => (
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
<span className="msg-item-main">
<span className="msg-item-row">
<span className="msg-dot"></span>
<span className="msg-item-title">{n.title}</span>
{tabMuted && (
<div className="msg-mute-note">
<span> · </span>
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => unmuteType(tab)}></button>
</div>
)}
{items.length === 0 ? (
<div className="msg-empty"><Search /><span></span></div>
) : (
<>
{items.map((n) => (
<button key={n.id} className={`msg-item${selected?.id === n.id ? " active" : ""}${n.is_read ? " read" : ""}`} type="button" onClick={() => selectItem(n)}>
<div className={`msg-item-icon ${n.notification_type}`}>{typeIcon(n.notification_type)}</div>
<div>
<strong>{n.title}</strong>
<p>{n.brief}</p>
</div>
<span className="msg-time">{fmtTime(n.created_at)}</span>
</span>
<span className="msg-brief">{n.brief}</span>
<span className="msg-item-foot">
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
</span>
</span>
</button>
))}
{(loading || hasMore) && <div className="msg-load-more mono">{loading ? "// 加载中…" : "// 滚动加载更多"}</div>}
{!loading && !hasMore && visibleItems.length > 0 && <div className="msg-load-more mono">// 已全部加载</div>}
</button>
))}
{(loading || hasMore) && (
<div className="msg-load-more">
{loading ? <span className="msg-skel-more" aria-label="加载中"><span /><span /><span /></span> : "滚动加载更多"}
</div>
)}
{!loading && !hasMore && items.length > 0 && <div className="msg-load-more"></div>}
</>
)}
</>
)}
</div>
</section>
<section className="msg-panel msg-detail">
{selected ? (
<section className="msg-detail">
{bootLoading ? (
<div className="msg-detail-skel" aria-busy="true">
<span className="msg-skel-bar msg-skel-title" />
<span className="msg-skel-bar msg-skel-lead" />
<span className="msg-skel-bar msg-skel-lead-2" />
<div className="msg-skel-summary">
{Array.from({ length: 6 }, (_, i) => (
<div className="msg-skel-kv" key={i}>
<span className="msg-skel-bar msg-skel-k" />
<span className="msg-skel-bar msg-skel-v" />
</div>
))}
</div>
</div>
) : selected ? (
<>
<div className="msg-detail-body">
<div className="msg-detail-top">
<span className={`msg-type-ic ${selected.notification_type}`}>{typeIcon(selected.notification_type)}</span>
<div className="msg-detail-title">
<h2>{selected.title}</h2>
<div className="meta"><span>{selected.source || ZH_TYPE[selected.notification_type]}</span><span>// {ZH_TYPE[selected.notification_type]}</span><span>{fmtFull(selected.created_at)}</span></div>
</div>
<span className={`msg-priority ${selected.priority}`}>{PRI_LABEL[selected.priority] || "更新"}</span>
</div>
<p className="msg-body-text">{selected.body || selected.brief}</p>
<div className="msg-props">
{([
["来源", selected.source || "-"],
["类别", ZH_TYPE[selected.notification_type] || selected.notification_type],
["项目", selected.project_name || "-"],
["阶段", selected.stage || "-"],
["负责人", selected.owner_label || "-"],
["费用", selected.cost_label || "-"],
["时间", fmtFull(selected.created_at)]
] as Array<[string, string]>).flatMap(([k, v]) => [
<span className="k" key={`${k}-k`}>{k}</span>,
<span className="v" key={`${k}-v`}>{v}</span>
])}
<span className="k"></span>
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} </a></span>
<h2>{selected.title}</h2>
<p>{selected.body || selected.brief}</p>
<div className="msg-summary">
<span></span><strong>{selected.source || ZH_TYPE[selected.notification_type] || "—"}</strong>
<span></span><strong>{ZH_TYPE[selected.notification_type] || "—"}</strong>
<span></span><strong>{selected.project_name || selected.project || "—"}</strong>
<span></span><strong>{selected.stage || ""}</strong>
<span></span><strong>{selected.owner_label || "—"}</strong>
<span></span><strong>{selected.cost_label || "—"}</strong>
<span></span><strong>{fmtFull(selected.created_at)}</strong>
<span></span>
{selected.related_url
? <button className="msg-related" type="button" onClick={() => navigate(target)}>{routeLabels[target]} </button>
: <strong></strong>}
</div>
{(() => {
const timeline = readTimeline(selected.metadata);
@@ -379,47 +500,48 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive
})()}
{showLogId === selected.id && (() => {
const log = readLog(selected.metadata);
// 后端暂未下发 log 字段 → 壳照出,内容缺位时给占位,不造假日志
return log
? <pre className="msg-log">{log}</pre>
: <pre className="msg-log msg-log-empty">// 暂无日志详情</pre>;
: <pre className="msg-log msg-log-empty"></pre>;
})()}
</div>
<div className="msg-detail-f">
<button className="btn btn-ghost" type="button" onClick={() => archiveOne(selected)}></button>
<button className="btn btn-ghost" type="button" onClick={() => muteType(selected)}>
<BellOff size={14} />
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => archiveOne(selected)}></button>
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => (unmuteTarget ? unmuteType(unmuteTarget) : muteType(selected))}>
<BellOff size={16} />{unmuteTarget ? "取消静音" : "静音同类"}
</button>
{!selected.is_read && !hasAck && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}></button>}
<span className="spacer"></span>
{!selected.is_read && !hasAck && <button className="msg-ghost msg-ghost-sm" type="button" onClick={() => markOne(selected.id)}></button>}
<span className="spacer" />
{detailActions.map((a) => (
<button
key={a.id}
className={`btn ${a.primary ? "btn-primary" : ""} ${a.kind === "log" && showLogId === selected.id ? "is-active" : ""}`}
className={`${a.primary ? "msg-primary" : "msg-ghost msg-ghost-sm"}${a.kind === "log" && showLogId === selected.id ? " is-active" : ""}`}
type="button"
onClick={() => runAction(selected, a)}
>
{/* nav 主操作未带文案时,用解析出的目标页名兜底 */}
{a.label || `进入${routeLabels[target]}`}
</button>
))}
</div>
</>
) : (
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div></div></div>
<div className="msg-detail-empty">
<div className="msg-detail-empty-inner">
<div className="ic">{tabMuted ? <BellOff /> : <Bell />}</div>
<div>{tabMuted ? `已静音${ZH_TYPE[tab] || ""}` : "暂无消息"}</div>
{tabMuted && (
<button className="msg-ghost msg-ghost-sm" type="button" onClick={() => unmuteType(tab)}></button>
)}
</div>
</div>
)}
</section>
</div>
<div className="msg-foot-note">
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
<a onClick={() => navigate("settingsNotify")}> </a>
</div>
{toast && (
<div className="toast show" role="status" aria-live="polite">
<div className="ic-t"><Info size={13} aria-hidden="true" /></div>
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
<div className="txt">{toast.title}<span className="mono">{toast.sub}</span></div>
</div>
)}
</div>
+154 -79
View File
@@ -1,21 +1,21 @@
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { RefreshCw, Upload, User, X } from "lucide-react";
import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react";
import { api } from "../api";
import { generationErrorText } from "../generation-error";
import type { ModelEntity } from "../types";
import { SystemLoading } from "../components/loading";
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
import "../models-page.css";
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
type Tab = "all" | "official" | "mine";
const TABS: { k: Tab; label: string }[] = [
{ k: "all", label: "全部" },
{ k: "official", label: "官方模板" },
{ k: "mine", label: "我的模特" }
const TABS: { k: Tab; label: string; title: string; note: string }[] = [
{ k: "all", label: "全部", title: "全部模特", note: "官方模板与团队自建模特都可以在这里复用" },
{ k: "official", label: "官方模板", title: "官方模特", note: "官方模特均为平台导入的授权素材" },
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
];
type Preview = { src: string; kind: "image"; name: string };
@@ -193,7 +193,7 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
<div className="model-detail-section-h">
<strong></strong>
<div className="model-detail-section-actions">
<span className="mono">// PORTRAIT</span>
<span className="mono">PORTRAIT</span>
{!model.is_official && (
<button className="btn btn-sm" type="button" disabled={saving} onClick={() => portraitFileRef.current?.click()}>
<Upload size={13} />{pendingPortrait ? "重新选择" : model.portrait ? "替换形象图" : "上传形象图"}
@@ -223,7 +223,7 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
<div className="model-detail-section-h">
<strong></strong>
<div className="model-detail-section-actions">
<span className="mono">// 可选资产</span>
<span className="mono"></span>
{!model.is_official && (
<button className="btn btn-sm" type="button" disabled={!canGenerateTriview} title={triviewTitle} onClick={() => void generateTriview()}>
{triviewBusy ? <span className="spinner btn-spin" aria-hidden="true" /> : <RefreshCw size={13} />}
@@ -242,9 +242,9 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
>
{!model.triview && (triviewBusy
? <span className="ph-frame mono"><span className="spinner" aria-hidden="true" /> </span>
: <span className="ph-frame mono">// 暂无三视图 · 模特仍可正常使用</span>)}
: <span className="ph-frame mono"> · 使</span>)}
</div>
{triviewStatus && <div className="field-hint mono">// {triviewStatus}</div>}
{triviewStatus && <div className="field-hint mono">{triviewStatus}</div>}
</div>
<div className="model-detail-form">
<div className="field">
@@ -255,12 +255,12 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
<label className="field-label" htmlFor="model-edit-description"> / </label>
<textarea id="model-edit-description" className="textarea" rows={4} disabled={model.is_official || saving} placeholder="补充模特特征、风格或生成提示词" value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
<div className="field-hint mono">// 编辑模特资料不会回写已存在的视频项目角色</div>
<div className="field-hint mono"></div>
</div>
</div>
</div>
<div className="modal-f">
{model.is_official && <span className="model-detail-readonly mono">// 官方模板仅供查看</span>}
{model.is_official && <span className="model-detail-readonly mono"></span>}
<button className="btn" type="button" disabled={saving} onClick={dismiss}></button>
{!model.is_official && <button className="btn btn-primary" type="button" disabled={saving || !name.trim() || !isDirty} onClick={() => void save()}>{saving ? "保存中…" : "保存修改"}</button>}
</div>
@@ -280,10 +280,19 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
const [items, setItems] = useState<ModelEntity[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
const [detail, setDetail] = useState<ModelEntity | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
useEffect(() => {
let alive = true;
@@ -296,6 +305,8 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
return () => { alive = false; };
}, [tab]);
useEffect(() => { setSelected(new Set()); }, [tab]);
async function onPick(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
@@ -312,98 +323,162 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
}
}
async function remove(id: string) {
try {
await api.deleteModel(id);
setItems((list) => list.filter((m) => m.id !== id));
onNotify?.("success", "已移至垃圾桶");
} catch (error) {
onNotify?.("error", error instanceof Error ? error.message : "删除失败");
} finally {
setConfirmId(null);
async function remove(ids: string[]) {
const failed = new Set<string>();
for (const id of ids) {
try {
await api.deleteModel(id);
} catch {
failed.add(id);
}
}
const removed = ids.filter((id) => !failed.has(id));
if (removed.length) {
setItems((list) => list.filter((m) => !removed.includes(m.id)));
setSelected((prev) => new Set([...prev].filter((id) => !removed.includes(id))));
onNotify?.("success", removed.length > 1 ? `已移至垃圾桶 · ${removed.length}` : "已移至垃圾桶");
}
if (failed.size) onNotify?.("error", "部分模特删除失败");
setConfirmIds(null);
}
return (
<div className="models-page">
<div className="page-head">
<section className={`models-page${editMode ? " manage-mode" : ""}`}>
<header className="ml-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 团队级可复用 · 形象图 + 三视图</span>
<span>·</span>
<span></span>
</div>
<p></p>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? "上传中…" : "真人上传"}
<div className="ml-actions">
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理模特"}</span>
</button>
<button className="ml-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
<UserPlus />
<span>{uploading ? "上传中…" : "添加模特"}</span>
</button>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
</div>
</header>
<div className="ml-toolbar">
<div className="ml-seg" role="tablist" aria-label="模特来源">
{TABS.map((t) => (
<button key={t.k} type="button" role="tab" aria-selected={tab === t.k} className={`ml-seg-btn${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
</div>
<span className="ml-note"></span>
</div>
<div className="tabs">
{TABS.map((t) => (
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
{t.label}
</button>
))}
<div className="ml-section">
<h2>{tabCopy.title}</h2>
<p>{tabCopy.note}</p>
</div>
{loading ? (
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
) : items.length === 0 ? (
<div className="placeholder models-empty">
<span className="ph-frame">// 还没有模特</span>
<span className="models-empty-hint mono">,/</span>
<div className="ml-empty" role="status">
<UserPlus />
<strong></strong>
<span>/</span>
</div>
) : (
<div className="models-grid">
{items.map((m) => (
<div
className="model-card"
key={m.id}
role="button"
tabIndex={0}
onClick={() => setDetail(m)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
>
<div
className={`placeholder model-portrait${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
<div className="ml-grid">
{items.map((m) => {
const selectable = !m.is_official;
const isSelected = selected.has(m.id);
const onCardClick = () => {
if (editMode) {
if (selectable) toggleSelect(m.id);
return;
}
setDetail(m);
};
return (
<article
className={`ml-card model-card${selectable ? " selectable" : ""}${isSelected ? " selected" : ""}`}
key={m.id}
role="button"
tabIndex={0}
aria-pressed={editMode && selectable ? isSelected : undefined}
onClick={onCardClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}
>
{!m.portrait && <span className="ph-frame"></span>}
{m.is_official && <span className="pill info model-official"></span>}
{!m.is_official &&
(confirmId === m.id ? (
<div className="model-confirm" onClick={(e) => e.stopPropagation()}>
<button className="btn btn-sm model-del" type="button" onClick={() => void remove(m.id)}></button>
<button className="btn btn-sm" type="button" onClick={() => setConfirmId(null)}></button>
</div>
) : (
<button className="model-del-btn" type="button" title="删除模特" onClick={(e) => { e.stopPropagation(); setConfirmId(m.id); }}>×</button>
))}
</div>
<div className="model-body">
<div className="model-name" title={m.name}>{m.name}</div>
{selectable && <span className="ml-check" aria-hidden="true"><Check /></span>}
{selectable && (
<button
className="card-del-btn"
type="button"
title="删除模特"
onClick={(e) => { e.stopPropagation(); setConfirmIds([m.id]); }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
className={`placeholder ml-thumb${m.portrait ? " has-mock-media" : ""}`}
style={m.portrait ? mediaStyle(m.portrait) : undefined}
>
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
{!m.portrait && <span className="ph-frame"></span>}
</div>
<div className="model-tags mono">
<span>{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
<span>·</span>
<span>{m.triview ? "三视图 ✓" : "三视图 —"}</span>
<div className="ml-meta">
<h3 title={m.name}>{m.name}</h3>
<p>{m.description?.trim() || (m.source === "upload" ? "真人上传的人物参考" : "AI 生成的人物参考")}</p>
<div
className={`placeholder ml-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame"></span>}
</div>
<div className="ml-line">
{m.is_official ? (
<>
<span className="ml-tag green"></span>
<span className="ml-tag"></span>
</>
) : (
<>
<span className="ml-tag blue"></span>
<span className={`ml-tag${m.triview ? " green" : ""}`}>{m.triview ? "资料完整" : "待补三视图"}</span>
</>
)}
<span className="ml-tag">{m.source === "upload" ? "真人上传" : "AI 生成"}</span>
</div>
</div>
</div>
</div>
))}
</article>
);
})}
</div>
)}
<div className={`ml-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="ml-sel-copy">
<strong> {selected.size} </strong>
<span></span>
</div>
<div className="ml-sel-actions">
<button className="ml-sel-cancel" type="button" onClick={exitEdit}></button>
<button className="ml-sel-confirm" type="button" disabled={selected.size === 0} onClick={() => selected.size && setConfirmIds(Array.from(selected))}>
</button>
</div>
</div>
<ConfirmModal
open={Boolean(confirmIds && confirmIds.length)}
title="删除模特"
icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个模特?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={() => void remove(confirmIds || [])}
/>
<ModelDetailModal
model={detail}
close={() => setDetail(null)}
@@ -416,6 +491,6 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
onBillingChanged={onBillingChanged}
/>
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
+166 -143
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { ArrowLeft, Trash2, X } from "lucide-react";
import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
import { ProductCreateDrawer, PC_CAT_OPTIONS } from "../components/product-create-drawer";
@@ -68,6 +68,21 @@ function resolveCoverUrl(product: Product): string {
return product.cover_preview_url || firstImage?.preview_url || "";
}
function createdLabel(iso?: string): string {
if (!iso) return "未知日期";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso.slice(0, 10);
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startThat = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const diffDays = Math.round((startToday.getTime() - startThat.getTime()) / 86400000);
if (diffDays === 0) return "今天创建";
if (diffDays === 1) return "昨天创建";
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${mm}.${dd} 创建`;
}
export function ProductsPage({ products, projects = [], loading = false, navigate, openProduct, onCreate, onUploadImage, onDelete, onDeleteMany, autoOpenCreate = false }: {
products: Product[];
projects?: Project[];
@@ -147,17 +162,24 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
{ value: "90", label: "近 90 天" }
];
const dateLabel = DATE_OPTS.find((o) => o.value === dateFilter)?.label || "创建时间";
const typeLabel = typeFilter === "all" ? "全部类目" : BUSINESS_TYPES[typeFilter];
const catLabel = catFilter.size === 0
? "全部分类"
: catFilter.size === 1
? Array.from(catFilter)[0]
: `已选 ${catFilter.size}`;
const hasFilters = Boolean(query || catFilter.size > 0 || dateFilter !== "all" || typeFilter !== "all");
// /products/new 进入时自动打开新建商品 drawer
useEffect(() => {
if (autoOpenCreate) setDrawer(true);
}, [autoOpenCreate]);
// 点击 chip 外部关闭下拉
// 点击下拉外部关闭
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
if (!(event.target as HTMLElement).closest(".pl-select")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
@@ -184,143 +206,141 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
return (
<section className="products-page">
<div className="page-head">
<section className={`products-page${editMode ? " manage-mode" : ""}`}>
<header className="pl-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// <span id="sku-count">{products.length}</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
<p></p>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="edit-toggle-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
<div className="pl-actions">
<button className={`pl-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理商品"}</span>
</button>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
<button className="pl-primary" type="button" onClick={() => setDrawer(true)}>
<PackagePlus />
<span></span>
</button>
</div>
</div>
</header>
<div className="products-main">
<div className="toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter === "all" ? "类目" : BUSINESS_TYPES[typeFilter]}</span>
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
<div className="pl-toolbar">
<label className="pl-search">
<Search />
<input id="search-input" placeholder="搜索商品名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<div className="pl-toolbar-right">
<div className={`pl-select${openChip === "type" ? " open" : ""}`} data-key="type">
<button className="pl-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "type"} onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span>{typeLabel}</span>
<ChevronDown />
</button>
<div className="chip-menu">
<div className={`mi${typeFilter === "all" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter("all"); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className="mi-sep" />
<div className="pl-select-menu" role="listbox">
<button className={`pl-select-option${typeFilter === "all" ? " selected" : ""}`} type="button" onClick={() => { setTypeFilter("all"); setOpenChip(""); }}></button>
{BUSINESS_TYPE_KEYS.map((key) => (
<div className={`mi${typeFilter === key ? " selected" : ""}`} key={key} role="button" tabIndex={0} onClick={() => { setTypeFilter(key); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{BUSINESS_TYPES[key]}
</div>
<button className={`pl-select-option${typeFilter === key ? " selected" : ""}`} type="button" key={key} onClick={() => { setTypeFilter(key); setOpenChip(""); }}>{BUSINESS_TYPES[key]}</button>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
<button className={`chip${catFilter.size ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
<span className="chip-label"></span>
{catFilter.size > 0 && <span className="chip-count">{catFilter.size}</span>}
<svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
<div className={`pl-select${openChip === "cat" ? " open" : ""}`} data-key="cat">
<button className="pl-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "cat"} onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
<span>{catLabel}</span>
<ChevronDown />
</button>
<div className="chip-menu">
{/* 全部分类 = 清空多选(不关菜单,方便连续多选) */}
<div className={`mi mi-all${catFilter.size === 0 ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => setCatFilter(new Set())}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{categories.length > 0 && <div className="mi-sep" />}
<div className="pl-select-menu" role="listbox">
<button className={`pl-select-option${catFilter.size === 0 ? " selected" : ""}`} type="button" onClick={() => setCatFilter(new Set())}></button>
{categories.map((cat) => (
<div className={`mi${catFilter.has(cat) ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => toggleCat(cat)}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
<span className="cat-count">{catCounts[cat] || 0}</span>
</div>
<button className={`pl-select-option${catFilter.has(cat) ? " selected" : ""}`} type="button" key={cat} onClick={() => toggleCat(cat)}>
{cat}
<span className="pl-cat-count">{catCounts[cat] || 0}</span>
</button>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "date" ? " open" : ""}`} data-key="date">
<button className={`chip${dateFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "date" ? "" : "date"))}>
<span className="chip-label">{dateFilter === "all" ? "创建时间" : dateLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
<div className={`pl-select${openChip === "date" ? " open" : ""}`} data-key="date">
<button className="pl-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "date"} onClick={() => setOpenChip((c) => (c === "date" ? "" : "date"))}>
<span>{dateFilter === "all" ? "创建时间" : dateLabel}</span>
<ChevronDown />
</button>
<div className="chip-menu">
<div className="pl-select-menu" role="listbox">
{DATE_OPTS.map((opt) => (
<div className={`mi${dateFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
<button className={`pl-select-option${dateFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>{opt.label}</button>
))}
</div>
</div>
{(query || catFilter.size > 0 || dateFilter !== "all" || typeFilter !== "all") && (
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
{hasFilters && (
<button className="pl-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
<X size={13} />
</button>
)}
</div>
<div className="result-meta" id="result-meta">
<span>// {loading && products.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</>}</span>
<div className="view-tog" style={{ marginLeft: "auto" }} id="view-tog" role="group" aria-label="视图切换">
<button type="button" className={view === "grid" ? "active" : ""} aria-pressed={view === "grid"} title="网格视图" onClick={() => setView("grid")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
<div className="pl-view" role="group" aria-label="视图切换">
<button type="button" className={`pl-view-btn${view === "grid" ? " active" : ""}`} aria-pressed={view === "grid"} title="网格视图" onClick={() => setView("grid")}>
<Grid2X2 />
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-pressed={view === "list"} title="列表视图" onClick={() => setView("list")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
<button type="button" className={`pl-view-btn${view === "list" ? " active" : ""}`} aria-pressed={view === "list"} title="列表视图" onClick={() => setView("list")}>
<List />
</button>
</div>
</div>
{loading && products.length === 0 ? (
<SkeletonGrid count={8} />
) : (
<div className="product-grid-wrap">
<div className={`product-grid${view === "list" ? " list-view" : ""}`} id="product-grid">
{pageItems.map((product) => (
<ProductCard
key={product.id}
product={product}
coverUrl={resolveCoverUrl(product)}
videoCount={projects.filter((p) => p.product === product.id).length}
editMode={editMode}
selected={selected.has(product.id)}
onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))}
onOpenVideos={() => openProduct(product.id, "videos")}
onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined}
/>
))}
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</div>
)}
</div>
{/* 编辑模式浮动操作条 */}
<div className={`bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}></button>
<span className="sep" />
<button
className="danger"
type="button"
disabled={selected.size === 0}
onClick={() => selected.size && setConfirmIds(Array.from(selected))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
<button type="button" onClick={exitEdit}>退</button>
<div className="pl-section">
<div>
<h2></h2>
<p></p>
</div>
</div>
{loading && products.length === 0 ? (
<SkeletonGrid count={8} />
) : pageItems.length === 0 ? (
<div className="pl-empty" role="status">
<PackageX />
<strong>{hasFilters || query ? "没有找到符合条件的商品" : "还没有商品"}</strong>
<span>{hasFilters || query ? "请调整商品名称、分类或创建时间后重试" : "点击右上角新建商品,开始整理卖点与参考图"}</span>
</div>
) : (
<>
<div className={`pl-grid${view === "list" ? " list" : ""}`} id="product-grid">
{pageItems.map((product) => (
<ProductCard
key={product.id}
product={product}
coverUrl={resolveCoverUrl(product)}
videoCount={projects.filter((p) => p.product === product.id).length}
editMode={editMode}
selected={selected.has(product.id)}
onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))}
onOpenVideos={() => openProduct(product.id, "videos")}
onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined}
/>
))}
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</>
)}
<div className={`pl-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="pl-sel-copy">
<strong> {selected.size} </strong>
<span></span>
</div>
<div className="pl-sel-actions">
<button className="pl-sel-cancel" type="button" onClick={exitEdit}></button>
<button
className="pl-sel-confirm"
type="button"
disabled={selected.size === 0}
onClick={() => selected.size && setConfirmIds(Array.from(selected))}
>
</button>
</div>
</div>
<ConfirmModal
open={Boolean(confirmIds && confirmIds.length)}
title="删除商品"
subtitle="// DELETE ITEM"
icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
confirmText="删除"
@@ -355,21 +375,28 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
}
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 }) {
// 优先显示真实上传/采用的主图;无真图时回退到按标题匹配的 mock 占位
const mock = productCover(product.title);
const assetCount = product.images?.length || 0;
// 缺三视图:采用三视图后端会把它落成 product.cover_asset(见 App.tsx onAdoptTriView)。
// 列表数据里无法逐条反查 tri_view 素材,只能用真实字段:既无采用封面(cover_asset)、又无上传主图(images)
// → 判定该商品尚未补三视图(对齐 V1「新建商品卡显缺三视图」);一旦有封面/图则判不准,不显示(别瞎标)。
const missingTri = !isLocalLife(product) && !product.cover_asset && assetCount === 0;
const sellingCount = product.selling_points?.length || 0;
const category = product.category || "未分类";
const projectTag = videoCount <= 0
? { label: "暂无项目", tone: "" }
: videoCount === 1
? { label: "1 个项目", tone: "blue" }
: { label: `${videoCount} 个项目`, tone: "green" };
return (
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete?.(); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
<article className={`pl-card product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<span className="pl-check" aria-hidden="true"><Check /></span>
{onDelete && (
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
{coverUrl ? (
<div className="product-thumb has-real-media"><img src={coverUrl} alt={product.title} loading="lazy" /></div>
<div className="pl-thumb"><img src={coverUrl} alt={product.title} loading="lazy" /></div>
) : (
<div className={`placeholder product-thumb${mock ? " has-mock-media" : ""}`} style={mock ? prodMock(mock) : undefined}>
<div className={`placeholder pl-thumb${mock ? " has-mock-media" : ""}`} style={mock ? prodMock(mock) : undefined}>
{missingTri && (
<span className="tri-missing-badge" tabIndex={0} role="button" aria-label="缺三视图,查看说明" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") event.stopPropagation(); }}>
<span className="ico" aria-hidden="true"></span>
@@ -387,26 +414,22 @@ export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, on
<span className="ph-frame">{product.title} · 1200×800</span>
</div>
)}
<div className="product-body">
<div className="product-name">{product.title}</div>
<div className="product-meta">
{isLocalLife(product) && <span className="pill neutral"></span>}
<div className="product-cat">{product.category || "未分类"}</div>
<div className="pl-meta">
<h3>{product.title}</h3>
<p>{isLocalLife(product) ? `本地生活 · ${category}` : category} · {sellingCount} </p>
<div className="pl-meta-line">
<button
className={`pl-tag${projectTag.tone ? ` ${projectTag.tone}` : ""}`}
type="button"
title="查看视频项目"
onClick={(event) => { event.stopPropagation(); onOpenVideos?.(); }}
>
{projectTag.label}
</button>
<span className="pl-tag">{createdLabel(product.created_at)}</span>
</div>
<div className="product-date">{(product.created_at || "").slice(0, 10)} </div>
</div>
<div className="product-footer">
<span className="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="10" r="2" /><path d="M21 17l-5-5-9 9" /></svg>
<b>{assetCount}</b>
</span>
<span className="sep">·</span>
<button className="stat stat-link" type="button" title="查看视频项目" onClick={(event) => { event.stopPropagation(); onOpenVideos?.(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg>
<b>{videoCount}</b>
</button>
</div>
</div>
</article>
);
}
@@ -453,7 +476,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 上传原图 + 填写基本信息</span> · 保存后可在工作台逐步丰富素材</div>
<div className="sub"><span className="mono"> + </span> · </div>
</div>
<div className="actions">
<button className="btn btn-ghost" type="button" onClick={onBack}><ArrowLeft size={14} /></button>
@@ -469,7 +492,7 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
<h3></h3>
<span className="req-tag"></span>
</div>
<div className="card-sub">// 1-5 张 · 这是后续所有 AI 生成的源材料</div>
<div className="card-sub">1-5 · AI </div>
<div className="photo-grid">
<div className="photo-slot photo-slot-add" role="button" tabIndex={0} title="上传图片">
<span className="plus">
@@ -552,8 +575,8 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo
<div className="form-foot">
<span className="req-info">
{ready
? <>// 必填检查:<span className="ok">已全部完成 ✓</span> · 可进入工作台</>
: <>// 必填检查:<span className="miss">{missing.join(" / ")}</span> 未完成</>}
? <>:<span className="ok"> </span> · </>
: <>:<span className="miss">{missing.join(" / ")}</span> </>}
</span>
<div className="foot-actions">
<button className="btn" type="button" onClick={onBack}></button>
@@ -953,10 +976,10 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triAdoptedId && triViewing?.id === triAdoptedId ? "已采用" : triUrl ? "已生成" : "待生成"}</span></div>
<div className="prod-preview-h"> · <span id="ov-tri-status">{triGenerating ? "生成中…" : triAdoptedId && triViewing?.id === triAdoptedId ? "已采用" : triUrl ? "已生成" : "待生成"}</span></div>
{/* 单张 16:9 三视图(.prod-preview-img 已 aspect-ratio:16/9) */}
<div className="placeholder prod-preview-img" id="ov-tri-img" role={triUrl ? "button" : undefined} tabIndex={triUrl ? 0 : undefined} title={triUrl ? "点击放大" : undefined} style={triUrl ? { cursor: "zoom-in" } : undefined} onClick={triUrl ? () => setPreview({ src: triUrl, name: "三视图" }) : undefined} onKeyDown={triUrl ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPreview({ src: triUrl, name: "三视图" }); } } : undefined}>
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "生成中,请稍候…" : "尚未生成 · 点击下方按钮开始"}</span>}
</div>
<div className="prod-preview-foot" id="ov-tri-foot">
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }} onClick={generateTriView} disabled={triGenerating || !onGenerateImages}>
@@ -975,7 +998,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
<div className={`prod-preview-history${triVersions.length ? " show" : ""}`} id="ov-tri-history">
<div className="h-lbl">// 历史版本 · <span className="ct" id="ov-tri-history-count">{triVersions.length}</span> 版</div>
<div className="h-lbl"> · <span className="ct" id="ov-tri-history-count">{triVersions.length}</span> </div>
<div className="h-row" id="ov-tri-history-row">
{triVersions.map((version, index) => {
const isViewing = version.id === triViewing?.id;
@@ -1160,7 +1183,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="ov-card ov-actions" ref={actionsRef} style={editing && actionsLockH ? { height: `${actionsLockH}px` } : undefined}>
<div className="ov-h"><span className="ti"></span></div>
<div className="qa-section">
<div className="qa-section-h">// 图片生成</div>
<div className="qa-section-h"></div>
<div className={isLocal ? "qa-row-2" : "qa-row-3"}>
{!isLocal && (
<div className="qa-item" data-go="model-photo" role="button" tabIndex={0} onClick={() => navigate("modelPhoto", { productId: product.id })}>
@@ -1179,7 +1202,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
</div>
</div>
<div className="qa-section">
<div className="qa-section-h">// 视频生成</div>
<div className="qa-section-h"></div>
<div className="qa-row-1">
<div className="qa-item primary" data-go="projects-new" role="button" tabIndex={0} onClick={() => navigate("projectWizard", { productId: product.id })}>
<span className="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg></span>
@@ -1213,7 +1236,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></svg>
</div>
<h3> AI </h3>
<p>// 用右侧「快速操作」生成{isLocal ? "平台套图 / 图片创作" : "模特上身图 / 平台套图 / 商品三视图"}</p>
<p>{isLocal ? "平台套图 / 图片创作" : "模特上身图 / 平台套图 / 商品三视图"}</p>
<button className="btn btn-primary" type="button" onClick={() => navigate("imageOptimize", { productId: product.id })}></button>
</div>
) : (
@@ -1233,7 +1256,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<span className="type-pill"></span>
{role.triview && <span className="pd-role-tri mono"></span>}
</div>
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "// 暂未引用"}</span></div>
<div className="meta pd-role-foot"><span className="mono">{role.ref_count > 0 ? `${role.ref_count} 个项目引用` : "暂未引用"}</span></div>
</div>
);
}
@@ -1323,7 +1346,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
<div className="pack-modal-h">
<div>
<h2>{openDetail.title}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openDetail.sub}</span>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{openDetail.sub}</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenDetail(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
+375 -394
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowUpRight, Grid2X2, List, Search } from "lucide-react";
import type { FormEvent } from "react";
import { ArrowLeft, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, Grid2X2, Info, List, PackagePlus, Play, Route, Search, Settings2, Trash2, VideoOff } from "lucide-react";
import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
import { ConfirmModal, EmptyPanel, MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
import { isLocalLife } from "../product-business";
import { Pager } from "../components/pager";
@@ -16,6 +16,13 @@ const PROJ_PAGE_SIZE = 8; // 4 列网格两行
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
const WIZ_RAIL = [
{ n: "01", title: "选择商品", desc: "确定本次内容生产的商品主体" },
{ n: "02", title: "脚本创建", desc: "生成并确认镜头脚本" },
{ n: "03", title: "资产选择", desc: "准备商品、角色与场景资产" },
{ n: "04", title: "故事板", desc: "生成并确认视频分镜画面" },
{ n: "05", title: "视频生成", desc: "按故事板生成视频片段" },
];
export function ProjectWizardPage({ products, projects = [], preselectProductId, onBack, onCreate, onCreateProduct, onUploadImage }: {
products: Product[];
@@ -44,7 +51,6 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
const [pickSearch, setPickSearch] = useState("");
const [pickCat, setPickCat] = useState("全部");
const [catOpen, setCatOpen] = useState(false);
const [pickView, setPickView] = useState<"grid" | "list">("grid");
const [pickPage, setPickPage] = useState(1);
// 新建商品 · 右侧抽屉开关(抽屉实现与商品库共用 ProductCreateDrawer,保证同一流程)
@@ -90,6 +96,14 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
[products]
);
useEffect(() => {
if (!catOpen) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".nw-select")) setCatOpen(false);
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [catOpen]);
// 筛选 + 排序后的商品
const filtered = useMemo(() => {
@@ -157,198 +171,201 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
}
}
// 商品封面图:用后端内嵌的 preview_url(cover_preview_url / images[].preview_url),不再反查全局 assets
const productCoverUrl = (p: Product): string => {
const sorted = [...(p.images || [])].sort((a, b) => a.sort_order - b.sort_order);
const primary = p.images?.find((img) => img.is_primary) || sorted[0];
return p.cover_preview_url || primary?.preview_url || "";
};
const imageCount = (p: Product) => p.images?.length || (p.cover_preview_url ? 1 : 0);
const pointCount = (p: Product) => p.selling_points?.length || 0;
const createdLabel = (iso: string) => {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
if (d.toDateString() === now.toDateString()) return "今天创建";
const yest = new Date(now);
yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "昨天创建";
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} 创建`;
};
const selectedCover = product ? productCoverUrl(product) : "";
const selectedMeta = product
? `${isLocalLife(product) ? "本地生活" : (product.category || "未分类")} · ${imageCount(product)} 张参考图`
: "尚未选择商品";
return (
<section className="project-wizard-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
<header className="nw-head">
<div className="nw-title">
<button className="nw-back" type="button" onClick={onBack} aria-label="返回视频创作">
<ArrowLeft />
</button>
<div>
<h1></h1>
<p>使</p>
</div>
</div>
<div className="actions">
<button className="btn btn-ghost" type="button" onClick={onBack}>退</button>
</div>
</div>
<div className="nw-status"><strong></strong><span>· </span></div>
</header>
<form className="wizard" onSubmit={submit}>
{/* ── 左侧步骤轨 ── */}
<nav className="steps" aria-label="新建项目步骤">
<div className={`step ${product1Done ? "done" : "active"}`}>
<div className="num">{product1Done ? (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
) : "1"}</div>
<form className="nw-shell" onSubmit={submit}>
<aside className="nw-rail" aria-label="项目进度">
<header className="nw-rail-head">
<span className="nw-rail-icon"><Route /></span>
<div>
<div className="label"></div>
<div className="desc">{product?.title || "未选择"}</div>
<h2></h2>
<p></p>
</div>
</header>
<div className="nw-rail-summary">
<span> 1 / 5 · 1 </span>
<div className="nw-rail-track"><span style={{ width: "20%" }} /></div>
</div>
<div className="nw-rail-steps">
{WIZ_RAIL.map((step, index) => (
<div className={`nw-step${index === 0 ? " current" : ""}`} key={step.n} hidden={index > 0}>
<span className="nw-step-mark">{index === 0 ? <Check /> : step.n}</span>
<span className="nw-step-copy">
<strong>{step.title}</strong>
<span>{step.desc}</span>
</span>
<ChevronRight className="nw-step-chev" />
</div>
))}
</div>
<div className="nw-picked">
<span></span>
<div className="nw-picked-card">
{selectedCover ? <img src={selectedCover} alt="" /> : <span className="nw-picked-ph" />}
<div>
<strong>{product?.title || "未选择"}</strong>
<small>{selectedMeta}</small>
</div>
</div>
</div>
<div className={`step ${config2Done ? "done" : product1Done ? "active" : ""}`}>
<div className="num">{config2Done ? (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>
) : "2"}</div>
</aside>
<section className="nw-surface" aria-labelledby="nwSelectTitle">
<div className="nw-select-head">
<div>
<div className="label"></div>
<div className="desc">{name.trim() || "未命名"}</div>
<h2 id="nwSelectTitle"></h2>
<p> SKU</p>
</div>
<span className="nw-count">{total} </span>
</div>
<div className="nw-toolbar">
<label className="nw-search">
<Search size={16} />
<input placeholder="搜索商品名称、分类或标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} onKeyDown={(event) => { if (event.key === "Enter") event.preventDefault(); }} />
</label>
<div className={`nw-select${catOpen ? " open" : ""}`}>
<button className="nw-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={catOpen} onClick={() => setCatOpen((open) => !open)}>
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
<ChevronDown size={15} />
</button>
<div className="nw-select-menu" role="listbox">
{cats.map((cat) => (
<button className={`nw-select-option${pickCat === cat ? " selected" : ""}`} type="button" key={cat} onClick={() => { setPickCat(cat); setPickPage(1); setCatOpen(false); }}>
{cat === "全部" ? "全部分类" : cat}
</button>
))}
</div>
</div>
</div>
</nav>
{/* ── 主体 ── */}
<div className="wiz-body">
{/* Step 1 · 商品选择 */}
<section className="step-pane-wrap">
<div className="wiz-pane">
<div className="wiz-step-h">
<h2> 1 · </h2>
<p> SKU LLM /</p>
<div className="nw-grid">
<button className="nw-card nw-create" type="button" onClick={openCreateProduct}>
<span className="nw-create-icon"><PackagePlus /></span>
<strong></strong>
<span> SKU</span>
</button>
{total === 0 ? (
<div className="nw-empty" role="status">
<strong>{products.length === 0 ? "还没有商品" : "没有符合条件的商品"}</strong>
<span>{products.length === 0 ? "先创建一个商品,再回来开项目" : "请调整分类或搜索关键词后重试"}</span>
{hasFilter && <button className="nw-empty-reset" type="button" onClick={clearPickFilters}></button>}
{products.length === 0 && <button className="nw-empty-reset" type="button" onClick={openCreateProduct}></button>}
</div>
) : pageList.map((item) => {
const cover = productCoverUrl(item);
const cat = isLocalLife(item) ? `本地生活${item.category ? " · " + item.category : ""}` : (item.category || "未分类");
return (
<button className={`nw-card${productId === item.id ? " active" : ""}`} type="button" key={item.id} onClick={() => selectProduct(item.id)}>
<span className="nw-check"><Check /></span>
<span className="nw-cover">{cover ? <img src={cover} alt="" loading="lazy" /> : null}</span>
<span className="nw-meta">
<h3>{item.title}</h3>
<p>{cat} · {pointCount(item)} </p>
<span className="nw-tags">
<span>{imageCount(item)} </span>
<span>{createdLabel(item.created_at)}</span>
</span>
</span>
</button>
);
})}
</div>
<div className="pp-toolbar">
<div className="search-inline">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input type="text" placeholder="搜索商品名称、标签" value={pickSearch} onChange={(event) => { setPickSearch(event.target.value); setPickPage(1); }} />
</div>
<div className={`pp-chip-wrap${catOpen ? " open" : ""}`}>
<button className={`pp-chip${pickCat !== "全部" ? " active" : ""}`} type="button" onClick={() => setCatOpen((open) => !open)}>
<span>{pickCat === "全部" ? "全部分类" : pickCat}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="pp-menu">
{cats.map((c) => (
<div className={`mi${pickCat === c ? " selected" : ""}`} key={c} onClick={() => { setPickCat(c); setPickPage(1); setCatOpen(false); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.6"><polyline points="3 8 7 12 13 4" /></svg>
<span>{c}</span>
</div>
))}
</div>
</div>
{hasFilter && (
<button className="pp-clear" type="button" onClick={clearPickFilters}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
)}
</div>
{total > WIZ_PAGE_SIZE && (
<Pager page={cur} total={total} pageSize={WIZ_PAGE_SIZE} onChange={setPickPage} />
)}
<div className="pp-result-meta">// 显示 {pageList.length} / {total} 个商品{hasFilter ? " (已筛选)" : ""}</div>
<div className={`pp-grid${pickView === "list" ? " list-view" : ""}`}>
<div className="pp-create-card" onClick={openCreateProduct}>
<div className="pc-plus"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg></div>
<div className="pc-t"></div>
<div className="pc-d">// 在此添加一个新商品</div>
</div>
{total === 0 ? (
<div className="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
) : (
pageList.map((p) => {
const coverUrl = productCoverUrl(p);
return (
<div className={`product-card${productId === p.id ? " selected" : ""}`} key={p.id} onClick={() => selectProduct(p.id)}>
{coverUrl ? (
<div className="product-thumb has-real-media"><img src={coverUrl} alt={p.title} loading="lazy" /></div>
) : (
<div className="placeholder product-thumb"><span className="ph-frame">{p.title} · 1200×800</span></div>
)}
<div className="product-body">
<div className="product-name">{p.title}</div>
<div className="product-cat">{isLocalLife(p) ? `本地生活${p.category ? " · " + p.category : ""}` : (p.category || "未分类")}</div>
<div className="product-date">{(p.created_at || "").slice(0, 10)} </div>
</div>
</div>
);
})
)}
</div>
{total > WIZ_PAGE_SIZE && (
<div className="pp-pager">
<span className="total"> {total} </span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => setPickPage(cur - 1)}></button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<button type="button" key={n} className={n === cur ? "active" : ""} onClick={() => setPickPage(n)}>{n}</button>
))}
<button type="button" disabled={cur === totalPages} onClick={() => setPickPage(cur + 1)}></button>
</div>
<span className="page-size"> {WIZ_PAGE_SIZE} </span>
</div>
)}
<div className="pp-bottom-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v5M12 16h.01" /></svg>
<span>?<a onClick={openCreateProduct}></a>, <a onClick={onBack}> · </a></span>
</div>
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={openCreateProduct} />}
<div className="nw-config">
<div className="nw-config-h">
<h2></h2>
<p></p>
</div>
</section>
{/* Step 2 · 项目配置 */}
<section className="step-pane-wrap">
<div className="wiz-pane">
<div className="wiz-step-h">
<h2> 2 · </h2>
<p>线 1 </p>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
{/* 5.2 套路模板 · 没存过模板就整块不出现,不给空下拉占位 */}
{templates.length > 0 && (
<div className="field">
<label className="field-label">()</label>
<select className="input" value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value=""> · AI </option>
{/* 默认模板名已含镜数,选项里只补时长与使用次数,避免「4 镜 · 4 镜」 */}
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name} · {t.total_duration}s{t.usage_count ? ` · 用过 ${t.usage_count}` : ""}</option>
))}
</select>
{/* 5.3 预期对齐:先讲清「同套路重出 ≠ 旧片换个商品名」 */}
{template && (
<div className="tpl-expect">
<div className="te-head"><span className="mono">// 同套路重出</span>沿用「{template.name}」的镜数、每镜作用和节奏</div>
<div className="te-outline">{template.outline_text}</div>
<div className="te-note"> {product?.title || "所选商品"} </div>
</div>
)}
</div>
)}
{Object.keys(points).length > 0 && (
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label">()</label>
<div className="theme-pill-row">
{Object.entries(points).map(([k, v]) => (
<button className={`theme-pill${v ? " active" : ""}`} type="button" key={k} aria-pressed={v} onClick={() => setPoints((prev) => ({ ...prev, [k]: !prev[k] }))}>
{v && <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg>}
<span>{k}</span>
</button>
))}
<label className="nw-field">
<span><small></small></span>
<input value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</label>
{templates.length > 0 && (
<label className="nw-field">
<span><small></small></span>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value=""> · AI </option>
{templates.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.usage_count}` : ""}</option>
))}
</select>
{template && (
<div className="nw-tpl">
<strong></strong>
<p>沿{template.name} {product?.title || "所选商品"} </p>
{template.outline_text ? <pre>{template.outline_text}</pre> : null}
</div>
)}
</label>
)}
{Object.keys(points).length > 0 && (
<div className="nw-field">
<span><small></small></span>
<div className="nw-pills">
{Object.entries(points).map(([key, on]) => (
<button className={`nw-pill${on ? " on" : ""}`} type="button" key={key} aria-pressed={on} onClick={() => setPoints((prev) => ({ ...prev, [key]: !prev[key] }))}>
{on && <Check size={13} />}
<span>{key}</span>
</button>
))}
</div>
)}
</div>
</section>
</div>
)}
</div>
{/* ── 底部「开始」CTA ── */}
<div className="wiz-start-bar">
<button className={`btn-start${canStart && !starting ? "" : " disabled"}`} type="submit" disabled={!canStart || starting}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
<span>{starting ? "创建中…" : "开始"}</span>
<div className="nw-actions">
<span className="nw-tip">
<Info />
<span></span>
</span>
<button className="nw-next" type="submit" disabled={!canStart || starting}>
<span>{starting ? "创建中…" : "确认商品并创建项目"}</span>
<ArrowRight />
</button>
</div>
</div>
</section>
</form>
{/* 新建商品 · 右侧抽屉 · 与商品库新建商品共用同一组件 → 落库到商品库,同一流程 */}
@@ -372,27 +389,12 @@ export function ProjectWizardPage({ products, projects = [], preselectProductId,
const PROJ_STAGE_TOTAL = 4;
const PROJ_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
function projStageNo(project: Project) { return project.status === "completed" ? PROJ_STAGE_TOTAL : (PROJ_STAGE_NO[project.current_stage] || 1); }
// 进度格 class:
// · 已完成 → 含末格在内全部 done(绿),末格不再用闪烁的 cur
// · 失败 → 停在第 no 格标红(fail),之前的 done
// · 进行中 → 已过的格 done,当前格 cur(一闪一闪),未到的格留空
function projProgClass(project: Project, i: number, no: number): string {
if (project.status === "completed") return i <= no ? "done" : "";
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
}
function projBucket(project: Project) {
if (project.status === "completed") return "done";
if (project.status === "failed") return "fail";
if (project.status === "draft") return "draft";
return "wip";
}
function projStatusLabel(project: Project) {
return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record<string, string>)[project.status] || "进行中";
}
// 失败态用现成 .pill.err(crimson),不存在 .pill.fail → 退化无色
function projPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
function projDate(project: Project) { return (project.updated_at || "").slice(0, 10); }
// 当前脚本版本:优先已采用,否则取第一版(轻量列表序列化可能不含 segments,故全程可选链)
function projScript(project: Project) {
const list = project.script_versions || [];
@@ -420,8 +422,6 @@ function projShotMeta(project: Project): string {
}
// 项目卡封面 = 该项目商品的主图(后端 cover_preview_url,取商品 cover_asset)。无图 → 占位。
// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。)
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "draft", label: "草稿" },
@@ -431,26 +431,24 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
];
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频。", page: "projectWizard", image: "/exact/assets/mock/person-linxi.png" },
{ title: "专业创作", desc: "控制脚本资产、故事板与生成。", page: "projectWizard", image: "/exact/assets/mock/scene-cafe.png" },
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
{ title: "极速成片", desc: "输入商品名称并上传图片,自动完成脚本、场景、故事板和视频生成。", page: "projectWizard", image: "/assets/yz/video-quick.jpg", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产、故事板和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词。", page: "freeCreate", image: "/exact/assets/mock/scene-night-street.png" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品。", page: "freeCreate", image: "/exact/assets/mock/cover-coffee.png" },
{ title: "自由生成", desc: "用提示词与参考视频自由生成镜头。", page: "freeCreate", image: "/exact/assets/mock/scene-bedroom.png" },
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "freeCreate", image: "/assets/yz/video-remix.jpg" },
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
];
function projCardSub(project: Project): string {
function projCardSub(project: Project, productTitle: string): string {
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
return projStageLabel(project);
const shots = projShotMeta(project);
const stage = projStageLabel(project);
if (project.status === "completed") return `专业创作 · ${productTitle} · ${shots}`;
const no = projStageNo(project);
return `专业创作 · ${stage} · ${no} / ${PROJ_STAGE_TOTAL}`;
}
function projCardCta(project: Project): string {
if (project.status === "failed") return "重新拍摄";
if (project.status === "completed") return "查看";
return "继续";
}
// 项目卡「当前阶段」文案(对齐 YZ 稿:资产创建 / 视频生成)
function projStageLabel(project: Project): string {
if (project.status === "failed") return "失败";
const map: Record<string, string> = {
@@ -462,13 +460,6 @@ function projStageLabel(project: Project): string {
};
return map[project.current_stage] || "视频生成";
}
function projCardMeta(project: Project, productTitle: string): string {
const shots = projShots(project);
return ["专业创作", productTitle, shots ? `${shots}` : null].filter(Boolean).join(" / ");
}
function projRowCta(project: Project): string {
return project.status === "completed" ? "查看" : "继续";
}
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: {
products: Product[];
@@ -496,11 +487,8 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [bulkConfirm, setBulkConfirm] = useState(false);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
useEffect(() => { if (!editMode) setSelected(new Set()); }, [editMode]);
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
@@ -529,7 +517,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
if (!(event.target as HTMLElement).closest(".vc-select")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
@@ -577,25 +565,35 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
}
return (
<section className="projects-page">
<section className={`projects-page${editMode ? " manage-mode" : ""}`}>
<header className="vc-head">
<h1></h1>
<p></p>
<div>
<h1></h1>
<p></p>
</div>
<div className="vc-actions">
<button className={`vc-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理项目"}</span>
</button>
</div>
</header>
<section className="vc-sec">
<div className="vc-sec-h">
<h2></h2>
<p></p>
<p></p>
</div>
<div className="vc-row cols-2">
<div className="vc-tools two">
{VIDEO_MAKE.map((card) => (
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-visual"><img src={card.image} alt="" /></div>
<div className="vc-copy">
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
<button className={`vc-tool${card.primary ? " primary" : ""}`} type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
<div className="vc-tool-body">
<div className="vc-tool-title">
<h3>{card.title}</h3>
<ArrowUpRight size={21} />
</div>
<p>{card.desc}</p>
</div>
</button>
))}
@@ -605,204 +603,187 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<section className="vc-sec">
<div className="vc-sec-h">
<h2>AI </h2>
<p></p>
<p></p>
</div>
<div className="vc-row cols-3">
<div className="vc-tools three">
{VIDEO_TOOLS.map((card) => (
<button className="vc-card" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-visual"><img src={card.image} alt="" /></div>
<div className="vc-copy">
<span className="vc-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
<button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
<div className="vc-tool-body">
<div className="vc-tool-title">
<h3>{card.title}</h3>
<ArrowUpRight size={21} />
</div>
<p>{card.desc}</p>
</div>
</button>
))}
</div>
</section>
<section className="vc-projects">
<div className="vc-sec-h">
<h2></h2>
<p></p>
</div>
<div className="vc-sec-h vc-list-h">
<h2></h2>
<p></p>
</div>
<div className="vc-toolbar">
<div className="vc-pills" role="tablist" aria-label="项目状态">
{PROJ_TABS.map((t) => (
<button
type="button"
role="tab"
aria-selected={tab === t.filter}
className={`vc-pill${tab === t.filter ? " active" : ""}`}
key={t.filter}
onClick={() => setTab(t.filter)}
>
{t.label}
</button>
))}
<div className="vc-toolbar">
<div className="vc-seg" role="tablist" aria-label="项目状态">
{PROJ_TABS.map((t) => (
<button
type="button"
role="tab"
aria-selected={tab === t.filter}
className={`vc-seg-btn${tab === t.filter ? " active" : ""}`}
key={t.filter}
onClick={() => setTab(t.filter)}
>
{t.label}
</button>
))}
</div>
<div className="vc-toolbar-right">
<label className="vc-search">
<Search size={16} />
<input placeholder="搜索视频项目" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<div className={`vc-select${openChip === "time" ? " open" : ""}`} data-key="time">
<button className="vc-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "time"} onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span>{timeLabel}</span>
<ChevronDown size={15} />
</button>
<div className="vc-select-menu" role="listbox">
{TIME_OPTS.map((opt) => (
<button className={`vc-select-option${timeFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>{opt.label}</button>
))}
</div>
</div>
<div className="vc-toolbar-right">
<div className="vc-search">
<Search size={14} />
<input placeholder="搜索项目名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{timeLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{catFilter || "全部类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{projectCategories.length > 0 && <div className="mi-sep" />}
{projectCategories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
</div>
))}
</div>
</div>
<div className="vc-view">
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
<Grid2X2 size={14} />
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
<List size={14} />
</button>
<div className={`vc-select${openChip === "product" ? " open" : ""}`} data-key="product">
<button className="vc-select-trigger" type="button" aria-haspopup="listbox" aria-expanded={openChip === "product"} onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span>{catFilter || "商品分类"}</span>
<ChevronDown size={15} />
</button>
<div className="vc-select-menu" role="listbox">
<button className={`vc-select-option${!catFilter ? " selected" : ""}`} type="button" onClick={() => { setCatFilter(""); setOpenChip(""); }}></button>
{projectCategories.map((cat) => (
<button className={`vc-select-option${catFilter === cat ? " selected" : ""}`} type="button" key={cat} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>{cat}</button>
))}
</div>
</div>
<div className="vc-view">
<button type="button" className={`vc-view-btn${view === "grid" ? " active" : ""}`} aria-label="网格显示" onClick={() => setView("grid")}>
<Grid2X2 size={16} />
</button>
<button type="button" className={`vc-view-btn${view === "list" ? " active" : ""}`} aria-label="列表显示" onClick={() => setView("list")}>
<List size={16} />
</button>
</div>
</div>
</div>
{loading && projects.length === 0 ? (
<SkeletonRows count={6} />
) : view === "grid" ? (
<div className="vc-grid">
{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const bucket = projBucket(project);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
className={`vc-proj${editMode && isSel ? " selected" : ""}`}
key={project.id}
role="button"
tabIndex={0}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
>
<div className={`vc-proj-visual${cover ? " has-img" : ""}`}>
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
</div>
<div className="vc-proj-body">
<div className="vc-proj-name">{project.name}</div>
<div className="vc-proj-sub">{projCardSub(project)}</div>
<div className="vc-proj-foot">
<span className={`vc-tag ${bucket}`}>{bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中"}</span>
<button
className="vc-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projCardCta(project)}
</button>
</div>
</div>
</article>
);
})}
) : filtered.length === 0 ? (
<div className="vc-empty" role="status">
<VideoOff />
<strong>{projects.length === 0 ? "还没有视频项目" : "没有符合条件的视频项目"}</strong>
<span>{projects.length === 0 ? "从上方入口开始生成" : "请调整状态、时间、分类或搜索关键词后重试"}</span>
</div>
) : (
<div className="proj-card-list" id="list-view">
{pageItems.map((project) => {
const no = projStageNo(project);
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
key={project.id}
className={`proj-card-row${editMode && isSel ? " selected" : ""}`}
data-status={projBucket(project)}
data-name={project.name}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
{editMode && (
<span className={`row-check${isSel ? " on" : ""}`} aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
)}
<div className={`placeholder proj-card-row-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="proj-card-row-main">
<div className="proj-card-row-title">{project.name}</div>
<div className="proj-card-row-sub">{projCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="proj-card-row-stage">
<div className="proj-card-row-stage-lbl"></div>
<div className="proj-card-row-stage-name">{projStageLabel(project)}</div>
<div className="prog proj-prog-yz">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
</div>
{!editMode && (
<>
<div className={`vc-grid${view === "list" ? " list" : ""}`}>
{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const bucket = projBucket(project);
const no = projStageNo(project);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else openPipeline(project.id);
};
const statusLabel = bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中";
return (
<article
className={`vc-card project-card${isSel ? " selected" : ""}`}
key={project.id}
role="button"
tabIndex={0}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
>
<span className="vc-check" aria-hidden="true"><Check /></span>
<button
className="proj-card-row-cta"
className="card-del-btn"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
title="删除项目"
onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}
>
{projRowCta(project)}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
{!editMode && (
<span className="row-more proj-card-row-more" onClick={(event) => event.stopPropagation()}>
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button></div>
</span>
)}
</article>
);
})}
</div>
<div className="vc-thumb">
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
{!editMode && project.final_video_url ? (
<button
className="vc-play"
type="button"
aria-label={`播放「${project.name}」成片`}
onClick={(event) => { event.stopPropagation(); setPlaying(project); }}
>
<Play />
</button>
) : null}
</div>
<div className="vc-meta">
<h3>{project.name}</h3>
<p>{projCardSub(project, productTitle(project.product))}</p>
<div className="vc-line">
<span className={`vc-tag ${bucket}`}>{statusLabel}</span>
<span className="vc-tag type"></span>
</div>
{bucket === "wip" && (
<div className="vc-progress" aria-hidden="true"><span style={{ width: `${Math.round((no / PROJ_STAGE_TOTAL) * 100)}%` }} /></div>
)}
</div>
</article>
);
})}
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
</>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && !loading && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
</section>
<div className={`bulk-bar${selected.size > 0 ? " show" : ""}`} role="toolbar" aria-label="批量管理">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" disabled={selected.size === 0} onClick={() => setSelected(new Set())}></button>
<span className="sep" />
<button className="danger" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => setBulkConfirm(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
{bulkDeleting ? "删除中…" : "删除所选"}
</button>
<button type="button" onClick={() => setEditMode(false)}></button>
<div className={`vc-sel-bar${editMode ? " active" : ""}`} role="toolbar" aria-label="批量操作">
<div className="vc-sel-copy">
<strong> {selected.size} </strong>
<span></span>
</div>
<div className="vc-sel-actions">
<button className="vc-sel-cancel" type="button" onClick={exitEdit}></button>
<button className="vc-sel-confirm" type="button" disabled={selected.size === 0 || bulkDeleting} onClick={() => selected.size && setBulkConfirm(true)}>
{bulkDeleting ? "删除中…" : "确认删除"}
</button>
</div>
</div>
<MediaLightbox open={Boolean(playing)} src={playing?.final_video_url || ""} kind="video" name={`${playing?.name || ""} · 成片`} close={() => setPlaying(null)} />
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
<ConfirmModal open={bulkConfirm} title="确认批量删除" detail={`即将删除选中的 ${selected.size} 个项目。`} confirmText="删除" onCancel={() => setBulkConfirm(false)} onConfirm={confirmBulkDelete} />
<ConfirmModal
open={Boolean(deleteTarget)}
title="删除项目"
icon={<Trash2 size={16} />}
detail={`确定删除「${deleteTarget?.name || ""}」?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
confirmText="删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={confirmDelete}
/>
<ConfirmModal
open={bulkConfirm}
title="删除项目"
icon={<Trash2 size={16} />}
detail={`确定删除选中的 ${selected.size} 个项目?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
confirmText="删除"
onCancel={() => setBulkConfirm(false)}
onConfirm={confirmBulkDelete}
/>
</section>
);
}
+4 -4
View File
@@ -72,9 +72,9 @@ export const mainNav: NavItem[] = [
{ page: "dashboard", label: "工作台", icon: Home },
{ page: "products", label: "商品库", icon: Package },
{ page: "models", label: "模特库", icon: UserRound },
{ page: "projects", label: "视频项目", icon: FolderKanban },
{ page: "projects", label: "视频创作", icon: FolderKanban },
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
{ page: "library", label: "资产库", icon: Library },
{ page: "library", label: "成品库", icon: Library },
{ page: "account", label: "账户", icon: Wallet }
];
@@ -92,10 +92,10 @@ export const routeLabels: Record<Page, string> = {
productDetail: "商品详情",
productCreateUpload: "上传创建商品",
models: "模特库",
projects: "视频项目",
projects: "视频创作",
projectWizard: "新建视频项目",
pipeline: "生产管线",
library: "资产库",
library: "成品库",
account: "账户",
team: "团队",
messages: "消息",
+157 -170
View File
@@ -4,7 +4,7 @@ import {
Bell,
KeyRound,
LogOut,
ShieldCheck,
Shield,
Upload,
User as UserIcon,
} from "lucide-react";
@@ -16,32 +16,38 @@ type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode }> }> = [
{
group: "个人",
items: [
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
{ key: "security", label: "安全", icon: <ShieldCheck /> },
{ key: "notify", label: "通知", icon: <Bell /> },
],
},
// 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
{ key: "security", label: "安全", icon: <Shield /> },
{ key: "notify", label: "通知", icon: <Bell /> },
];
function SettingRow({ title, hint, stack, children }: { title: string; hint?: string; stack?: boolean; children: ReactNode }) {
return (
<div className={`st-row${stack ? " st-row-stack" : ""}`}>
<div className="st-copy">
<strong>{title}</strong>
{hint ? <span>{hint}</span> : null}
</div>
<div className="st-val">{children}</div>
</div>
);
}
const TEMPLATE_CHOICES = [
{ v: "pain", t: "痛点种草", d: "// 30s 默认档" },
{ v: "unbox", t: "开箱测评", d: "// 45s 默认档" },
{ v: "compare", t: "对比展示", d: "// 45s 默认档" },
{ v: "howto", t: "教程演示", d: "// 60s 默认档" },
{ v: "drama", t: "剧情带货", d: "// 60s 默认档" },
{ v: "pain", t: "痛点种草", d: "30s 默认档" },
{ v: "unbox", t: "开箱测评", d: "45s 默认档" },
{ v: "compare", t: "对比展示", d: "45s 默认档" },
{ v: "howto", t: "教程演示", d: "60s 默认档" },
{ v: "drama", t: "剧情带货", d: "60s 默认档" },
];
const SUBTITLE_CHOICES = [
{ v: "big-variety", t: "大字综艺", d: "// 抖音热门" },
{ v: "clean-ec", t: "简洁电商", d: "// 信息清晰" },
{ v: "premium", t: "高级排版", d: "// 居中衬线" },
{ v: "bullet", t: "弹幕轻量", d: "// 滚动出现" },
{ v: "emphasis", t: "强调爆款", d: "// 高对比" },
{ v: "big-variety", t: "大字综艺", d: "抖音热门" },
{ v: "clean-ec", t: "简洁电商", d: "信息清晰" },
{ v: "premium", t: "高级排版", d: "居中衬线" },
{ v: "bullet", t: "弹幕轻量", d: "滚动出现" },
{ v: "emphasis", t: "强调爆款", d: "高对比" },
];
const DURATIONS = ["30", "45", "60"];
@@ -56,12 +62,19 @@ function deviceName(ua: string): string {
// 仅保留已真实接入「站内通知」的行;邮箱/短信/异地登录告警渠道未接入,隐藏对应行。
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内" },
{ key: "n-export", title: "项目完成通知", sub: "视频导出后", channels: "站内" },
{ key: "n-fail", title: "任务失败告警", channels: "站内" },
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内" },
{ key: "n-quota", title: "额度不足提醒", sub: "团队或个人剩余 < 20%", channels: "站内" },
// n-login(异地登录告警·短信渠道)未接入,已移除。
];
const MUTE_TYPE_ROWS: Array<{ key: string; title: string; hint: string }> = [
{ key: "task", title: "任务提醒", hint: "关闭后消息中心不再显示此类" },
{ key: "team", title: "团队协作", hint: "关闭后消息中心不再显示此类" },
{ key: "billing", title: "计费通知", hint: "关闭后消息中心不再显示此类" },
{ key: "system", title: "系统公告", hint: "关闭后消息中心不再显示此类" },
];
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
const DEFAULT_PREFS = {
template: "pain",
@@ -117,7 +130,9 @@ const FIELD_SECTION: Record<keyof TrackedState, SectionKey> = {
};
function notifyEqual(a: Record<string, boolean>, b: Record<string, boolean>): boolean {
return NOTIFY_ROWS.every((row) => !!a[row.key] === !!b[row.key]);
const eventsMatch = NOTIFY_ROWS.every((row) => !!a[row.key] === !!b[row.key]);
const mutesMatch = MUTE_TYPE_ROWS.every((row) => !!a[`mute-${row.key}`] === !!b[`mute-${row.key}`]);
return eventsMatch && mutesMatch;
}
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
@@ -243,7 +258,7 @@ export function SettingsPage({
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
// ─── nav badge · 由 state 计算(通知开启数 / 设备数),不写死 ───
// ─── nav badge · 由真实会话 / 通知开关计算,接口已有,保留 ───
const notifyOnCount = useMemo(() => NOTIFY_ROWS.filter((row) => !!draft.notify[row.key]).length, [draft.notify]);
const navBadge = useCallback(
(key: SectionKey): string | null => {
@@ -388,174 +403,147 @@ export function SettingsPage({
</div>
<div className="settings-grid">
{/* 左侧 nav */}
<aside className="settings-nav" role="tablist" aria-label="设置分区">
{NAV.map((group, gi) => (
<div key={group.group}>
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
{group.items.map((item) => {
const badge = navBadge(item.key);
const dirty = dirtySections.has(item.key);
return (
<a
key={item.key}
href={`#sec-${item.key}`}
className={`${section === item.key ? "active" : ""}${dirty ? " has-changes" : ""}`}
role="tab"
aria-selected={section === item.key}
onClick={(event) => {
event.preventDefault();
setSection(item.key);
}}
>
{item.icon}
<span>{item.label}</span>
{badge ? <span className="nav-badge">{badge}</span> : null}
<span className="nav-dot" aria-hidden="true" />
</a>
);
})}
</div>
))}
<div className="nav-h" style={{ marginTop: 16 }}></div>
{NAV.map((item) => {
const badge = navBadge(item.key);
const dirty = dirtySections.has(item.key);
return (
<button
key={item.key}
type="button"
className={`st-tab${section === item.key ? " active" : ""}${dirty ? " has-changes" : ""}`}
role="tab"
aria-selected={section === item.key}
onClick={() => setSection(item.key)}
>
{item.icon}
<span>{item.label}</span>
{badge ? <span className="nav-badge">{badge}</span> : null}
<span className="nav-dot" aria-hidden="true" />
</button>
);
})}
<div className="nav-h"></div>
<button className="logout-pill" type="button" onClick={() => setModal("logout")}>
<LogOut />
<span>退</span>
</button>
</aside>
{/* 右侧内容 */}
<main>
<main className="settings-content">
{section === "profile" && (
<section className="pane" aria-label="个人信息">
<h3></h3>
<div className="pane-desc">// 头像、姓名、联系方式</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<div className="avatar-edit">
<div className="av-big">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={openAvatarModal}></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onResetAvatar?.()}></button>
</div>
</div>
<SettingRow title="头像" hint="支持上传 JPG、PNG 图片">
<div className="avatar-edit">
<div className="av-big">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatarChar}</div>
<button className="st-ghost" type="button" onClick={openAvatarModal}></button>
<button className="st-text" type="button" onClick={() => onResetAvatar?.()}></button>
</div>
</div>
<div className="form-row">
<div className="lbl"><span className="req">*</span></div>
<div className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", event.target.value)} /></div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 仅做记录用</div></div>
<div className="val">
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
{/* 邮件服务未接入,验证功能暂不可用,入口已隐藏 */}
<span className="switch-note">// 邮件验证未启用</span>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" value={phone} onChange={(event) => patchDraft("phone", event.target.value)} placeholder="138****8000" />
<span className="switch-note">// 在「保存所有更改」中一并提交</span>
</div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 一人一团队</div></div>
<div className="val">
<span className="static">{team.name}</span>
<span className="role-tag"><span className="dot" /> · </span>
</SettingRow>
<SettingRow title="显示名称" hint="用于团队和项目协作中展示">
<input className="st-input" value={name} onChange={(event) => patchDraft("name", event.target.value)} />
</SettingRow>
<SettingRow title="登录邮箱" hint="仅做记录用,不用于登录">
<input
className="st-input"
type="email"
value={email}
placeholder="邮箱验证未启用"
onChange={(event) => patchDraft("email", event.target.value)}
/>
</SettingRow>
<SettingRow title="手机号" hint="保存所有更改时一并提交">
<input
className="st-input"
value={phone}
onChange={(event) => patchDraft("phone", event.target.value)}
placeholder="138****8000"
/>
</SettingRow>
<SettingRow title="所属团队" hint="一人一团队">
<div className="st-team">
<strong>{team.name} · / </strong>
<a
href="/team"
className="row-link"
className="st-text"
onClick={(event) => {
event.preventDefault();
window.history.pushState(null, "", "/team");
window.dispatchEvent(new PopStateEvent("popstate"));
}}
>
</a>
</div>
</div>
<div className="form-row">
<div className="lbl"> ID<div className="lbl-sub">// 不可改</div></div>
<div className="val"><span className="static mono">{user.id}</span></div>
</div>
</SettingRow>
<SettingRow title="用户 ID" hint="不可修改">
<span className="st-muted">{user.id}</span>
</SettingRow>
</section>
)}
{section === "security" && (
<section className="pane" aria-label="安全">
<h3></h3>
<div className="pane-desc">// 登录密码、双因素、在用设备</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<span className="static mono"></span>
<span className="row-note" style={{ marginLeft: "auto" }}> 2026-04-12</span>
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }} onClick={openPasswordModal}></button>
<SettingRow title="登录密码" hint="上次修改 2026-04-12">
<div className="st-password">
<span className="st-dots"></span>
<button className="st-ghost" type="button" onClick={openPasswordModal}></button>
</div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 推荐开启</div></div>
<div className="val">
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
<span className="switch-note"> + Authenticator</span>
</SettingRow>
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
</SettingRow>
<SettingRow stack title="在用设备" hint="真实登录会话 · 每次登录记录设备 UA / IP">
<div className="device-list">
{sessions.length === 0 ? (
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}></div></div>
) : (
sessions.map((s) => {
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
return (
<div className="device-row" key={s.id}>
<div className="ic">
{isPhone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">{s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
</div>
<div className="spacer" />
{s.is_current
? <span className="row-note"></span>
: <button className="st-ghost st-ghost-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>线</button>}
</div>
);
})
)}
</div>
</div>
<h3 className="sub-head"></h3>
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</div>
<div className="device-list">
{sessions.length === 0 ? (
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}>// 暂无其他登录会话记录</div></div>
) : (
sessions.map((s) => {
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
return (
<div className="device-row" key={s.id}>
<div className="ic">
{isPhone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">// {s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
</div>
<div className="spacer" />
{s.is_current
? <span className="row-note"></span>
: <button className="btn btn-ghost btn-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>线</button>}
</div>
);
})
)}
</div>
<div style={{ marginTop: 14 }}>
<button className="btn" type="button" onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>线</button>
</div>
<button className="st-ghost" type="button" style={{ marginTop: 16 }} onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>
线
</button>
</SettingRow>
</section>
)}
{section === "notify" && (
<section className="pane" aria-label="通知">
<h3></h3>
<div className="pane-desc">// 站内通知开关 · 邮件/短信渠道未接入</div>
{NOTIFY_ROWS.map((row) => (
<div className="form-row" key={row.key}>
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
<div className="val">
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
<span className="switch-note">{row.channels}</span>
</div>
</div>
<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 })} />
</SettingRow>
))}
<div className="pane-desc" style={{ margin: "18px 0 4px" }}></div>
{MUTE_TYPE_ROWS.map((row) => (
<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 })}
/>
</SettingRow>
))}
</section>
)}
@@ -563,7 +551,7 @@ export function SettingsPage({
{section === "pref" && (
<section className="pane" aria-label="创作默认">
<h3></h3>
<div className="pane-desc">// 新建项目时的预填值,可在向导中改</div>
<div className="pane-desc">,</div>
<div className="form-row row-top">
<div className="lbl"></div>
@@ -600,7 +588,7 @@ export function SettingsPage({
</span>
))}
</div>
<span className="switch-note" style={{ marginLeft: 10 }}>// 60s = 4 段 × 15s</span>
<span className="switch-note" style={{ marginLeft: 10 }}>60s = 4 × 15s</span>
</div>
</div>
<div className="form-row row-top">
@@ -645,7 +633,7 @@ export function SettingsPage({
</div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// VIP 可关闭</div></div>
<div className="lbl"><div className="lbl-sub">VIP </div></div>
<div className="val">
<Switch checked disabled />
<span className="switch-note"> · Airshelf</span>
@@ -658,7 +646,7 @@ export function SettingsPage({
{section === "display" && (
<section className="pane" aria-label="显示">
<h3></h3>
<div className="pane-desc">// 界面外观与语言</div>
<div className="pane-desc"></div>
<div className="form-row">
<div className="lbl"></div>
@@ -692,7 +680,6 @@ export function SettingsPage({
</section>
)}
<div className="settings-foot">// Airshelf · v2.1 · build 20260521</div>
</main>
</div>
@@ -700,7 +687,7 @@ export function SettingsPage({
<TeamModal
open={modal === "avatar"}
title="上传头像"
subtitle="// 用于个人主页、评论与团队展示"
subtitle="用于个人主页、评论与团队展示"
icon={<Upload size={16} />}
close={() => setModal("")}
footer={
@@ -716,7 +703,7 @@ export function SettingsPage({
</div>
<div className="av-up-preview-meta">
<div className="t">{avatarFile ? avatarFile.name : "当前头像 · 默认"}</div>
<div className="d">{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}</div>
<div className="d">{avatarFile ? `${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "系统生成 · 取姓氏首字"}</div>
</div>
</div>
@@ -757,7 +744,7 @@ export function SettingsPage({
<TeamModal
open={modal === "password"}
title="修改登录密码"
subtitle="// CHANGE PASSWORD"
icon={<KeyRound size={16} />}
close={() => setModal("")}
footer={
@@ -793,7 +780,7 @@ export function SettingsPage({
/>
{pwTooShort || (pwSubmitted && newPassword.length < 8)
? <span className="field-hint pw-err"> 8 </span>
: <span className="field-hint">// 建议混合字母、数字与符号</span>}
: <span className="field-hint"></span>}
</div>
</TeamModal>
@@ -801,7 +788,7 @@ export function SettingsPage({
<TeamModal
open={modal === "logout"}
title="退出当前账号"
subtitle="// LOG OUT CURRENT SESSION"
icon={<LogOut size={16} />}
close={() => setModal("")}
footer={
+270 -189
View File
@@ -1,14 +1,34 @@
import { useEffect, useState } from "react";
import { CheckCircle2, CircleDollarSign, Copy, KeyRound, RefreshCw, UserPlus } from "lucide-react";
import { CheckCircle2, CircleDollarSign, Copy, Gauge, KeyRound, RefreshCw, Search, UserPlus } from "lucide-react";
import { api } from "../api";
import type { BillingSummary, Invitation, Notification, Team, TeamMember, User } from "../types";
import type { BillingSummary, BillingTrend, Invitation, Notification, Team, TeamMember, User } from "../types";
import type { Page } from "./route-config";
import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays";
import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10;
type TrendRange = "day" | "week" | "month";
const TREND_CHIPS: Array<{ key: TrendRange; label: string }> = [
{ key: "day", label: "日" },
{ key: "week", label: "周" },
{ key: "month", label: "月" },
];
const STAGE_SPEND: Array<{ key: keyof BillingTrend["by_stage"]; label: string }> = [
{ key: "video", label: "视频生成" },
{ key: "storyboard", label: "故事板" },
{ key: "base", label: "基础资产" },
{ key: "script", label: "脚本生成" },
];
// 登录地址必须是用户当前真实访问的域名(与邀请链接同源),写死的 airshelf.com 不存在 → PMC#16
function shortTs(iso?: string) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const LOGIN_URL = `${location.origin}/login`;
const LIMIT_PRESETS = [1000, 3000, 5000, 10000, -1];
@@ -67,15 +87,15 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
{ cap: "团队充值", cells: ["✓", "—", "—"] },
{ cap: "设置月限额", cells: ["✓", "—", "—"] },
{ cap: "编辑别人项目", cells: ["✓", "✓", "—"] },
{ cap: "团队共享资产库管理", cells: ["✓", "✓", "仅自传"] },
{ cap: "团队共享成品库管理", cells: ["✓", "✓", "仅自传"] },
{ cap: "查看团队消费明细", cells: ["✓", "✓", "仅自己"] },
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
];
// 角色双卡(创建/编辑共用):成员 / 团管
const ROLE_CARDS: Array<{ value: string; title: string; desc: string }> = [
{ value: "member", title: "成员", desc: "// 创建项目 + 用资产" },
{ value: "admin", title: "团管", desc: "// 管成员 + 改额度" }
{ value: "member", title: "成员", desc: "创建项目 + 用资产" },
{ value: "admin", title: "团管", desc: "管成员 + 改额度" }
];
export function TeamPage({ team, user, billing, navigate, onCreateMember: onCreateMemberRaw, onUpdateMember: onUpdateMemberRaw, onRemoveMember: onRemoveMemberRaw, onResetPassword, onRecharge: onRechargeRaw }: {
@@ -119,6 +139,14 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
}, []);
const [trendRange, setTrendRange] = useState<TrendRange>("day");
const [trend, setTrend] = useState<BillingTrend | null>(null);
useEffect(() => {
let alive = true;
api.billingTrend(trendRange).then((d) => { if (alive) setTrend(d); }).catch(() => {});
return () => { alive = false; };
}, [trendRange]);
// 创建账户表单
const [cuUser, setCuUser] = useState("");
const [cuPass, setCuPass] = useState("");
@@ -221,6 +249,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
const memberCurPage = Math.min(memberPage, memberTotalPages);
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
const daily = trend?.daily ?? [];
const peak = Number(trend?.peak || 0);
const stageSpend = STAGE_SPEND.map((s) => ({ ...s, amount: Number(trend?.by_stage[s.key] || 0) }));
const stageSum = stageSpend.reduce((s, x) => s + x.amount, 0);
const topStage = stageSpend.reduce((a, b) => (b.amount > a.amount ? b : a), stageSpend[0]);
const topPct = stageSum > 0 ? Math.round((topStage.amount / stageSum) * 100) : 0;
function openCreate() {
// 打开时预填随机用户名 + 随机密码,用户可改
setCuUser((v) => v || genUsername());
@@ -312,199 +347,245 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<section className="team-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 成员 · 角色 · 额度 · 共享资产库</span></div>
<h1></h1>
<div className="sub"></div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" id="open-invite" onClick={openCreate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M19 8v6M22 11h-6" /></svg>
<button className="tc-ghost" type="button" id="open-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
<UserPlus size={18} />
</button>
<button className="tc-ghost" type="button" id="open-invite" onClick={openCreate}>
<UserPlus size={18} />
</button>
<button className="tc-primary" type="button" onClick={() => setModal("recharge")}>
<CircleDollarSign size={18} />
</button>
</div>
</div>
{/* 顶部行:团队 banner(左)+ 团队动态(右) */}
<div className="team-top">
<div className="team-banner">
<span className="corner-tr" aria-hidden></span><span className="corner-bl" aria-hidden></span>
<div className="banner-head">
<div className="banner-id">
<div className="lbl">[ TEAM ]</div>
<div className="nm">{team.name} <span className="tag"></span></div>
<div className="meta">// 团队 ID: {team.id} · {rows.length} 名成员</div>
</div>
<div className="banner-actions">
<button className="btn btn-sm" type="button" onClick={() => setModal("recharge")}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v10M9 10h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4" /></svg>
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-gen-invite" onClick={() => { setInviteResult(null); setInviteErr(""); setModal("gen-invite"); }}>
<UserPlus size={13} />
</button>
<button className="btn btn-ghost btn-sm" type="button" id="open-limit" onClick={openLimit}></button>
</div>
</div>
<div className="banner-divider"></div>
<div className="banner-stats">
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v">{money(balance)}</div>
<div className="sub">// 团队总池</div>
</div>
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v" id="stat-limit">{money(limit)}</div>
<div className="sub">// 自然月重置</div>
</div>
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v" id="stat-used">{money(used)}</div>
<div className="sub" id="stat-used-sub">// 占月限 {pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="lbl">[ ]</div>
<div className="v warn" id="stat-left">{money(left)}</div>
<div className="sub" id="stat-left-sub">// 还可生成约 {Math.max(0, Math.round(left / 10))} 个项目</div>
</div>
<div className="tc-hero">
<div className="tc-identity">
<div>
<span className="tc-team-tag">TEAM</span>
<h2>{team.name}</h2>
<p> · {rows.length} </p>
</div>
<p> ID{team.id}</p>
</div>
{/* 团队动态(banner 右栏)· 接真实 ops/notifications 事件流 */}
<div className="team-feed">
<div className="h">
<h3></h3>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notificationTotal ?? notifications.length}</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}> </a>
</div>
<div className="feed-list">
{feedItems.length === 0 ? (
<div className="feed-item">
<div className="av">{(team.name || "T").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt"><span className="who">{team.name}</span><span className="act"></span><span className="obj">{rows.length} </span></div>
<div className="ts">// 暂无团队动态</div>
</div>
</div>
) : (
feedItems.map((n) => (
<div className="feed-item" key={n.id}>
<div className="av">{(n.owner_label || n.source || team.name || "·").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt">
<span className="who">{n.owner_label || n.source || "系统"}</span>
<span className="act">{n.title}</span>
{n.project_name && <span className="obj">{n.project_name}</span>}
</div>
<div className="ts">{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}</div>
</div>
</div>
))
)}
</div>
<div className="tc-metric">
<span></span>
<strong>{money(balance)}</strong>
<small></small>
</div>
</div>{/* /.team-top */}
<div className="team-grid">
{/* 左:成员表 */}
<div>
<div className="pane" style={{ padding: 0 }}>
<div style={{ display: "flex", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid var(--border-faint)" }}>
<h3 style={{ margin: 0 }}> <span className="ct">// {list.length} / {rows.length} 人 · 真实团队表</span></h3>
<span className="spacer"></span>
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<div className="members-table-wrap">
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th style={{ width: "140px" }}></th>
<th style={{ textAlign: "right", width: "88px" }}></th>
</tr>
</thead>
<tbody id="members-tbody">
{pagedMembers.map((member) => {
const rawName = (member.user.username || "").trim();
const email = (member.user.email || "").trim();
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
const role = roleUi(member.role);
const daily = Number(member.daily_credit_limit || 0);
const totalLimit = Number(member.total_credit_limit || 0);
const dayUsed = Number(member.day_charged || 0);
const totalUsed = Number(member.total_charged || 0);
const monthly = Number(member.monthly_credit_limit || 0);
const memberUsed = Number(member.month_charged || 0);
// 月度不限时按团队月限额/余额作分母给参考进度;有消费即显可见细条,避免「用了钱进度条却空白」
const quotaDenom = monthly > 0 ? monthly : limit;
const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0;
const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0;
const barClass = memberPct >= 80 ? "warn" : "ok";
const isOwner = member.role === "owner";
return (
<tr key={member.id} data-id={member.id}>
<td><span className="member-cell"><span className="av">{displayName.slice(0, 1).toUpperCase()}</span><span className="member-meta"><span className="nm">{displayName}</span>{showEmail && <span className="em">{email}</span>}</span></span></td>
<td><span className={`role-pill role-${role.key}`}><span className="dot"></span>{role.label}</span></td>
<td><span className="quota-cell"><span className="v">{daily > 0 ? money(daily) : "不限"}</span>{daily > 0 && <span className="lbl"> {money(dayUsed)}</span>}</span></td>
<td><span className="quota-cell"><span className="v">{monthly > 0 ? money(monthly) : "不限"}</span></span></td>
<td><span className="quota-cell"><span className="v">{totalLimit > 0 ? money(totalLimit) : "不限"}</span>{totalLimit > 0 && <span className="lbl"> {money(totalUsed)}</span>}</span></td>
<td><div className="quota-cell"><span className="v">{money(memberUsed)}</span> <span className="lbl">/ {monthly > 0 ? `${memberPct.toFixed(0)}%` : "不限"}</span></div><div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }}></span></div></td>
<td><div className="acts">{isOwner
? <span style={{ fontFamily: "var(--font-mono)", fontSize: "12px", color: "var(--black-alpha-32)", alignSelf: "center" }}></span>
: <>
<button className="icon-btn-sm" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
<button className="icon-btn-sm" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(genPassword()); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
<button className="icon-btn-sm danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
</>}</div></td>
</tr>
);
})}
</tbody>
</table>
</div>
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
</div>
<div className="tc-metric">
<span></span>
<strong id="stat-limit">{money(limit)}</strong>
<small></small>
</div>
{/* 右:权限矩阵 */}
<div>
<div className="pane">
<h3></h3>
<div style={{ fontSize: "12px", color: "var(--black-alpha-48)", marginTop: "-10px", marginBottom: "12px", fontFamily: "var(--font-mono)", letterSpacing: ".02em" }}>// PRD §10.2 权限矩阵节选</div>
<table className="perm-table">
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{PERM_ROWS.map((row) => (
<tr key={row.cap} style={row.last ? { borderBottom: 0 } : undefined}>
<td>{row.cap}</td>
{row.cells.map((cell, index) => (
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="tc-metric">
<span></span>
<strong id="stat-used">{money(used)}</strong>
<small>使 {pct.toFixed(1)}%</small>
</div>
<div className="tc-metric">
<span></span>
<strong id="stat-left">{money(left)}</strong>
<small> {Math.max(0, Math.round(left / 10))} </small>
</div>
</div>
<section className="tc-section" aria-labelledby="teamCollaborationTitle">
<div className="tc-section-title">
<div>
<h2 id="teamCollaborationTitle"></h2>
<p></p>
</div>
<button className="tc-compact tc-compact-klein" type="button" id="viewAllMessages" onClick={() => navigate("messages")}></button>
</div>
<div className="tc-grid">
<div className="tc-panel">
<div className="tc-panel-head">
<h2></h2>
<label className="tc-search">
<Search size={16} />
<input id="member-search" placeholder="搜索姓名 / 手机号" value={search} onChange={(event) => setSearch(event.target.value)} />
</label>
</div>
<div className="tc-row tc-row-head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{pagedMembers.map((member) => {
const rawName = (member.user.username || "").trim();
const email = (member.user.email || "").trim();
const displayName = rawName && !rawName.includes("@") ? rawName : (email ? email.split("@")[0] : (rawName || "成员"));
const showEmail = !!email && email.toLowerCase() !== displayName.toLowerCase();
const role = roleUi(member.role);
const dailyLimit = Number(member.daily_credit_limit || 0);
const totalLimit = Number(member.total_credit_limit || 0);
const dayUsed = Number(member.day_charged || 0);
const totalUsed = Number(member.total_charged || 0);
const monthly = Number(member.monthly_credit_limit || 0);
const memberUsed = Number(member.month_charged || 0);
const quotaDenom = monthly > 0 ? monthly : limit;
const memberPct = quotaDenom > 0 ? Math.min(100, (memberUsed / quotaDenom) * 100) : 0;
const barWidth = memberUsed > 0 ? Math.max(memberPct, 3) : 0;
const barClass = memberPct >= 80 ? "warn" : "ok";
const isOwner = member.role === "owner";
return (
<div className="tc-row" key={member.id} data-id={member.id}>
<div className="tc-person">
<div className="tc-avatar">{displayName.slice(0, 1).toUpperCase()}</div>
<div>
<strong>{displayName}</strong>
<span>{showEmail ? email : team.name}</span>
</div>
</div>
<span className={`tc-tag tc-tag-${role.key}`}>{role.label}</span>
<span className="tc-quota">{dailyLimit > 0 ? money(dailyLimit) : "不限"}{dailyLimit > 0 ? ` · 今日 ${money(dayUsed)}` : ""}</span>
<span>{monthly > 0 ? money(monthly) : "不限"}</span>
<span className="tc-quota">{totalLimit > 0 ? money(totalLimit) : "不限"}{totalLimit > 0 ? ` · 累计 ${money(totalUsed)}` : ""}</span>
<div className="tc-used">
<strong>{money(memberUsed)}</strong>
<div className="used-bar"><span className={barClass} style={{ width: `${barWidth}%` }} /></div>
</div>
<div className="tc-acts">{isOwner
? <span className="tc-muted"></span>
: <>
<button className="tc-icon" type="button" title="编辑" onClick={() => openEdit(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" /></svg></button>
<button className="tc-icon" type="button" title="重置密码" onClick={() => { setResetTarget(member); setResetPwd(genPassword()); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg></button>
<button className="tc-icon danger" type="button" title="移出" onClick={() => setRemoveTarget(member)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg></button>
</>}</div>
</div>
);
})}
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
</div>
<div className="tc-panel">
<div className="tc-panel-head">
<h2></h2>
<span className="tc-tag">{notificationTotal || feedItems.length} </span>
</div>
<div className="tc-activity">
{feedItems.length === 0 ? (
<div className="tc-activity-item">
<span className="tc-dot" />
<div>
<p><strong>{team.name}</strong> </p>
<span> {rows.length} </span>
</div>
</div>
) : (
feedItems.map((n) => (
<div className="tc-activity-item" key={n.id}>
<span className="tc-dot" />
<div>
<p><strong>{n.owner_label || n.source || "系统"}</strong> {n.title}</p>
<span>{shortTs(n.created_at)}{n.cost_label ? ` · ${n.cost_label}` : ""}{n.project_name ? ` · ${n.project_name}` : ""}</span>
</div>
</div>
))
)}
</div>
</div>
</div>
</section>
<section className="tc-section" aria-labelledby="teamConsumptionTitle">
<div className="tc-section-title">
<div>
<h2 id="teamConsumptionTitle"></h2>
<p>{trendRange === "week" ? "近 8 周" : trendRange === "month" ? "近 6 个月" : "近 14 天"} · </p>
</div>
<button className="tc-compact" type="button" id="open-limit" onClick={openLimit}>
<Gauge size={16} />
</button>
</div>
<div className="tc-panel">
<div className="tc-panel-head">
<h2></h2>
<div className="tc-segmented">
{TREND_CHIPS.map((chip) => (
<button
key={chip.key}
type="button"
className={`tc-seg${trendRange === chip.key ? " active" : ""}`}
onClick={() => setTrendRange(chip.key)}
>
{chip.label}
</button>
))}
</div>
</div>
<div className="tc-summary">
<div>
<span></span>
<strong id="stat-used-sub">{money(used)}</strong>
</div>
<div>
<span></span>
<strong id="stat-left-sub">{money(left)}</strong>
</div>
<div>
<span></span>
<strong>{stageSum > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"}</strong>
</div>
</div>
<div className="tc-chart">
{daily.length === 0 ? (
<div className="tc-chart-empty"></div>
) : daily.map((d) => {
const amt = Number(d.amount);
const h = peak > 0 ? Math.max(amt > 0 ? 8 : 4, (amt / peak) * 100) : 8;
return (
<div className="tc-bar-group" key={d.date} title={`${d.label} · ${money(amt)}`}>
<strong className="tc-bar-value">{amt > 0 ? Math.round(amt) : ""}</strong>
<div className="tc-bar" style={{ height: `${h}%` }} />
<span>{d.label}</span>
</div>
);
})}
</div>
</div>
</section>
<section className="tc-section" aria-labelledby="teamPermTitle">
<div className="tc-section-title">
<div>
<h2 id="teamPermTitle"></h2>
<p> / / </p>
</div>
</div>
<div className="tc-panel tc-perm-panel">
<table className="perm-table">
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{PERM_ROWS.map((row) => (
<tr key={row.cap}>
<td>{row.cap}</td>
{row.cells.map((cell, index) => (
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* 设置月限额(团队级)· 预设 pill + 实时剩余 */}
<TeamModal
open={modal === "gen-invite"}
title="邀请成员"
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
subtitle="生成邀请码 · 新成员凭码注册即加入本团队"
icon={<UserPlus size={16} />}
close={() => setModal("")}
dismissable={false}
@@ -546,7 +627,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<div><div className="ck"></div><div className="cv">{`${location.origin}${inviteResult.register_url}`}</div></div>
<button className="cred-copy" type="button" onClick={() => navigator.clipboard?.writeText(`${location.origin}${inviteResult.register_url}`)}><Copy size={13} /> </button>
</div>
<div className="cred-rules">// 角色 {invRole === "admin" ? "团队管理员" : "成员"} · 7 天有效 · 凭码注册即加入,不重复发额度</div>
<div className="cred-rules"> {invRole === "admin" ? "团队管理员" : "成员"} · 7 · ,</div>
</div>
)}
</div>
@@ -555,7 +636,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<TeamModal
open={modal === "limit"}
title="设置月限额"
subtitle="// 自然月重置 · 仅超管可改"
subtitle="自然月重置 · 仅超管可改"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
@@ -587,7 +668,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<TeamModal
open={modal === "invite"}
title="创建账户"
subtitle="// 直接生成账号 · 分享给成员登录"
subtitle="直接生成账号 · 分享给成员登录"
icon={<UserPlus size={16} />}
close={() => setModal("")}
footer={<button className="btn btn-primary" type="button" onClick={submitCreate}></button>}
@@ -647,7 +728,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<TeamModal
open={!!share}
title={share?.mode === "reset" ? "密码已重置" : "账户已创建"}
subtitle={share?.mode === "reset" ? `// 把新凭据分享给 ${share?.name} · 旧密码已失效` : "// 把下方凭据分享给成员 · 凭据仅展示一次"}
subtitle={share?.mode === "reset" ? `把新凭据分享给 ${share?.name} · 旧密码已失效` : "把下方凭据分享给成员 · 凭据仅展示一次"}
icon={<CheckCircle2 size={16} />}
close={() => setShare(null)}
footer={<button className="btn btn-primary" type="button" onClick={() => setShare(null)}></button>}
@@ -686,20 +767,20 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<TeamModal
open={modal === "recharge"}
title="团队充值"
subtitle="// 充值后立即到账 · 仅超管可操作"
subtitle="充值后立即到账 · 仅超管可操作"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}></button>}
>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /><div className="field-hint">// 到账积分 = 支付金额 × {pointsRate}(当前汇率)</div></div>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /><div className="field-hint"> = × {pointsRate}()</div></div>
</TeamModal>
{/* 编辑成员 · 角色双卡 + 三档额度 */}
<TeamModal
open={!!editTarget}
title="编辑成员"
subtitle={editTarget ? `// ${editTarget.user.username || editTarget.user.email}` : ""}
subtitle={editTarget ? `${editTarget.user.username || editTarget.user.email}` : ""}
icon={<UserPlus size={16} />}
close={() => setEditTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitEdit}></button>}
@@ -740,7 +821,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
<TeamModal
open={!!resetTarget}
title="重置登录密码"
subtitle="// 该成员当前会话会被强制下线"
subtitle="该成员当前会话会被强制下线"
icon={<KeyRound size={16} />}
close={() => setResetTarget(null)}
footer={<button className="btn btn-primary" type="button" onClick={submitReset}></button>}
+6 -6
View File
@@ -313,7 +313,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 已删除内容</span>
<span className="mono"></span>
<span>·</span>
<span>,()</span>
</div>
@@ -329,13 +329,13 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
{loading ? (
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="trash" />
) : isEmpty ? (
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
<div className="placeholder trash-empty"><span className="ph-frame"></span></div>
) : (
<>
{errText && <div className="trash-err mono">// {errText}</div>}
{errText && <div className="trash-err mono">{errText}</div>}
{sections.map((section) => (
<div className="trash-section" key={section.key}>
<div className="trash-section-title mono">// {section.title} · {section.rows.length}</div>
<div className="trash-section-title mono">{section.title} · {section.rows.length}</div>
<div className="trash-list">
{section.rows.map((row) => {
const key = rowKey(row);
@@ -347,7 +347,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
</div>
<div className="trash-meta">
<div className="trash-name" title={row.title}>{row.title}</div>
<div className="trash-sub mono">// {row.subtitle}</div>
<div className="trash-sub mono">{row.subtitle}</div>
</div>
<div className="trash-actions">
{confirmId === key ? (
@@ -375,7 +375,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
<ConfirmModal
open={purgeAllOpen}
title="清空垃圾桶"
subtitle="// PURGE ALL"
detail="清空垃圾桶数据无法恢复,是否需要清空?"
confirmText="清空"
onCancel={() => setPurgeAllOpen(false)}
+281 -79
View File
@@ -1,71 +1,266 @@
/* 设置页 · 从 public/exact/settings.html 内联 <style> 忠实移植,整段 scope 进 .settings-page 防外泄 · 只用 token */
/* 设置页 · 对照影擎 settings:左菜单卡 + 右表单卡。整段 scope 进 .settings-page */
.settings-page {
/* ─── 设置布局:左 nav + 右 panel · 单张白卡(对齐 YZ 设置页) ─── */
--st-muted: #6f747c;
--st-text: #17181a;
--st-black: #101012;
--st-line: rgba(34, 42, 54, 0.09);
--st-wash: rgba(34, 42, 54, 0.045);
--st-hover: rgba(34, 42, 54, 0.08);
--st-danger: #c73737;
--st-shadow: 0 8px 20px rgba(20, 27, 38, 0.09);
--klein: #002fa7;
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
.page-head { margin-bottom: 22px; }
.page-head h1 { font-size: 28px; }
.page-head .sub { margin-top: 6px; font-size: 15px; color: var(--st-muted); }
.page-head .actions .save-count { font-family: var(--font-mono); font-size: 11px; margin-left: 4px; opacity: .75; font-variant-numeric: tabular-nums; }
.settings-grid {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: 0;
align-items: stretch;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: 12px;
box-shadow: var(--shadow-floating);
overflow: hidden;
min-height: 560px;
grid-template-columns: 210px minmax(0, 1fr);
gap: 22px;
align-items: start;
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: visible;
min-height: 0;
}
.settings-nav {
display: grid;
gap: 7px;
align-content: start;
padding: 10px;
border: 1px solid var(--st-line);
border-radius: 13px;
background: var(--st-wash);
}
.settings-nav .nav-h {
display: block;
margin-top: 8px;
padding: 4px 13px 2px;
font-size: 12px;
color: var(--st-muted);
}
.st-tab {
height: 44px;
display: flex;
flex-direction: column;
padding: 18px 14px;
border-right: 1px solid var(--border-faint);
background: var(--surface);
}
.settings-nav .nav-h { display: none; }
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 14px; font: inherit; font-size: 14px; color: var(--black-alpha-56); border-radius: 10px; border: 1px solid transparent; background: transparent; cursor: pointer; text-decoration: none; text-align: left; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); position: relative; }
.settings-nav :where(a, button):hover { background: var(--background-lighter); color: var(--accent-black); }
.settings-nav :where(a, button):focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
.settings-nav a.active { background: var(--nav-active); color: var(--accent-white); border-color: transparent; font-weight: 600; }
.settings-nav a.active svg { color: var(--accent-white); }
.settings-nav :where(a, button) svg { width: 16px; height: 16px; stroke-width: 1.5; flex: 0 0 auto; }
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
.settings-nav a.active .nav-badge { color: var(--accent-white); background: rgba(255,255,255,.12); border-color: transparent; }
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
.settings-nav a.has-changes .nav-dot { display: block; }
.settings-nav a.active .nav-dot { background: var(--accent-white); }
.settings-nav a.has-changes:not(.active) > span:first-of-type { color: var(--heat); }
/* ─── 顶栏「保存所有变更」脏字段计数 · 主 CTA 内白字 mono ─── */
.page-head .actions .save-count { font-family: var(--font-mono); font-size: 11px; margin-left: 4px; opacity: .75; font-variant-numeric: tabular-nums; }
.settings-nav .logout-pill {
align-items: center;
gap: 10px;
width: 100%;
height: 40px;
margin-top: auto;
justify-content: center;
border-radius: 10px;
background: var(--crimson-bg);
border-color: transparent;
color: var(--accent-crimson);
font-weight: 500;
padding: 0 13px;
border: 0;
border-radius: 9px;
background: transparent;
color: #4b5058;
font: inherit;
font-size: 14px;
text-align: left;
cursor: pointer;
position: relative;
}
.settings-nav .logout-pill:hover,
.settings-nav .logout-pill:focus-visible {
background: rgba(235, 52, 36, .14);
border-color: transparent;
color: var(--accent-crimson);
.st-tab:hover { background: var(--st-hover); color: var(--st-text); }
.st-tab:focus-visible { outline: 2px solid var(--klein); outline-offset: 2px; }
.st-tab svg { display: block; width: 16px; height: 16px; stroke-width: 1.8; flex: 0 0 auto; }
.st-tab.active {
color: #fff;
background: var(--st-black);
font-weight: 600;
}
.st-tab.active svg { color: #fff; }
.st-tab .nav-badge {
margin-left: auto;
font-size: 12px;
color: var(--st-muted);
padding: 1px 6px;
background: rgba(34, 42, 54, 0.08);
border-radius: 999px;
letter-spacing: .02em;
line-height: 16px;
font-variant-numeric: tabular-nums;
}
.st-tab.active .nav-badge {
color: #fff;
background: rgba(255,255,255,.14);
}
.st-tab .nav-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--klein);
display: none;
flex: 0 0 auto;
}
.st-tab.has-changes .nav-dot { display: block; }
.st-tab.active .nav-dot { background: #fff; }
.st-tab:not(:has(.nav-badge)) .nav-dot { margin-left: auto; }
.logout-pill {
height: 44px;
width: 100%;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 13px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--st-danger);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.logout-pill svg { display: block; width: 16px; height: 16px; stroke-width: 1.8; }
.logout-pill:hover,
.logout-pill:focus-visible {
background: rgba(199, 55, 55, .08);
color: var(--st-danger);
}
/* ─── pane ─── */
.pane { background: transparent; border: 0; border-radius: 0; padding: 28px 32px; margin-bottom: 0; }
.settings-content {
padding: 8px 22px;
border: 1px solid var(--st-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.04);
box-shadow: var(--st-shadow);
min-width: 0;
}
.pane { background: transparent; border: 0; border-radius: 0; padding: 0; margin: 0; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.pane .pane-desc { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; margin-bottom: 18px; }
.pane .pane-desc { font-size: 12px; color: var(--st-muted); margin-bottom: 18px; }
/* ─── form row ─── */
.st-row {
min-height: 82px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid rgba(34, 42, 54, 0.075);
}
.st-row:last-child { border-bottom: 0; }
.st-row-stack {
align-items: flex-start;
flex-direction: column;
gap: 16px;
padding: 22px 0;
min-height: 0;
}
.st-copy { min-width: 0; }
.st-copy strong {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--st-text);
}
.st-copy span {
display: block;
margin-top: 5px;
color: var(--st-muted);
font-size: 12px;
line-height: 1.5;
}
.st-val {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
min-width: 0;
flex: 0 0 auto;
}
.st-row-stack .st-val {
width: 100%;
display: block;
}
.st-input {
width: 340px;
max-width: 100%;
height: 42px;
margin: 0;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 10px;
outline: none;
background: #fff;
color: var(--st-text);
font: inherit;
font-size: 14px;
}
.st-input:focus { border-color: rgba(0, 47, 167, .35); box-shadow: 0 0 0 3px rgba(0, 47, 167, .08); }
.st-input::placeholder { color: var(--black-alpha-32); }
.st-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
background: rgba(34, 42, 54, 0.05);
color: var(--st-text);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.st-ghost:hover:not(:disabled) { background: rgba(34, 42, 54, 0.10); }
.st-ghost:disabled {
color: rgba(28, 34, 43, 0.36);
border-color: rgba(28, 34, 43, 0.07);
background: rgba(34, 42, 54, 0.025);
cursor: not-allowed;
}
.st-ghost-sm { min-width: 0; height: 38px; padding: 0 14px; }
.st-text {
border: 0;
background: transparent;
color: var(--st-muted);
font: inherit;
font-size: 13px;
cursor: pointer;
text-decoration: none;
padding: 0;
}
.st-text:hover { color: var(--st-text); }
.st-muted { color: var(--st-muted); font-size: 13px; word-break: break-all; }
.st-team { display: flex; align-items: center; gap: 16px; }
.st-team strong { font-size: 14px; font-weight: 600; color: var(--st-text); }
.st-password { display: flex; align-items: center; gap: 16px; }
.st-dots { letter-spacing: 4px; color: var(--st-muted); }
.avatar-edit { display: flex; align-items: center; gap: 18px; }
.avatar-edit .av-big {
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--st-black);
color: #fff;
display: grid;
place-items: center;
font-size: 24px;
font-weight: 600;
overflow: hidden;
flex: 0 0 60px;
}
.avatar-edit .av-big img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* 隐藏分区仍用旧 form-row */
.form-row { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-faint); align-items: center; }
.form-row:last-child { border-bottom: 0; }
.form-row .lbl { font-size: 13px; color: var(--black-alpha-56); }
.form-row .lbl .req { color: var(--accent-crimson); margin-left: 2px; }
.form-row .lbl-sub { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
.form-row .lbl-sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 2px; }
.form-row .val { display: flex; align-items: center; gap: 10px; min-width: 0; }
.form-row .val .input, .form-row .val .select { width: 100%; max-width: 380px; }
.form-row .val .static { font-size: 13px; color: var(--accent-black); font-variant-numeric: tabular-nums; }
@@ -73,25 +268,19 @@
.form-row .val .role-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 12px; font-weight: 500; background: var(--heat-12); color: var(--heat); }
.form-row .val .role-tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--heat); }
.form-row .val .row-link { font-size: 12px; color: var(--heat); text-decoration: none; margin-left: auto; }
.form-row .val .row-note { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); }
.form-row .val .switch-note { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); }
.form-row .val .row-note { font-size: 12px; color: var(--black-alpha-48); }
.form-row .val .switch-note { font-size: 12px; color: var(--black-alpha-48); }
.form-row.row-top { align-items: flex-start; }
.form-row.row-top .lbl { padding-top: 4px; }
.form-row.row-top .val { display: block; }
/* ─── 头像上传 ─── */
.avatar-edit { display: flex; align-items: center; gap: 16px; }
.avatar-edit .av-big { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 24px; font-weight: 600; color: var(--accent-black); overflow: hidden; }
.avatar-edit .av-big img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-edit .av-actions { display: flex; gap: 8px; }
/* ─── toggle switch ─── */
.switch { position: relative; width: 36px; height: 20px; flex: 0 0 36px; display: inline-block; }
/* ─── toggle switch · 对照影擎 46×26 Klein ─── */
.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: var(--black-alpha-24); border-radius: 20px; cursor: pointer; transition: background var(--t-base); }
.switch .slider::before { content: ''; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; background: var(--accent-white); border-radius: 50%; transition: transform var(--t-base); }
.switch input:checked + .slider { background: var(--heat); }
.switch input:checked + .slider::before { transform: translateX(16px); }
.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 input:checked + .slider { background: var(--klein); }
.switch input:checked + .slider::before { transform: translateX(20px); }
.switch input:disabled + .slider { cursor: not-allowed; opacity: .55; }
/* ─── 偏好选项卡 ─── */
@@ -111,12 +300,23 @@
/* ─── 设备列表 ─── */
.device-list { /* 容器 · 仅承载行 */ }
.device-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-faint); }
.device-row { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 0; border-bottom: 1px solid rgba(34, 42, 54, 0.08); }
.device-row:last-child { border-bottom: 0; }
.device-row .ic { width: 36px; height: 36px; border-radius: var(--r-md); background: var(--background-lighter); display: grid; place-items: center; color: var(--ink-3); flex: 0 0 36px; }
.device-row .ic svg { width: 18px; height: 18px; }
.device-row .nm { font-size: 13px; font-weight: 500; display: flex; align-items: center; }
.device-row .meta { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
.device-row .ic {
width: 34px;
height: 34px;
border-radius: 9px;
background: rgba(34, 42, 54, 0.06);
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
color: var(--st-muted);
flex: 0 0 34px;
}
.device-row .ic svg { display: block; width: 18px; height: 18px; }
.device-row .nm { font-size: 13px; font-weight: 600; display: flex; align-items: center; color: var(--st-text); }
.device-row .meta { font-size: 12px; color: var(--st-muted); margin-top: 4px; }
.device-row .tag-cur { font-family: var(--font-mono); font-size: 12px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }
.device-row .spacer { margin-left: auto; }
.device-row .row-note { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); }
@@ -134,7 +334,7 @@
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 12px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
.av-up-rules .li { display: flex; gap: 8px; }
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
.av-up-rules .li::before { content: none; }
/* 头像上传 modal 的 upload-zone · 共享样式仅 scope 在 .np-body,故在此页内补齐 uz-ic / uz-hint */
.upload-zone {
@@ -158,11 +358,12 @@
background: var(--surface);
color: var(--heat);
border: 1px solid var(--heat-20);
display: grid; place-items: center;
display: flex; align-items: center; justify-content: center;
line-height: 0;
margin-bottom: 8px;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.upload-zone .uz-ic svg { width: 18px; height: 18px; }
.upload-zone .uz-ic svg { display: block; width: 18px; height: 18px; }
.upload-zone .uz-hint { display: block; margin-top: 2px; font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
/* ─── 退出登录确认 modal 正文 ─── */
@@ -182,12 +383,7 @@
line-height: 1.55;
color: var(--black-alpha-64);
}
.logout-confirm-points .li::before {
content: '//';
flex: 0 0 auto;
font-family: var(--font-mono);
color: var(--black-alpha-32);
}
.logout-confirm-points .li::before { content: none; }
.logout-unsaved-note {
margin-top: 12px;
padding: 9px 11px;
@@ -205,9 +401,15 @@
/* ─── 页脚 build 标记 ─── */
.settings-foot { text-align: center; padding: 24px 0 8px; color: var(--black-alpha-32); font-family: var(--font-mono); font-size: 12px; letter-spacing: .04em; }
@media (max-width: 1100px) {
margin: -28px -24px -48px;
}
@media (max-width: 1024px) {
.settings-grid { grid-template-columns: 1fr; }
.settings-nav { position: static; }
.form-row { grid-template-columns: 1fr; gap: 6px; }
.st-row { flex-wrap: wrap; min-height: 0; padding: 16px 0; }
.st-val { justify-content: flex-start; width: 100%; }
.st-input { width: 100%; }
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ img, svg, video { display: block; max-width: 100%; }
.divider { height: 1px; background: var(--border); margin: 14px 0; }
/* ─── App shell ─── */
.app { display: grid; grid-template-columns: 248px 1fr; min-height: 100vh; }
.app { display: grid; grid-template-columns: 164px 1fr; min-height: 100vh; }
/* ─── Sidebar ─── */
aside.sidebar {
+366 -111
View File
@@ -1,121 +1,376 @@
/* 团队页 · public/exact/team.html 内联 <style> 忠实移植(仅页面结构 9-127,
modal 段沿用共享 TeamModal,默认隐藏不入 diff),整段 scope .team-page 防冲突
依赖 design-restraint.css 的全局 .stat(padding 24/28 + border-right)与镜像 restraint.css 一致 */
/* 团队中心 · 对照影擎 team-center。仅 scope 在 .team-page,不改共享 .btn/.pill/.input */
.team-page {
/* ─── 团队信息卡(深色 banner · 上标题行 + 下统计行)─── */
.team-top { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; margin-bottom: 24px; align-items: stretch; }
--klein: #002fa7;
--st-muted: #6f747c;
--st-text: #17181a;
--st-black: #101012;
--st-line: rgba(34, 42, 54, 0.10);
--st-wash: rgba(34, 42, 54, 0.045);
--st-shadow: 0 8px 20px rgba(20, 27, 38, 0.09);
max-width: 1140px;
padding-bottom: 44px;
.team-banner {
background: var(--accent-black);
color: var(--accent-white);
padding: 22px 28px 24px;
.page-head { margin-bottom: 22px; }
.page-head h1 { font-size: 28px; }
.page-head .sub { margin-top: 6px; font-size: 15px; color: var(--st-muted); }
.tc-ghost,
.tc-primary,
.tc-compact {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border-radius: 11px;
cursor: pointer;
font: inherit;
font-size: 14px;
}
.tc-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
color: var(--st-text);
border: 1px solid rgba(28, 34, 43, 0.12);
background: rgba(34, 42, 54, 0.05);
}
.tc-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.tc-ghost svg, .tc-primary svg, .tc-compact svg {
display: block;
width: 18px;
height: 18px;
}
.tc-primary {
min-width: 132px;
height: 50px;
padding: 0 22px;
color: #fff;
border: 0;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
}
.tc-primary:hover { background: #002680; }
.tc-compact {
height: 38px;
padding: 0 15px;
color: #fff;
border: 0;
background: var(--st-black);
}
.tc-compact-klein {
color: var(--klein);
border: 1px solid rgba(0, 47, 167, 0.18);
background: rgba(0, 47, 167, 0.07);
}
.tc-compact-klein:hover { color: #fff; background: var(--klein); }
.tc-hero {
display: grid;
grid-template-columns: minmax(260px, 0.9fr) repeat(4, minmax(150px, 0.7fr));
gap: 0;
overflow: hidden;
border-radius: 14px;
color: #fff;
background: #202124;
box-shadow: 0 14px 30px rgba(16, 16, 18, 0.18);
}
.tc-identity,
.tc-metric { min-height: 158px; padding: 26px; }
.tc-identity { display: flex; flex-direction: column; justify-content: space-between; }
.tc-identity h2 { margin: 0 0 8px; font-size: 23px; font-weight: 600; color: #fff; }
.tc-identity p,
.tc-metric span,
.tc-metric small { color: rgba(255, 255, 255, 0.58); }
.tc-identity p { margin: 0; font-size: 12px; }
.tc-team-tag {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
margin-bottom: 10px;
border-radius: 7px;
color: #fff;
background: rgba(255,255,255,.1);
font-size: 11px;
letter-spacing: .04em;
}
.tc-metric {
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid rgba(255, 255, 255, 0.13);
}
.tc-metric strong {
margin: 9px 0 6px;
font-size: 20px;
font-weight: 600;
color: #fff;
letter-spacing: -.02em;
}
.tc-metric small { font-size: 11px; }
.tc-section { margin-top: 28px; }
.tc-section-title {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.tc-section-title h2 { margin: 0; font-size: 20px; font-weight: 600; color: var(--st-text); }
.tc-section-title p { margin: 5px 0 0; color: var(--st-muted); font-size: 12px; }
.tc-grid {
display: grid;
grid-template-columns: minmax(0, 1.18fr) minmax(320px, 0.82fr);
gap: 20px;
align-items: stretch;
}
.tc-panel {
min-width: 0;
border: 1px solid var(--st-line);
border-radius: 14px;
background: var(--st-wash);
box-shadow: var(--st-shadow);
overflow: hidden;
}
.tc-panel-head {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 0 20px;
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
}
.tc-panel-head h2 { margin: 0; font-size: 18px; font-weight: 600; color: var(--st-text); }
.tc-search {
width: 220px;
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 10px;
background: #fff;
color: var(--st-muted);
}
.tc-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 13px;
color: var(--st-text);
}
.tc-row {
min-height: 72px;
display: grid;
grid-template-columns: minmax(160px, 1.4fr) 64px minmax(90px, 0.7fr) minmax(80px, 0.65fr) minmax(90px, 0.7fr) minmax(90px, 0.75fr) 92px;
align-items: center;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid rgba(34, 42, 54, 0.07);
font-size: 13px;
color: var(--st-text);
}
.tc-row:last-of-type { border-bottom: 0; }
.tc-row-head {
min-height: 46px;
color: var(--st-muted);
font-size: 12px;
}
.tc-person { display: flex; align-items: center; gap: 12px; min-width: 0; }
.tc-person strong,
.tc-person span { display: block; }
.tc-person strong { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tc-person span { margin-top: 3px; color: var(--st-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tc-avatar {
width: 42px;
height: 42px;
flex: 0 0 42px;
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
background: var(--st-black);
font-size: 16px;
font-weight: 600;
}
.tc-tag {
display: inline-flex;
align-items: center;
width: max-content;
height: 25px;
padding: 0 9px;
border-radius: 7px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
}
.tc-tag-super { color: var(--klein); background: rgba(0, 47, 167, 0.09); }
.tc-tag-admin { color: #138d3f; background: rgba(22, 184, 78, 0.10); }
.tc-quota { font-size: 12px; color: var(--st-muted); }
.tc-used strong { display: block; font-size: 13px; font-weight: 600; }
.used-bar { width: 80px; height: 4px; background: rgba(34, 42, 54, 0.08); border-radius: 2px; overflow: hidden; margin-top: 6px; }
.used-bar > span { display: block; height: 100%; background: var(--klein); }
.used-bar > span.ok { background: #42c366; }
.used-bar > span.warn { background: #c73737; }
.tc-acts { display: flex; gap: 4px; justify-content: flex-end; }
.tc-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
border: 1px solid var(--st-line);
border-radius: 8px;
background: #fff;
cursor: pointer;
color: var(--st-muted);
padding: 0;
}
.tc-icon svg { display: block; width: 14px; height: 14px; }
.tc-icon:hover { color: var(--st-text); background: rgba(34, 42, 54, 0.06); }
.tc-icon.danger:hover { color: #c73737; border-color: rgba(199, 55, 55, .3); }
.tc-muted { font-size: 12px; color: var(--st-muted); }
.tc-activity {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px 20px 20px;
}
.tc-activity-item {
min-height: 74px;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border: 1px solid rgba(34, 42, 54, 0.07);
border-radius: 12px;
background: rgba(255, 255, 255, 0.62);
}
.tc-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
margin-top: 7px;
border-radius: 50%;
background: var(--klein);
}
.tc-activity-item p { margin: 0; font-size: 13px; line-height: 1.45; color: var(--st-text); }
.tc-activity-item span { display: block; margin-top: 4px; color: var(--st-muted); font-size: 12px; }
.tc-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 18px 20px 2px;
}
.tc-summary div {
padding: 14px;
border-radius: 11px;
background: rgba(0, 47, 167, 0.055);
}
.tc-summary span,
.tc-summary strong { display: block; }
.tc-summary span { color: var(--st-muted); font-size: 11px; }
.tc-summary strong { margin-top: 7px; font-size: 16px; font-weight: 600; color: var(--st-text); }
.tc-segmented { display: flex; align-items: center; gap: 8px; }
.tc-seg {
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 9px;
color: #50555d;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 14px;
}
.tc-seg.active { color: #fff; background: var(--st-black); }
.tc-chart {
position: relative;
border: 1px solid var(--accent-black);
border-radius: var(--r-md);
height: 220px;
display: flex;
align-items: flex-end;
gap: 12px;
padding: 30px 28px 22px;
}
/* ─── 团队动态卡(贴 banner 右边)─── */
.team-feed { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 16px 18px; display: flex; flex-direction: column; min-width: 0; }
.team-feed .h { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.team-feed .h h3 { font-size: 14px; font-weight: 600; margin: 0; }
.team-feed .h .ct { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
.team-feed .h .more { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--heat); text-decoration: none; cursor: pointer; }
.team-feed .feed-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; max-height: 240px; padding-right: 4px; scrollbar-width: thin; }
.team-feed .feed-list::-webkit-scrollbar { width: 4px; }
.team-feed .feed-list::-webkit-scrollbar-thumb { background: var(--border-faint); border-radius: 2px; }
.team-feed .feed-item { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 10px; align-items: start; }
.team-feed .feed-item .av { width: 24px; height: 24px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 12px; font-weight: 600; color: var(--accent-black); }
.team-feed .feed-item .txt { font-size: 13px; line-height: 1.45; color: var(--accent-black); min-width: 0; }
.team-feed .feed-item .txt .who { font-weight: 600; }
.team-feed .feed-item .txt .act { color: var(--black-alpha-56); margin: 0 3px; }
.team-feed .feed-item .txt .obj { color: var(--heat); }
.team-feed .feed-item .txt .obj-money { color: var(--accent-forest); font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
.team-feed .feed-item .ts { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); margin-top: 2px; letter-spacing: .02em; }
/* 4 个装订线小十字(2 个 pseudo + 2 个 span)*/
.team-banner::before, .team-banner::after,
.team-banner > .corner-tr, .team-banner > .corner-bl {
content: ''; position: absolute; width: 14px; height: 14px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 22 21' fill='%23e8e8e8'%3E%3Cpath d='M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z'/%3E%3C/svg%3E") no-repeat center;
background-size: contain; pointer-events: none;
.tc-chart::after {
content: "";
position: absolute;
right: 28px;
bottom: 42px;
left: 28px;
height: 1px;
background: rgba(34, 42, 54, 0.12);
}
.team-banner::before { top: -7px; left: -7px; }
.team-banner::after { bottom: -7px; right: -7px; }
.team-banner > .corner-tr { top: -7px; right: -7px; }
.team-banner > .corner-bl { bottom: -7px; left: -7px; }
.tc-chart-empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--st-muted);
font-size: 13px;
}
.tc-bar-group {
position: relative;
z-index: 1;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 9px;
color: var(--st-muted);
font-size: 11px;
min-width: 0;
}
.tc-bar {
width: min(30px, 70%);
min-height: 10px;
border-radius: 7px 7px 3px 3px;
background: var(--klein);
opacity: 0.78;
}
.tc-bar-value {
color: #343a45;
font-size: 10px;
font-weight: 600;
line-height: 1;
}
.tc-bar-group span { overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
/* 第 1 行:标题 + 主操作 */
.banner-head { display: flex; align-items: flex-start; gap: 20px; }
.banner-id { flex: 1; min-width: 0; }
.banner-id .lbl { font-family: var(--font-mono); font-size: 12px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.banner-id .nm { font-size: 22px; font-weight: 700; letter-spacing: -.012em; margin-top: 4px; display: flex; align-items: baseline; gap: 10px; }
.banner-id .nm .tag { font-size: 12px; font-family: var(--font-mono); padding: 2px 8px; background: rgba(255,255,255,.12); border-radius: var(--r-pill); letter-spacing: .04em; font-weight: 500; }
.banner-id .meta { font-size: 12px; color: rgba(255,255,255,.5); margin-top: 6px; font-family: var(--font-mono); letter-spacing: .02em; }
.banner-actions { display: flex; gap: 8px; flex-shrink: 0; }
.banner-actions .btn { background: var(--accent-white); color: var(--accent-black); border-color: var(--accent-white); }
.banner-actions .btn:hover { background: var(--background-base); }
.banner-actions .btn-ghost { background: transparent; color: var(--accent-white); border: 1px solid rgba(255,255,255,.25); }
.banner-actions .btn-ghost:hover { background: rgba(255,255,255,.08); color: var(--accent-white); }
/* 分隔线 */
.banner-divider { height: 1px; background: rgba(255,255,255,.1); margin: 20px 0 18px; }
/* 第 2 行:4 列统计 */
.banner-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; }
.banner-stats .stat { min-width: 0; }
.banner-stats .stat .lbl { font-family: var(--font-mono); font-size: 12px; color: rgba(255,255,255,.55); letter-spacing: .06em; text-transform: uppercase; }
.banner-stats .stat .v { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.012em; margin-top: 6px; }
/* color 必须显式写,否则会被 restraint.css 全局 .stat .v 的 color: var(--accent-black) 覆盖成黑字 */
.banner-stats .stat .v { color: var(--accent-white); }
.banner-stats .stat .v.warn { color: #FFB870; }
.banner-stats .stat .sub { font-size: 12px; color: rgba(255,255,255,.5); margin-top: 4px; font-family: var(--font-mono); letter-spacing: .02em; }
/* ─── 主体两栏 ─── */
.team-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 24px; align-items: start; }
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 20px; margin-bottom: 16px; }
.pane h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
.pane h3 .ct { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); font-weight: 400; }
.pane h3 .spacer { margin-left: auto; }
/* ─── 成员表 ─── */
.members-table th { white-space: nowrap; }
.members-table .av { flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%; background: var(--background-lighter); display: inline-grid; place-items: center; font-weight: 600; font-size: 13px; color: var(--accent-black); border: 1px solid var(--border-faint); }
.members-table .who { display: flex; align-items: center; gap: 10px; }
.members-table .member-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
.members-table .member-cell .member-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.members-table .nm { font-weight: 500; font-size: 14px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.members-table .em { font-size: 12px; color: var(--black-alpha-48); font-family: var(--font-mono); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 260px; }
.members-table .role-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 12px; font-weight: 500; }
.members-table .role-pill .dot { width: 6px; height: 6px; border-radius: 50%; }
.members-table .role-super { background: var(--heat-12); color: var(--heat); }
.members-table .role-super .dot { background: var(--heat); }
.members-table .role-admin { background: rgba(30,64,175,.1); color: #1E40AF; }
.members-table .role-admin .dot { background: #1E40AF; }
.members-table .role-member { background: var(--background-lighter); color: var(--black-alpha-56); }
.members-table .role-member .dot { background: var(--black-alpha-56); }
.members-table .quota-cell { font-variant-numeric: tabular-nums; font-family: var(--font-mono); font-size: 12px; }
.members-table .quota-cell .lbl { color: var(--black-alpha-48); }
.members-table .quota-cell .v { color: var(--accent-black); font-weight: 600; }
.members-table .used-bar { width: 80px; height: 4px; background: var(--background-lighter); border-radius: 2px; overflow: hidden; margin-top: 4px; }
.members-table .used-bar > span { display: block; height: 100%; background: var(--heat); }
.members-table .used-bar > span.ok { background: var(--accent-forest); }
.members-table .used-bar > span.warn { background: #B45309; }
.members-table .acts { display: flex; gap: 4px; justify-content: flex-end; }
.members-table .icon-btn-sm { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; color: var(--black-alpha-56); transition: all var(--t-base); }
.members-table .icon-btn-sm:hover { color: var(--heat); border-color: var(--heat-20); }
.members-table .icon-btn-sm svg { width: 14px; height: 14px; }
.members-table .icon-btn-sm.danger:hover { color: var(--accent-crimson); border-color: var(--accent-crimson); }
.members-table tr.pending td { opacity: .65; }
.members-table tr.pending .nm::after { content: '· 待激活'; font-size: 12px; color: var(--black-alpha-48); margin-left: 6px; font-weight: 400; font-family: var(--font-mono); }
/* ─── 角色权限矩阵 ─── */
.perm-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border-muted); border-radius: var(--r-md); overflow: hidden; font-size: 13px; }
.perm-table th, .perm-table td { padding: 8px 10px; border-bottom: 0; }
.perm-table th { border-bottom: 1px solid var(--border-muted); background: var(--background-lighter); font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; text-align: left; }
.tc-perm-panel { padding: 8px 0 4px; }
.perm-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.perm-table th, .perm-table td { padding: 10px 20px; }
.perm-table th { border-bottom: 1px solid rgba(34, 42, 54, 0.08); color: var(--st-muted); font-size: 12px; font-weight: 500; text-align: left; }
.perm-table th:not(:first-child), .perm-table td:not(:first-child) { text-align: center; }
.perm-table tbody td:first-child { color: var(--accent-black); }
.perm-table .yes { color: var(--accent-forest); font-weight: 600; }
.perm-table tbody td:first-child { color: var(--st-text); }
.perm-table tbody tr { border-bottom: 1px solid rgba(34, 42, 54, 0.07); }
.perm-table .yes { color: #138d3f; font-weight: 600; }
.perm-table .no { color: var(--black-alpha-32); }
.list-pager { padding: 12px 20px 16px; border-top: 1px solid rgba(34, 42, 54, 0.07); }
@media (max-width: 1100px) {
.tc-hero { grid-template-columns: minmax(230px, 1fr) repeat(2, minmax(125px, 0.7fr)); }
.tc-grid { grid-template-columns: 1fr; }
.tc-row { grid-template-columns: minmax(140px, 1.2fr) 64px minmax(80px, 0.7fr) minmax(80px, 0.7fr) 92px; }
.tc-row > :nth-child(3),
.tc-row > :nth-child(5) { display: none; }
.tc-activity { grid-template-columns: 1fr; }
}
}
/*
@@ -164,7 +419,7 @@
.share-acct-modal .cred-copy:hover { border-color: var(--heat-20); color: var(--heat); }
.share-acct-modal .cred-rules { margin-top: 14px; font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-56); letter-spacing: .02em; line-height: 1.7; }
.share-acct-modal .cred-rules .li { display: flex; gap: 8px; }
.share-acct-modal .cred-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
.share-acct-modal .cred-rules .li::before { content: none; }
.share-acct-modal .cred-copy-all { margin-top: 14px; }
.share-acct-modal .cred-copy-all .btn-sm { width: 100%; justify-content: center; }