大量修改UI
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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}` : ""}`);
|
||||
},
|
||||
|
||||
@@ -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)} 积分`}
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,52 +244,98 @@ 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>
|
||||
<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="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="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>
|
||||
</div>
|
||||
{isLocal && <span className="pcd-hint">团购按虚拟商品走主流程,无需实物三视图</span>}
|
||||
</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-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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="field-row">
|
||||
<section className="pcd-block" aria-labelledby="productImageTitle">
|
||||
<div className="pcd-block-head">
|
||||
<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>
|
||||
<strong id="productImageTitle">{isLocal ? "门店图 / 套餐海报" : "商品图片"}{isLocal ? "" : <span className="pcd-req"> *</span>}</strong>
|
||||
<p>最多上传九张商品图,两张及以上将自动切换为缩略图预览</p>
|
||||
</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 岁女性、敏感肌、办公室通勤"} />
|
||||
<span>JPG / PNG · 单张不超过 10MB</span>
|
||||
</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">
|
||||
<input ref={imgInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple hidden onChange={pickImage} />
|
||||
<div className="pcd-upload-grid">
|
||||
<div
|
||||
className={`pf-upload-zone${dragOver ? " is-dragover" : ""}`}
|
||||
className={`pcd-upload${dragOver ? " is-dragover" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPicker}
|
||||
@@ -271,112 +344,155 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
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 className="pcd-upload-hit">
|
||||
<span className="pcd-upload-icon"><Upload /></span>
|
||||
<strong>点击上传商品图片</strong>
|
||||
<small>{isLocal ? "店招、套餐海报均可 · 团购无实物可不传" : "最多九张,建议使用清晰商品实拍图"}</small>
|
||||
</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>
|
||||
重新上传
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
添加卖点
|
||||
<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>
|
||||
<ul className="bullet-list">
|
||||
<div className="pcd-points">
|
||||
{bullets.map((bullet, index) => (
|
||||
<li className="bl-item bl-item-edit" key={index}>
|
||||
<span className="num">{index + 1}</span>
|
||||
<div className="pcd-point" key={index}>
|
||||
<span className="pcd-point-idx">{padIndex(index)}</span>
|
||||
<input
|
||||
className="bl-input"
|
||||
value={bullet}
|
||||
placeholder={index === 0
|
||||
? (isLocal ? "例如:双人下午茶套餐,含蛋糕+饮品" : "例如:7 天改善干燥,敏感肌可用")
|
||||
: "继续填写一个核心卖点"}
|
||||
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 type="button" className="pcd-point-x" aria-label="删除卖点" disabled={bullets.length <= 1} onClick={() => removeBullet(index)}>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showGuide && (
|
||||
<div className="pc-guide-note">
|
||||
<strong>// 建好商品的 3 步</strong><br />
|
||||
① 填写商品名称 + 类目(电商/本地生活) + 品类<br />
|
||||
② 电商须上传清晰主图;本地生活/团购无实物可不传<br />
|
||||
③ 写 2–4 条核心卖点,越具体生成效果越好
|
||||
</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") ? "图片上传中…" : "创建商品"}
|
||||
<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="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 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>
|
||||
{/* YYX#24:填了内容中途退出 → 二次确认(复用通用 ConfirmModal) */}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmExit}
|
||||
title="商品未保存"
|
||||
subtitle="// UNSAVED"
|
||||
|
||||
detail="当前商品信息尚未保存,确定要退出编辑吗?"
|
||||
confirmText="退出编辑"
|
||||
onCancel={() => setConfirmExit(false)}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
/* 抽屉内汉堡键无意义,藏掉,留出顶部空间给页面自己的汉堡 */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
--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;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.library-page { margin: -28px -24px -48px; }
|
||||
}
|
||||
|
||||
.library-page .lib-head {
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 30px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
.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; }
|
||||
|
||||
.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);
|
||||
}
|
||||
.library-page .lib-seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.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); }
|
||||
|
||||
.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;
|
||||
}
|
||||
.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); }
|
||||
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;
|
||||
}
|
||||
|
||||
/* 编辑模式:开启「管理资产」后,批次卡/素材包卡删除按钮常显 */
|
||||
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;
|
||||
.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 .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-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 0.78;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #e6e7ea;
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* 吸底批量操作栏 · 仅 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-thumb.portrait {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 280px;
|
||||
}
|
||||
/* 显隐绑定「是否有选中」而非编辑模式 → 退选即消失 */
|
||||
.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-thumb img,
|
||||
.library-page .lib-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
.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-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; }
|
||||
|
||||
/* edit-toggle 激活态(管理资产 → 完成 高亮) */
|
||||
.library-page .btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }
|
||||
.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。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
@media (max-width: 1100px) {
|
||||
.projects-page { margin: -28px -24px -48px; }
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.vc-sec-h p { margin-top: 6px; font-size: 13px; color: var(--black-alpha-48); }
|
||||
}
|
||||
.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-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-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-card {
|
||||
min-width: 0;
|
||||
height: 240px;
|
||||
.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;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-floating), inset 0 0 0 1px var(--border-faint);
|
||||
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;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
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;
|
||||
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; }
|
||||
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-projects { margin-top: 8px; }
|
||||
.vc-toolbar {
|
||||
.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); }
|
||||
|
||||
.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;
|
||||
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);
|
||||
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: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
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 {
|
||||
}
|
||||
.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;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--black-alpha-40);
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
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;
|
||||
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;
|
||||
text-align: left;
|
||||
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-select-option:hover { background: #e8f2ff; }
|
||||
.projects-page .vc-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
|
||||
|
||||
.vc-grid {
|
||||
.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: 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;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.projects-page .vc-card {
|
||||
position: relative;
|
||||
background: var(--black-alpha-4);
|
||||
min-width: 0;
|
||||
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;
|
||||
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;
|
||||
color: var(--accent-black);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vc-proj-sub {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.projects-page .vc-meta p {
|
||||
margin: 0;
|
||||
color: var(--vc-muted);
|
||||
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 {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.projects-page .vc-line {
|
||||
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;
|
||||
gap: 10px;
|
||||
margin-top: 13px;
|
||||
}
|
||||
.projects-page .vc-tag {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.vc-proj-cta:hover { background: var(--black-alpha-7); }
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
.list-pager { margin-top: 20px; }
|
||||
|
||||
@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; }
|
||||
}
|
||||
@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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
<section className="login-page" aria-labelledby="loginTitle">
|
||||
{brand}
|
||||
<section className="auth-form" aria-label="注册">
|
||||
<div className="h-row"><h2>注册</h2><span className="sub">// /auth/register</span></div>
|
||||
<p className="lead">
|
||||
<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>
|
||||
<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" />
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
{inviteState.status !== "valid" && (
|
||||
<p className={`login-error${error ? " show" : ""}`} role="alert">{error || "\u00a0"}</p>
|
||||
)}
|
||||
</form>
|
||||
<p className="login-card-footer"><Lock />登录信息仅用于当前演示会话</p>
|
||||
</div>
|
||||
<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>
|
||||
<section className="login-page" aria-labelledby="loginTitle">
|
||||
{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" />
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
<p className="login-card-footer"><Lock />登录信息仅用于当前演示会话</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<label><input type="checkbox" checked={remember} onChange={(event) => setRemember(event.target.checked)} /> 记住我 7 天</label>
|
||||
<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="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 ? "登录中" : "登录"}
|
||||
<div className="login-sso">
|
||||
<button type="button" onClick={() => showToast("微信扫码", "对接中 · 当前请用账号 + 密码登录")}>
|
||||
<ScanLine /> 微信扫码
|
||||
</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" /> 微信扫码
|
||||
</button>
|
||||
<button type="button" className="sso-btn" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用用户名 + 密码登录")}>
|
||||
<ShieldCheck size={14} strokeWidth={1.6} aria-hidden="true" /> 飞书 SSO
|
||||
<button type="button" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用账号 + 密码登录")}>
|
||||
<ShieldCheck /> 飞书 SSO
|
||||
</button>
|
||||
</div>
|
||||
<div className="switch-row">
|
||||
<p className="login-switch">
|
||||
还没账号? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}>注册团队 →</a>
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{toastNode}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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="dash-create-group" key={group.label}>
|
||||
<div className="dash-create-label">{group.label}</div>
|
||||
<div className="dash-create-row">
|
||||
<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={`dash-create-card ${card.tone}`}
|
||||
className={`entry entry-${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} />
|
||||
<EntryIcon name={card.icon} />
|
||||
<span className="entry-copy">
|
||||
<strong>{card.title}</strong>
|
||||
<small>{card.desc}</small>
|
||||
</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
))}
|
||||
</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,40 +211,43 @@ export function Dashboard({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dash-mine-list">
|
||||
|
||||
{listed.length === 0 ? (
|
||||
<div className="dash-mine-empty">还没有项目,从上面开始创作</div>
|
||||
) : listed.map((project) => {
|
||||
<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="dash-proj-card"
|
||||
className="project-card"
|
||||
data-status={dashBucket(project)}
|
||||
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 className="product-thumb">
|
||||
{cover ? <img src={cover} alt="" /> : <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 className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
<p>{dashCardMeta(project, productTitle(project.product))}</p>
|
||||
</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">
|
||||
<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={dashProgClass(project, i, no)} />
|
||||
<span key={i} className={`segment ${dashProgClass(project, i, no)}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="dash-proj-cta"
|
||||
className="continue-button"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
@@ -237,6 +257,7 @@ export function Dashboard({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
<div className="sub">
|
||||
<span className="mono">// {total} 个视频</span> · AI 视频生成 · 全能参考 / 首尾帧
|
||||
<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()}
|
||||
|
||||
@@ -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,109 +652,62 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="library-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>资产库</h1>
|
||||
<div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</span></div>
|
||||
</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>
|
||||
</button>
|
||||
{/* 上传资产入口已隐藏:资产由 AI 生成流水线自动入库,不开放手动上传 */}
|
||||
</div>
|
||||
</div>
|
||||
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 ? "没有找到符合条件的资产,请调整筛选后重试" : "当前分类暂无真实资产");
|
||||
|
||||
<div className="tabs" id="asset-tabs">
|
||||
return (
|
||||
<section className={`library-page${editMode ? " manage-mode" : ""}`}>
|
||||
<header className="lib-head">
|
||||
<div>
|
||||
<h1>成品库</h1>
|
||||
<p>集中管理图片、视频与场景成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lib-toolbar">
|
||||
<div className="lib-seg" id="asset-tabs" role="tablist" aria-label="资产类型">
|
||||
{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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<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) => {
|
||||
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)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>
|
||||
<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="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" />}
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!srcFilter ? " selected" : ""}`} type="button" onClick={() => { setSrcFilter(""); setOpenChip(""); }}>全部来源</button>
|
||||
{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}
|
||||
</div>
|
||||
<button className={`lib-select-option${srcFilter === src ? " selected" : ""}`} type="button" key={src} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>{SOURCE_LABELS[src] || src}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,171 +715,221 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
}
|
||||
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>
|
||||
<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="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" />}
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!kindFilter ? " selected" : ""}`} type="button" onClick={() => { setKindFilter(""); setOpenChip(""); }}>全部类型</button>
|
||||
{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>
|
||||
<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") {
|
||||
// 关联商品:选项走 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>
|
||||
<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="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" />}
|
||||
<div className="lib-select-menu" role="listbox">
|
||||
<button className={`lib-select-option${!productFilter ? " selected" : ""}`} type="button" onClick={() => { setProductFilter(""); setOpenChip(""); }}>全部商品</button>
|
||||
{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>
|
||||
<button className={`lib-select-option${productFilter === p.id ? " selected" : ""}`} type="button" key={p.id} onClick={() => { setProductFilter(p.id); setOpenChip(""); }}>{p.title}</button>
|
||||
))}
|
||||
</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>
|
||||
<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="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" />}
|
||||
<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) => (
|
||||
<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>
|
||||
<button className={`lib-select-option${cur === v ? " selected" : ""}`} type="button" key={v} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
<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="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 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="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
|
||||
<div className="lib-section">
|
||||
<h2>{tabLabel}</h2>
|
||||
<p>按创作任务类型归档的最终资产 · 显示 {shownCount} / {shownTotal} {resultUnit}{hasFilter || (isVideoPackTab && debouncedQuery) ? "(已筛选)" : ""}{loading && tab !== "videopacks" ? " · 加载中…" : ""}</p>
|
||||
</div>
|
||||
|
||||
{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);
|
||||
{tab === "videopacks" ? (
|
||||
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={`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>
|
||||
<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 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 className="asset-body"><div className="asset-name">{batch.name}</div><div className="asset-meta mono">生成批次</div></div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pager page={packCurPage} total={filteredPacks.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
|
||||
</>
|
||||
)
|
||||
) : isBatchTab ? (
|
||||
batches.length ? (
|
||||
<>
|
||||
<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={`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 && (
|
||||
<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="placeholder asset-thumb pack-thumb">
|
||||
<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" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
<video src={first.url} muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<span className="ph-frame">无视频</span>
|
||||
)}
|
||||
<span className="pack-count mono">1 条</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="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono">视频自由创作</div></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="empty-filter">// 当前分类暂无真实资产</div>
|
||||
<div className="lib-empty" role="status"><Images /><strong>{emptyHint}</strong><span>对应创作流程生成后会自动入库</span></div>
|
||||
)
|
||||
) : shown.length ? (
|
||||
<div className="asset-grid" id="asset-grid">
|
||||
<>
|
||||
<div className="lib-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 && (
|
||||
<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" style={{ position: "relative" }}>
|
||||
<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>
|
||||
@@ -934,12 +946,11 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
)}
|
||||
{cover && isVideo && (
|
||||
<>
|
||||
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
|
||||
<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 && (
|
||||
// 音频没有可视封面,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>
|
||||
@@ -948,20 +959,43 @@ export function LibraryPage({ onUpload, onDeleteMany }: { onUpload: (formData: F
|
||||
{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>
|
||||
<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>
|
||||
</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="删除"
|
||||
|
||||
@@ -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>
|
||||
<section className="msg-inbox">
|
||||
<div className="msg-toolbar">
|
||||
<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>
|
||||
{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>
|
||||
<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="搜索项目、来源、内容" />
|
||||
<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 ? (
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
{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>}
|
||||
{(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>
|
||||
<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>
|
||||
|
||||
@@ -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) {
|
||||
async function remove(ids: string[]) {
|
||||
const failed = new Set<string>();
|
||||
for (const id of ids) {
|
||||
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);
|
||||
} 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>
|
||||
<p>维护可复用的品牌模特与人物参考资产</p>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="tabs">
|
||||
<div className="ml-toolbar">
|
||||
<div className="ml-seg" role="tablist" aria-label="模特来源">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.k} type="button" className={`tab${tab === t.k ? " active" : ""}`} onClick={() => setTab(t.k)}>
|
||||
<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="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"
|
||||
<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}
|
||||
onClick={() => setDetail(m)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetail(m); } }}
|
||||
aria-pressed={editMode && selectable ? isSelected : undefined}
|
||||
onClick={onCardClick}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onCardClick(); } }}
|
||||
>
|
||||
{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-portrait${m.portrait ? " has-mock-media" : ""}`}
|
||||
className={`placeholder ml-thumb${m.portrait ? " has-mock-media" : ""}`}
|
||||
style={m.portrait ? mediaStyle(m.portrait) : undefined}
|
||||
>
|
||||
{!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>
|
||||
<div className="ml-meta">
|
||||
<h3 title={m.name}>{m.name}</h3>
|
||||
<p>{m.description?.trim() || (m.source === "upload" ? "真人上传的人物参考" : "AI 生成的人物参考")}</p>
|
||||
<div
|
||||
className={`placeholder model-triview${m.triview ? " has-mock-media" : ""}`}
|
||||
className={`placeholder ml-triview${m.triview ? " has-mock-media" : ""}`}
|
||||
style={m.triview ? mediaStyle(m.triview) : undefined}
|
||||
>
|
||||
{!m.triview && <span className="ph-frame mono">// 无三视图</span>}
|
||||
{!m.triview && <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-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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,103 +206,102 @@ 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>
|
||||
))}
|
||||
</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>
|
||||
<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 className="chip-menu">
|
||||
{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>
|
||||
))}
|
||||
</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(""); }}>
|
||||
<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="pl-select-menu" role="listbox">
|
||||
{DATE_OPTS.map((opt) => (
|
||||
<button className={`pl-select-option${dateFilter === opt.value ? " selected" : ""}`} type="button" key={opt.value} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{hasFilters && (
|
||||
<button className="pl-clear" type="button" onClick={() => { setQuery(""); setCatFilter(new Set()); setDateFilter("all"); setTypeFilter("all"); setOpenChip(""); }}>
|
||||
<X size={13} /> 清空筛选
|
||||
</button>
|
||||
)}
|
||||
<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={`pl-view-btn${view === "list" ? " active" : ""}`} aria-pressed={view === "list"} title="列表视图" onClick={() => setView("list")}>
|
||||
<List />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
<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="product-grid-wrap">
|
||||
<div className={`product-grid${view === "list" ? " list-view" : ""}`} id="product-grid">
|
||||
<>
|
||||
<div className={`pl-grid${view === "list" ? " list" : ""}`} id="product-grid">
|
||||
{pageItems.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
@@ -296,31 +317,30 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
|
||||
))}
|
||||
</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" />
|
||||
<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="danger"
|
||||
className="pl-sel-confirm"
|
||||
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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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">
|
||||
<header className="nw-head">
|
||||
<div className="nw-title">
|
||||
<button className="nw-back" type="button" onClick={onBack} aria-label="返回视频创作">
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
<div>
|
||||
<h1>新建项目</h1>
|
||||
<div className="sub"><span className="mono">// 商品 → 配置 · 2 步开始生成</span></div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-ghost" type="button" onClick={onBack}>退出</button>
|
||||
<p>先选择本次项目使用的商品,下一步再配置内容方向与生成规格</p>
|
||||
</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={`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>
|
||||
<div>
|
||||
<div className="label">项目配置</div>
|
||||
<div className="desc">{name.trim() || "未命名"}</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>
|
||||
|
||||
<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 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>
|
||||
{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>
|
||||
清空筛选
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="nw-surface" aria-labelledby="nwSelectTitle">
|
||||
<div className="nw-select-head">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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="pp-empty">// NO MATCH<br />没有符合筛选条件的商品 <span className="reset" onClick={clearPickFilters}>[ 清空筛选 ]</span></div>
|
||||
) : (
|
||||
pageList.map((p) => {
|
||||
const coverUrl = productCoverUrl(p);
|
||||
<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 (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<Pager page={cur} total={total} pageSize={WIZ_PAGE_SIZE} onChange={setPickPage} />
|
||||
)}
|
||||
|
||||
<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 className="nw-config">
|
||||
<div className="nw-config-h">
|
||||
<h2>项目配置</h2>
|
||||
<p>基础配置只保留项目名与可选套路模板;脚本来源、风格和时长在下一步由脚本助手落定。</p>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && <EmptyPanel title="还没有商品" action="去创建商品" onAction={openCreateProduct} />}
|
||||
</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 套路模板 · 没存过模板就整块不出现,不给空下拉占位 */}
|
||||
<label className="nw-field">
|
||||
<span>项目名称<small>必填</small></span>
|
||||
<input value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
|
||||
</label>
|
||||
{templates.length > 0 && (
|
||||
<div className="field">
|
||||
<label className="field-label">套路模板(可选)</label>
|
||||
<select className="input" value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||
<label className="nw-field">
|
||||
<span>套路模板<small>可选</small></span>
|
||||
<select 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>
|
||||
{templates.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name} · {item.total_duration}s{item.usage_count ? ` · 用过 ${item.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 className="nw-tpl">
|
||||
<strong>同套路重出</strong>
|
||||
<p>沿用「{template.name}」的镜数、每镜作用和节奏。文案、画面、模特与故事板都会按 {product?.title || "所选商品"} 重新生成。</p>
|
||||
{template.outline_text ? <pre>{template.outline_text}</pre> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 底部「开始」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">
|
||||
<div>
|
||||
<h1>视频创作</h1>
|
||||
<p>从商品出发,一站完成电商短视频生产</p>
|
||||
<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,36 +603,37 @@ 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">
|
||||
<div className="vc-sec-h vc-list-h">
|
||||
<h2>视频项目</h2>
|
||||
<p>查看视频任务状态与生成结果</p>
|
||||
<p>统一查看各类视频任务的状态与生成结果</p>
|
||||
</div>
|
||||
|
||||
<div className="vc-toolbar">
|
||||
<div className="vc-pills" role="tablist" aria-label="项目状态">
|
||||
<div className="vc-seg" 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" : ""}`}
|
||||
className={`vc-seg-btn${tab === t.filter ? " active" : ""}`}
|
||||
key={t.filter}
|
||||
onClick={() => setTab(t.filter)}
|
||||
>
|
||||
@@ -643,44 +642,39 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
))}
|
||||
</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>
|
||||
<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="chip-menu">
|
||||
<div className="vc-select-menu" role="listbox">
|
||||
{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>
|
||||
<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={`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>
|
||||
<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="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" />}
|
||||
<div className="vc-select-menu" role="listbox">
|
||||
<button className={`vc-select-option${!catFilter ? " selected" : ""}`} type="button" onClick={() => { setCatFilter(""); setOpenChip(""); }}>全部分类</button>
|
||||
{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>
|
||||
<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={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
|
||||
<Grid2X2 size={14} />
|
||||
<button type="button" className={`vc-view-btn${view === "grid" ? " active" : ""}`} aria-label="网格显示" onClick={() => setView("grid")}>
|
||||
<Grid2X2 size={16} />
|
||||
</button>
|
||||
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
|
||||
<List size={14} />
|
||||
<button type="button" className={`vc-view-btn${view === "list" ? " active" : ""}`} aria-label="列表显示" onClick={() => setView("list")}>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -688,121 +682,108 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
|
||||
|
||||
{loading && projects.length === 0 ? (
|
||||
<SkeletonRows count={6} />
|
||||
) : view === "grid" ? (
|
||||
<div className="vc-grid">
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="vc-empty" role="status">
|
||||
<VideoOff />
|
||||
<strong>{projects.length === 0 ? "还没有视频项目" : "没有符合条件的视频项目"}</strong>
|
||||
<span>{projects.length === 0 ? "从上方入口开始生成" : "请调整状态、时间、分类或搜索关键词后重试"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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 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>
|
||||
);
|
||||
})}
|
||||
</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);
|
||||
};
|
||||
const statusLabel = bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中";
|
||||
return (
|
||||
<article
|
||||
className={`vc-card project-card${isSel ? " selected" : ""}`}
|
||||
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}
|
||||
onClick={openProject}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
|
||||
>
|
||||
{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 && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<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 ? "删除中…" : "删除所选"}
|
||||
<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>
|
||||
<button type="button" onClick={() => setEditMode(false)}>完成</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "消息",
|
||||
|
||||
@@ -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: [
|
||||
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
|
||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||
{ key: "security", label: "安全", icon: <ShieldCheck /> },
|
||||
{ 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,131 +403,100 @@ 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) => {
|
||||
{NAV.map((item) => {
|
||||
const badge = navBadge(item.key);
|
||||
const dirty = dirtySections.has(item.key);
|
||||
return (
|
||||
<a
|
||||
<button
|
||||
key={item.key}
|
||||
href={`#sec-${item.key}`}
|
||||
className={`${section === item.key ? "active" : ""}${dirty ? " has-changes" : ""}`}
|
||||
type="button"
|
||||
className={`st-tab${section === item.key ? " active" : ""}${dirty ? " has-changes" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={section === item.key}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setSection(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" />
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
||||
<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">
|
||||
<SettingRow title="头像" hint="支持上传 JPG、PNG 图片">
|
||||
<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>
|
||||
<button className="st-ghost" type="button" onClick={openAvatarModal}>上传新头像</button>
|
||||
<button className="st-text" type="button" onClick={() => onResetAvatar?.()}>恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
</SettingRow>
|
||||
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
|
||||
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
|
||||
<span className="switch-note">短信 + Authenticator</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="sub-head">在用设备</h3>
|
||||
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</div>
|
||||
</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>
|
||||
<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);
|
||||
@@ -527,35 +511,39 @@ export function SettingsPage({
|
||||
</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 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>}
|
||||
: <button className="st-ghost st-ghost-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">
|
||||
<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 })} />
|
||||
<span className="switch-note">{row.channels}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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={
|
||||
|
||||
@@ -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,182 +347,229 @@ 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>
|
||||
</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 className="tc-hero">
|
||||
<div className="tc-identity">
|
||||
<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>
|
||||
<span className="tc-team-tag">TEAM</span>
|
||||
<h2>{team.name}</h2>
|
||||
<p>企业团队 · {rows.length} 名成员</p>
|
||||
</div>
|
||||
<p>团队 ID:{team.id}</p>
|
||||
</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 className="tc-metric">
|
||||
<span>团队余额</span>
|
||||
<strong>{money(balance)}</strong>
|
||||
<small>团队总池</small>
|
||||
</div>
|
||||
<div className="ts">{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}</div>
|
||||
<div className="tc-metric">
|
||||
<span>月限额</span>
|
||||
<strong id="stat-limit">{money(limit)}</strong>
|
||||
<small>自然月重置</small>
|
||||
</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>
|
||||
|
||||
</div>{/* /.team-top */}
|
||||
|
||||
<div className="team-grid">
|
||||
{/* 左:成员表 */}
|
||||
<section className="tc-section" aria-labelledby="teamCollaborationTitle">
|
||||
<div className="tc-section-title">
|
||||
<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)} />
|
||||
<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>
|
||||
<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 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 (
|
||||
<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>
|
||||
<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="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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
<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} style={row.last ? { borderBottom: 0 } : undefined}>
|
||||
<tr key={row.cap}>
|
||||
<td>{row.cap}</td>
|
||||
{row.cells.map((cell, index) => (
|
||||
<td key={index} className={cell === "✓" ? "yes" : "no"}>{cell}</td>
|
||||
@@ -497,14 +579,13 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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>}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||