完善前端UI

This commit is contained in:
Azmat@qq.com
2026-08-20 10:41:28 +08:00
parent dcd9bf6b5e
commit b883fed981
15 changed files with 1182 additions and 583 deletions
+13 -30
View File
@@ -21,7 +21,7 @@ import type {
} from "./types";
import { publicModelDisplayName } from "./model-display";
import { generationErrorText } from "./generation-error";
import { AccountMenu, CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { SystemLoading } from "./components/loading";
import {
AccountPage,
@@ -1132,23 +1132,7 @@ export function App() {
<main>
<Decorations />
<header className="topbar">
<div className="yz-mods" role="tablist" aria-label="创作模块">
<button
className={`yz-mod${topModule === "workbench" ? " active" : ""}`}
type="button"
onClick={() => navigate("dashboard")}
></button>
<button
className={`yz-mod${topModule === "image" ? " active" : ""}`}
type="button"
onClick={() => navigate("assetFactory")}
></button>
<button
className={`yz-mod${topModule === "video" ? " active" : ""}`}
type="button"
onClick={() => navigate("projects")}
></button>
</div>
<ModeTabs active={topModule} navigate={navigate} />
<div className="right">
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
@@ -1178,23 +1162,22 @@ export function App() {
</button>
</div>
</header>
<div className="content" id="page-content">
<div className="content" id="page-content" key={page}>
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{pipelinePage || renderPage()}
</div>
</main>
{accountAnchor && (
<AccountMenu
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={logout}
user={currentUser}
team={currentTeam}
canManageBilling={isOwner}
/>
)}
<AccountMenu
open={accountAnchor !== null}
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={logout}
user={currentUser}
team={currentTeam}
canManageBilling={isOwner}
/>
</div>
);
}
+304 -108
View File
@@ -1,117 +1,243 @@
/* 账户页 · 对照影擎页头(返回方钮 + 大标题 + 灰摘要)。仅 scope 在 .account-page,不改共享 .btn/.pill/.input */
/* 账单库 · 影擎没有独立页,按商品库/团队中心的页头、深色指标卡、data-panel 风格排。不改共享 .btn/.pill/.input */
.account-page {
--klein: #002fa7;
--klein-hover: #002680;
--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;
width: 100%;
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
.page-head {
.ac-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 14px;
margin-bottom: 22px;
flex-wrap: nowrap;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
}
.ac-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.ac-head p {
margin: 0;
color: var(--st-muted);
font-size: 15px;
}
.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;
.ac-top {
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;
grid-template-columns: minmax(0, 1.2fr) minmax(430px, 0.9fr);
gap: 20px;
margin-bottom: 28px;
align-items: stretch;
}
.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; }
@media (max-width: 1120px) { .ac-top { grid-template-columns: 1fr; } }
.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: #202124;
color: #fff;
padding: 26px 28px;
position: relative;
border: 0;
border-radius: 14px;
.ac-hero {
min-height: 270px;
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
min-height: 246px;
justify-content: space-between;
padding: 28px;
border-radius: 14px;
color: #fff;
background: #202124;
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 {
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;
.ac-hero > span,
.ac-hero-stats span,
.ac-progress-meta {
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
}
.ac-hero > strong {
display: block;
margin: 8px 0 6px;
font-size: 38px;
font-weight: 600;
letter-spacing: -.02em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.ac-hero > em {
font-style: normal;
color: rgba(255, 255, 255, 0.46);
font-size: 12px;
}
.ac-hero-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
margin: 22px 0 18px;
}
.ac-hero-stats strong {
display: block;
margin-top: 6px;
font-size: 19px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ac-progress {
height: 6px;
overflow: hidden;
border-radius: 99px;
background: rgba(255, 255, 255, 0.15);
}
.ac-progress > span {
display: block;
min-width: 7px;
height: 100%;
background: var(--klein);
}
.ac-progress-meta {
display: flex;
justify-content: space-between;
gap: 14px;
margin-top: 10px;
}
.balance-banner::before { top: -7px; left: -7px; }
.balance-banner::after { bottom: -7px; right: -7px; }
.balance-banner > .corner-tr { top: -7px; right: -7px; }
.balance-banner > .corner-bl { bottom: -7px; left: -7px; }
.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: 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; }
.ac-recharge {
padding: 22px;
border: 1px solid var(--st-line);
border-radius: 14px;
background: var(--st-wash);
box-shadow: var(--st-shadow);
display: flex;
flex-direction: column;
}
.ac-recharge h2 {
margin: 0 0 5px;
font-size: 18px;
font-weight: 600;
color: var(--st-text);
}
.ac-recharge > p {
margin: 0 0 18px;
color: var(--st-muted);
font-size: 12px;
}
.ac-recharge-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.ac-option {
min-height: 66px;
padding: 10px;
border: 1px solid rgba(34, 42, 54, 0.10);
border-radius: 10px;
background: rgba(34, 42, 54, 0.035);
cursor: pointer;
text-align: center;
position: relative;
}
.ac-option:hover { background: rgba(34, 42, 54, 0.06); }
.ac-option.active {
border-color: var(--klein);
background: rgba(0, 47, 167, 0.08);
}
.ac-option strong,
.ac-option span { display: block; }
.ac-option strong {
font-size: 16px;
font-weight: 600;
color: var(--st-text);
font-variant-numeric: tabular-nums;
}
.ac-option span {
margin-top: 4px;
color: #16a34a;
font-size: 10px;
}
.ac-option span.plain { color: var(--st-muted); }
.ac-option .ribbon {
position: absolute;
top: 6px;
left: 7px;
font-size: 9px;
padding: 1px 5px;
background: var(--klein);
color: #fff;
font-weight: 600;
border-radius: 4px;
}
.ac-pay-title {
margin: 16px 0 8px;
font-size: 12px;
font-weight: 600;
color: var(--st-text);
}
.ac-recharge .input {
width: 100%;
height: 42px;
margin: 0;
padding: 0 13px;
border: 1px solid rgba(34, 42, 54, 0.11);
border-radius: 10px;
background: #fff;
}
.ac-pay-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.ac-pay {
height: 42px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
background: #fff;
color: var(--st-text);
display: inline-flex;
justify-content: center;
align-items: center;
gap: 8px;
font: inherit;
font-size: 14px;
cursor: pointer;
}
.ac-pay:hover { background: rgba(34, 42, 54, 0.05); }
.ac-pay .pay-logo {
width: 18px;
height: 18px;
border-radius: 6px;
overflow: hidden;
flex: 0 0 18px;
}
.ac-pay .pay-logo img { width: 100%; height: 100%; display: block; object-fit: cover; }
.ac-tip {
margin-top: 14px;
font-size: 12px;
color: var(--st-muted);
line-height: 1.65;
}
.ac-tip strong { color: var(--st-text); font-weight: 600; }
@media (max-width: 720px) { .ac-recharge-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.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: 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; }
.pane {
background: var(--st-wash);
border: 1px solid var(--st-line);
border-radius: 14px;
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--st-shadow);
}
.pane h3 { font-size: 18px; font-weight: 600; margin: 0 0 6px; color: var(--st-text); }
.pane .desc { font-size: 12px; color: var(--st-muted); margin-bottom: 14px; }
.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(--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: #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(--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(--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(--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(--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: 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: center; gap: 8px; border-bottom: 0; margin: 8px 0 18px; padding: 0; overflow-x: auto; scrollbar-width: none; }
.billing-tabs { display: flex; align-items: center; gap: 8px; border-bottom: 0; margin: 0 0 18px; padding: 0; overflow-x: auto; scrollbar-width: none; }
.billing-tabs::-webkit-scrollbar { display: none; }
.ac-tab {
display: inline-flex;
@@ -143,33 +269,103 @@
.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(--st-muted); letter-spacing: .02em; }
.trend-pane { padding: 0; display: flex; flex-direction: column; overflow: hidden; }
.trend-head {
min-height: 64px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
margin-bottom: 0;
border-bottom: 1px solid rgba(34, 42, 54, 0.08);
}
.trend-head h3 { margin-bottom: 0; font-size: 18px; }
.trend-head .sub { font-size: 12px; color: var(--st-muted); }
.trend-head .spacer { flex: 1; }
.ac-seg {
height: 30px;
padding: 0 12px;
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 8px;
color: var(--st-muted);
background: rgba(34, 42, 54, 0.055);
border-radius: 9px;
color: #50555d;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 12px;
font-size: 14px;
}
.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: 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; }
.ac-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 18px 20px 2px;
}
.ac-summary div {
padding: 14px;
border-radius: 11px;
background: rgba(0, 47, 167, 0.055);
}
.ac-summary span,
.ac-summary strong { display: block; }
.ac-summary span { color: var(--st-muted); font-size: 11px; }
.ac-summary strong { margin-top: 7px; font-size: 16px; font-weight: 600; color: var(--st-text); }
.trend-chart {
position: relative;
height: 220px;
display: flex;
align-items: flex-end;
gap: 12px;
padding: 30px 28px 22px;
min-height: 0;
}
.trend-chart::after {
content: "";
position: absolute;
right: 28px;
bottom: 42px;
left: 28px;
height: 1px;
background: rgba(34, 42, 54, 0.12);
}
.ac-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;
}
.ac-bar {
width: min(30px, 70%);
min-height: 10px;
border-radius: 7px 7px 3px 3px;
background: var(--klein);
opacity: 0.78;
}
.ac-bar-value {
color: #343a45;
font-size: 10px;
font-weight: 600;
}
.ac-bar-group span { overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.trend-chart-empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: var(--st-muted);
font-size: 13px;
}
.trend-foot { display: flex; gap: 14px; margin: 0 20px 16px; 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(--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); }
+292 -86
View File
@@ -1,8 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AlertCircle, Boxes, Check, Info, LogOut, Settings, UsersRound } from "lucide-react";
import { AlertCircle, Boxes, Check, ChevronDown, Info, LogOut, Settings, UsersRound } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock } from "./overlays";
import { useBodyScrollLock, useOverlayTransition } from "./overlays";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -18,8 +18,8 @@ const SHELL_COMMANDS: Command[] = [
{ 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: "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" },
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
{ id: "settings", group: "导航", label: "系统设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" },
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
@@ -30,6 +30,7 @@ const SHELL_COMMANDS: Command[] = [
function CommandPalette({ open, onClose, navigate, canManageBilling = true }: { open: boolean; onClose: () => void; navigate: Navigate; canManageBilling?: boolean }) {
const [query, setQuery] = useState("");
const { mounted, show } = useOverlayTransition(open, onClose, 220);
useBodyScrollLock(open);
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
@@ -40,13 +41,13 @@ function CommandPalette({ open, onClose, navigate, canManageBilling = true }: {
.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
}, [query, canManageBilling]);
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
if (!open) return null;
if (!mounted) return null;
let lastGroup = "";
return createPortal(
<div
id="shell-command-bg"
className="shell-command-bg show"
aria-hidden="false"
className={`shell-command-bg${show ? " show" : ""}`}
aria-hidden={!show}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
@@ -108,11 +109,12 @@ const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
{ act: "settings", icon: "settings", label: "个人设置" },
{ act: "messages", icon: "bell", label: "消息中心" },
{ act: "team", icon: "users", label: "团队管理" },
{ act: "account", icon: "creditCard", label: "消费与余额" }
{ act: "account", icon: "creditCard", label: "账单库" }
];
export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
anchorRect: DOMRect;
export function AccountMenu({ open, anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
open: boolean;
anchorRect: DOMRect | null;
onClose: () => void;
navigate: Navigate;
logout: () => void;
@@ -120,24 +122,32 @@ export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team,
team: Team | null;
canManageBilling?: boolean;
}) {
const { mounted, show } = useOverlayTransition(open, undefined, 180);
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 });
const lastRect = useRef<DOMRect | null>(anchorRect);
if (anchorRect) lastRect.current = anchorRect;
const rect = lastRect.current;
const [pos, setPos] = useState<{ left: number; top: number }>(() => (
rect ? { left: rect.left, top: rect.bottom + 8 } : { left: 0, top: 0 }
));
// 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致
useLayoutEffect(() => {
if (!rect) return;
const menu = menuRef.current;
const width = menu?.offsetWidth || 232;
const height = menu?.offsetHeight || 260;
let left = anchorRect.right - width;
if (left < 12) left = anchorRect.left;
let left = rect.right - width;
if (left < 12) left = rect.left;
left = Math.min(Math.max(12, left), window.innerWidth - width - 12);
let top = anchorRect.bottom + 8;
if (top + height > window.innerHeight - 12) top = Math.max(12, anchorRect.top - height - 8);
let top = rect.bottom + 8;
if (top + height > window.innerHeight - 12) top = Math.max(12, rect.top - height - 8);
setPos({ left, top });
}, [anchorRect]);
}, [rect]);
// 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉)
useEffect(() => {
if (!open) return;
const onDown = (event: MouseEvent) => {
const target = event.target as Node;
if (menuRef.current?.contains(target)) return;
@@ -151,15 +161,16 @@ export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team,
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
}, [open, onClose]);
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
const go = (page: Page) => { onClose(); navigate(page); };
if (!mounted || !rect) return null;
return createPortal(
<div
ref={menuRef}
className="shell-account-menu show"
className={`shell-account-menu${show ? " show" : ""}`}
id="shell-account-menu"
role="menu"
aria-label="账户菜单"
@@ -199,19 +210,31 @@ const NAV: NavDef[] = [
{ id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" },
{ id: "library", page: "library", label: "成品库", icon: "library" },
{ id: "team", page: "team", label: "团队", icon: "users" },
{ id: "account", page: "account", label: "消费", icon: "creditCard" },
{ id: "account", page: "account", label: "账单库", icon: "creditCard" },
{ id: "trash", page: "trash", label: "垃圾桶", icon: "trash" },
{ id: "settings", page: "settings", label: "设置", icon: "settings" }
{ id: "settings", page: "settings", label: "系统设置", icon: "settings" }
];
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: "account", page: "account", label: "消费" },
{ id: "account", page: "account", label: "账单库" },
{ id: "trash", page: "trash", label: "垃圾桶" },
];
type MajorNav = "resource" | "team" | "settings";
const MAJOR_META: Record<MajorNav, { label: string; icon: "boxes" | "users-round" | "settings" }> = {
resource: { label: "资源中心", icon: "boxes" },
team: { label: "团队中心", icon: "users-round" },
settings: { label: "系统设置", icon: "settings" },
};
function LiquidIcon({ name }: { name: "boxes" | "users-round" | "settings" }) {
if (name === "boxes") return <Boxes size={20} strokeWidth={1.9} />;
if (name === "users-round") return <UsersRound size={20} strokeWidth={1.9} />;
return <Settings size={20} strokeWidth={1.9} />;
}
export type TopModule = "workbench" | "image" | "video";
const OPEN_PALETTE_EVENT = "airshelf:open-palette";
@@ -233,6 +256,102 @@ export function topModuleForPage(page: Page): TopModule | null {
return null;
}
const MODE_TABS: { id: TopModule; label: string; page: Page }[] = [
{ id: "workbench", label: "工作台", page: "dashboard" },
{ id: "image", label: "图片创作", page: "assetFactory" },
{ id: "video", label: "视频创作", page: "projects" },
];
// 顶栏三按钮滑动黑胶囊 · 对照影擎 .mode-tabs / moveModeIndicator 原样接入:
// clip-path 只露出三个按钮形状,胶囊在缝里看不见;位移走 transform,不在 React state 里改尺寸。
export function ModeTabs({ active, navigate }: { active: TopModule | null; navigate: Navigate }) {
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const clipRectsRef = useRef<Array<SVGRectElement | null>>([]);
const indicatorRef = useRef<HTMLSpanElement>(null);
const firstPaint = useRef(true);
const moveModeIndicator = (tab: HTMLButtonElement, animate = true) => {
btnRefs.current.forEach((item, index) => {
const rect = clipRectsRef.current[index];
if (!item || !rect) return;
rect.setAttribute("x", String(item.offsetLeft));
rect.setAttribute("y", "0");
rect.setAttribute("width", String(item.offsetWidth));
rect.setAttribute("height", String(item.offsetHeight));
});
const modeIndicator = indicatorRef.current;
if (!modeIndicator) return;
if (!animate) modeIndicator.style.transition = "none";
modeIndicator.style.width = `${tab.offsetWidth}px`;
modeIndicator.style.transform = `translateX(${tab.offsetLeft}px)`;
if (!animate) {
void modeIndicator.offsetWidth;
modeIndicator.style.transition = "";
}
};
const activeButton = () => {
const idx = active ? MODE_TABS.findIndex((tab) => tab.id === active) : -1;
return idx >= 0 ? btnRefs.current[idx] : null;
};
useLayoutEffect(() => {
const tab = activeButton();
if (tab) moveModeIndicator(tab, !firstPaint.current);
firstPaint.current = false;
}, [active]);
useEffect(() => {
const snap = () => {
const tab = activeButton();
if (tab) moveModeIndicator(tab, false);
};
window.addEventListener("resize", snap);
void document.fonts?.ready.then(snap);
return () => window.removeEventListener("resize", snap);
}, [active]);
return (
<div
className={`yz-mods${active ? "" : " no-active"}`}
role="tablist"
aria-label="创作模块"
>
<svg className="yz-mod-clip-defs" width="0" height="0" aria-hidden="true" focusable="false">
<defs>
<clipPath id="yzModeButtonMask" clipPathUnits="userSpaceOnUse">
<rect ref={(el) => { clipRectsRef.current[0] = el; }} rx="22" ry="22" x="0" y="0" width="104" height="44" />
<rect ref={(el) => { clipRectsRef.current[1] = el; }} rx="22" ry="22" x="120" y="0" width="110" height="44" />
<rect ref={(el) => { clipRectsRef.current[2] = el; }} rx="22" ry="22" x="246" y="0" width="110" height="44" />
</clipPath>
</defs>
</svg>
<span className="yz-mod-indicator-mask" aria-hidden="true">
<span
ref={indicatorRef}
className="yz-mod-indicator"
style={{ width: 104, transform: "translateX(0px)" }}
/>
</span>
{MODE_TABS.map((tab, index) => (
<button
key={tab.id}
ref={(el) => { btnRefs.current[index] = el; }}
className={`yz-mod${active === tab.id ? " active" : ""}`}
type="button"
role="tab"
aria-selected={active === tab.id}
onClick={() => {
const btn = btnRefs.current[index];
if (btn) moveModeIndicator(btn, true);
navigate(tab.page);
}}
>{tab.label}</button>
))}
</div>
);
}
const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
dashboard: "dashboard",
products: "products",
@@ -252,6 +371,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
library: "library",
team: "team",
account: "account",
trash: "trash",
settings: "settings",
settingsNotify: "settings"
};
@@ -276,7 +396,7 @@ export function Sidebar({ page, navigate, user, canManageBilling = true, product
const navItems = NAV.filter((item) => canManageBilling || (item.page !== "team" && item.page !== "account"));
const resourceNavItems = RESOURCE_NAV.filter((item) => navItems.some((nav) => nav.page === item.page));
const topModule = topModuleForPage(page);
const activeMajor: "resource" | "team" | "settings" | null =
const activeMajor: MajorNav | null =
activeNav === "team" ? "team"
: activeNav === "settings" ? "settings"
: topModule ? null
@@ -305,12 +425,72 @@ export function Sidebar({ page, navigate, user, canManageBilling = true, product
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mobileNavOpen]);
// 二级类目展开/收起:进资源中心时展开;切到顶栏模块(工作台/图片/视频)时收起,避免左边再出现选中
const [resourceOpen, setResourceOpen] = useState(activeMajor === "resource");
// 二级类目:跟当前大类走,同一帧收起,避免液态胶囊先飞到「展开后的位置」再追回来
const [resourcePinned, setResourcePinned] = useState(true);
useEffect(() => {
if (activeMajor === "resource") setResourceOpen(true);
else if (topModule) setResourceOpen(false);
}, [activeMajor, topModule]);
if (activeMajor === "resource") setResourcePinned(true);
}, [activeMajor]);
const resourceOpen = activeMajor === "resource" && resourcePinned;
const resourceItemRef = useRef<HTMLButtonElement>(null);
const teamItemRef = useRef<HTMLButtonElement>(null);
const settingsItemRef = useRef<HTMLButtonElement>(null);
const submenuRef = useRef<HTMLDivElement>(null);
const prevMajorRef = useRef<MajorNav | null>(activeMajor);
const [liquid, setLiquid] = useState({ y: 0, hidden: true, pulse: false });
const skipPulse = useRef(true);
useLayoutEffect(() => {
const item = activeMajor === "resource" ? resourceItemRef.current
: activeMajor === "team" ? teamItemRef.current
: activeMajor === "settings" ? settingsItemRef.current
: null;
const leavingResource = prevMajorRef.current === "resource" && activeMajor !== "resource";
prevMajorRef.current = activeMajor;
const targetY = (el: HTMLElement, subtractSubmenu: boolean) => {
let top = el.offsetTop;
if (subtractSubmenu && submenuRef.current) {
const style = window.getComputedStyle(submenuRef.current);
const space = submenuRef.current.offsetHeight
+ (Number.parseFloat(style.marginTop) || 0)
+ (Number.parseFloat(style.marginBottom) || 0);
if (space > 1) top -= space;
}
return top - Math.max(0, (76 - el.offsetHeight) / 2);
};
if (!item) {
setLiquid((prev) => ({ ...prev, hidden: true, pulse: false }));
skipPulse.current = false;
return;
}
const animate = !skipPulse.current;
skipPulse.current = false;
setLiquid({
y: targetY(item, leavingResource),
hidden: false,
pulse: animate,
});
const onResize = () => {
const current = activeMajor === "resource" ? resourceItemRef.current
: activeMajor === "team" ? teamItemRef.current
: activeMajor === "settings" ? settingsItemRef.current
: null;
if (!current) return;
setLiquid((prev) => ({ ...prev, y: targetY(current, false), pulse: false }));
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [activeMajor, canManageBilling]);
useEffect(() => {
if (!liquid.pulse) return;
const timer = window.setTimeout(() => setLiquid((prev) => ({ ...prev, pulse: false })), 520);
return () => window.clearTimeout(timer);
}, [liquid.pulse]);
// 命令面板:Ctrl/Cmd K,以及顶栏搜索按钮
const [paletteOpen, setPaletteOpen] = useState(false);
@@ -354,64 +534,90 @@ export function Sidebar({ page, navigate, user, canManageBilling = true, product
<span className="sidebar-toggle-icon sidebar-toggle-icon--collapse"><IconKitSvg name="chevronLeft" size={18} strokeWidth={1.8} /></span>
<span className="sidebar-toggle-icon sidebar-toggle-icon--expand"><IconKitSvg name="chevronRight" size={18} strokeWidth={1.8} /></span>
</button>
<div className="yz-side-main">
<button
className={`yz-resource-head${activeMajor === "resource" ? " active" : ""}`}
type="button"
aria-expanded={resourceOpen}
onClick={() => {
// 在资源中心内点击=折叠/展开;在其它大类点击=切回资源中心并展开
if (activeMajor !== "resource") {
setResourceOpen(true);
navigate("products");
return;
}
setResourceOpen((value) => !value);
}}
>
<span className="major-dot" aria-hidden="true" />
<Boxes size={16} strokeWidth={2} />
<span></span>
<span className="chev"><IconKitSvg name="chevronRight" /></span>
</button>
<div className={`yz-resource-sub${resourceOpen ? " open" : " collapsed"}`}>
{resourceNavItems.map((item) => (
<a
key={item.id}
href={`/${item.id}`}
className={activeNav === item.page ? "active" : ""}
title={item.label}
aria-label={item.label}
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate(item.page); }}
<div className="nav-panel">
<div className="nav-list">
<div
className={`liquid-indicator${liquid.hidden ? " is-hidden" : ""}${activeMajor && MAJOR_META[activeMajor].label.length >= 4 ? " wide-label" : ""}${liquid.pulse ? " pulse" : ""}`}
aria-hidden="true"
style={{ transform: `translateY(${liquid.y}px)` }}
>
<svg className="liquid-shape" viewBox="0 0 132 76" preserveAspectRatio="none">
<path fill="#101012" d="M0 11H103C120 11 130 22 130 38C130 54 120 65 103 65H0Z" />
</svg>
{activeMajor && (
<div className="indicator-content">
<span className="indicator-icon-slot"><LiquidIcon name={MAJOR_META[activeMajor].icon} /></span>
<span>{MAJOR_META[activeMajor].label}</span>
</div>
)}
</div>
<button
ref={resourceItemRef}
className={`nav-item${activeMajor === "resource" ? " active" : ""}`}
type="button"
aria-expanded={resourceOpen}
aria-controls="resourceSubmenu"
onClick={() => {
if (activeMajor !== "resource") {
setResourcePinned(true);
navigate("products");
return;
}
setResourcePinned((value) => !value);
}}
>
<span className="nav-inner">
<Boxes size={23} strokeWidth={1.8} />
<span className="nav-label"></span>
<ChevronDown className="nav-chevron" size={17} strokeWidth={1.8} />
</span>
</button>
<div
ref={submenuRef}
id="resourceSubmenu"
className={`nav-submenu${resourceOpen ? " expanded" : ""}`}
aria-label="资源中心二级导航"
aria-hidden={!resourceOpen}
>
{resourceNavItems.map((item) => (
<button
key={item.id}
type="button"
className={`nav-subitem${activeNav === item.page ? " active" : ""}`}
onClick={() => { setMobileNavOpen(false); navigate(item.page); }}
>
{item.label}
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
</button>
))}
</div>
{canManageBilling && (
<button
ref={teamItemRef}
className={`nav-item${activeMajor === "team" ? " active" : ""}`}
type="button"
onClick={() => { setMobileNavOpen(false); navigate("team"); }}
>
<span className="dot" />
<span>{item.label}</span>
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
</a>
))}
<span className="nav-inner">
<UsersRound size={23} strokeWidth={1.8} />
<span className="nav-label"></span>
</span>
</button>
)}
<button
ref={settingsItemRef}
className={`nav-item${activeMajor === "settings" ? " active" : ""}`}
type="button"
onClick={() => { setMobileNavOpen(false); navigate("settings"); }}
>
<span className="nav-inner">
<Settings size={23} strokeWidth={1.8} />
<span className="nav-label"></span>
</span>
</button>
</div>
<a
className={`yz-side-link${activeMajor === "team" ? " active" : ""}`}
href="/team"
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate("team"); }}
>
<span className="major-dot" aria-hidden="true" />
<UsersRound size={18} strokeWidth={2} />
<span></span>
</a>
<a
className={`yz-side-link${activeMajor === "settings" ? " active" : ""}`}
href="/settings"
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate("settings"); }}
>
<span className="major-dot" aria-hidden="true" />
<Settings size={18} strokeWidth={2} />
<span></span>
</a>
</div>
{user.is_platform_admin && onOpenAdmin && (
<>
<div className="nav-section"></div>
{user.is_platform_admin && onOpenAdmin && (
<nav>
<a
href="/admin"
@@ -423,8 +629,8 @@ export function Sidebar({ page, navigate, user, canManageBilling = true, product
<span></span>
</a>
</nav>
</>
)}
)}
</div>
</aside>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} canManageBilling={canManageBilling} />
</>
+385 -201
View File
@@ -49,6 +49,9 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* 比影擎 164px 略宽:我们子项带数量徽章,164 会挤 */
--sidebar-width: 192px;
/* ===== Backgrounds (冷灰 · YZ 影擎) ===== */
--background-base: #f7f8fa;
--background-lighter: #fbfbfc;
@@ -217,7 +220,7 @@ img, svg, video { display: block; max-width: 100%; }
/* ─── App shell ─── */
.app {
display: grid;
grid-template-columns: 164px 1fr;
grid-template-columns: var(--sidebar-width) 1fr;
min-height: 100vh;
transition: grid-template-columns var(--t-base);
}
@@ -225,22 +228,26 @@ body.sidebar-collapsed .app { grid-template-columns: 96px 1fr; }
/* ─── Sidebar ─── */
aside.sidebar {
padding: 0 16px 22px;
border-right: 1px solid var(--border-faint);
background: #f5f6f8;
--klein: #002fa7;
padding: 0;
border-right: 1px solid rgba(27, 32, 40, 0.08);
background: rgba(28, 34, 43, 0.035);
position: sticky;
top: 0;
align-self: start;
height: 100vh;
overflow-y: auto;
overflow: hidden;
display: flex;
flex-direction: column;
transition: padding var(--t-base);
}
.sidebar-head {
position: relative;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
margin: 0 -16px;
margin: 0;
padding: 0;
height: 116px;
min-height: 116px;
@@ -252,19 +259,20 @@ aside.sidebar {
.brand { display: flex; align-items: center; justify-content: center; min-width: 0; color: var(--accent-black); }
.brand-clip {
display: block;
width: 132px;
height: 40px;
width: 112px;
height: 90px;
overflow: hidden;
transition: width var(--t-base), height var(--t-base);
}
.brand-logo {
display: block;
width: 132px;
height: 40px;
width: 112px;
height: 90px;
max-width: none;
margin: 0;
object-fit: contain;
object-position: left center;
object-position: center;
mix-blend-mode: multiply;
}
.brand-mark, .flame { width: 22px; height: 22px; color: var(--accent-black); }
.brand-mark svg, .flame svg { width: 100%; height: 100%; }
@@ -272,7 +280,7 @@ aside.sidebar {
.sidebar-toggle {
position: fixed;
top: 0;
left: 136px;
left: calc(var(--sidebar-width) - 28px);
z-index: 70;
width: 28px;
height: 100vh;
@@ -395,180 +403,226 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
.user .av img { width: 100%; height: 100%; object-fit: cover; display: block; }
.user .em { font-size: 13px; color: var(--accent-black); }
/* ─── YZ 左侧导航预览(资源中心胶囊 + 子库) ─── */
/* ─── 影擎侧栏 · 从 UI 源码原样接入(液态胶囊 + nav-item) ─── */
.sidebar-toggle,
.sidebar .search-box,
.nav-section {
display: none;
}
.yz-side-main {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 6px;
}
.yz-resource-head {
height: 48px;
margin-left: -16px;
margin-right: 10px;
border: 0;
border-radius: 0 24px 24px 0;
background: transparent;
color: #17191d;
display: flex;
align-items: center;
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 {
width: 7px;
height: 7px;
border-radius: 50%;
background: transparent;
flex-shrink: 0;
margin-right: 1px;
}
.yz-resource-head .major-dot { display: none; }
.yz-side-link .major-dot { display: none; }
.yz-resource-head > svg {
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; }
.yz-resource-head .chev svg {
width: 12px;
height: 12px;
transform: rotate(90deg);
transition: transform .18s ease;
}
.yz-resource-head[aria-expanded="false"] .chev svg {
transform: rotate(0deg);
}
.yz-resource-sub {
aside.sidebar .nav-panel {
position: relative;
margin-left: 8px;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 6px;
/* 条目变多后不能再卡死 320px,否则「消费/垃圾桶」会被裁掉只剩一截字 */
overflow: visible;
max-height: none;
opacity: 1;
margin-top: 2px;
height: calc(100vh - 116px);
min-height: 0;
padding-top: 14px;
overflow-x: hidden;
overflow-y: auto;
}
.yz-resource-sub::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 6px;
width: 1px;
background: #d7dbe3;
aside.sidebar .nav-list {
position: relative;
z-index: 2;
}
.yz-resource-sub a {
min-height: 34px;
border-radius: 11px;
padding: 0 8px 0 10px;
color: #888888;
aside.sidebar .nav-item {
position: relative;
z-index: 3;
width: 100%;
height: clamp(70px, 7.6vh, 84px);
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
transition: background .15s, color .15s;
padding: 0 10px 0 38px;
color: #272a2f;
background: transparent;
cursor: pointer;
text-align: left;
transition: color 180ms ease, transform 180ms ease;
}
.yz-resource-sub a .dot {
aside.sidebar .nav-item .nav-inner {
width: 100%;
display: grid;
grid-template-columns: 28px max-content 17px;
align-items: center;
gap: 10px;
white-space: nowrap;
transition: opacity 140ms ease;
}
aside.sidebar .nav-item .nav-chevron {
width: 17px;
height: 17px;
margin-left: 1px;
color: rgba(39, 42, 47, 0.45);
transform: translateX(-7px);
transition: color 180ms ease, transform 220ms ease;
}
aside.sidebar .nav-item[aria-expanded="true"] .nav-chevron {
color: var(--klein);
transform: translateX(-7px) rotate(180deg);
}
aside.sidebar .nav-item:hover:not(.active) {
color: var(--klein);
transform: translateX(3px);
}
aside.sidebar .nav-item.active .nav-inner {
opacity: 0;
}
aside.sidebar .nav-item svg {
width: 23px;
height: 23px;
stroke-width: 1.8;
}
aside.sidebar .nav-label {
font-size: 16px;
font-weight: 600;
}
aside.sidebar .nav-submenu {
display: grid;
gap: 4px;
max-height: 0;
margin: 0 14px 0 28px;
padding: 0 0 0 18px;
overflow: hidden;
border-left: 1px solid rgba(0, 47, 167, 0.16);
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition:
max-height 320ms cubic-bezier(0.22, 1, 0.36, 1),
margin 280ms ease,
padding 280ms ease,
opacity 180ms ease,
transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
aside.sidebar .nav-submenu.expanded {
/* 比影擎 184px 略高:我们多「账单库 / 垃圾桶」两行 */
max-height: 260px;
margin: -8px 14px 10px 28px;
padding: 8px 0 8px 18px;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
aside.sidebar .nav-subitem {
min-height: 38px;
display: flex;
align-items: center;
gap: 9px;
padding: 0 12px;
border-radius: 10px;
color: rgba(39, 42, 47, 0.62);
background: transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: color 180ms ease, background 180ms ease, transform 180ms ease;
}
aside.sidebar .nav-subitem::before {
content: "";
width: 5px;
height: 5px;
flex: 0 0 auto;
border-radius: 50%;
background: #c8c8c8;
background: currentColor;
opacity: 0.45;
}
.yz-resource-sub a:hover {
background: #eceef2;
color: #5e6571;
aside.sidebar .nav-subitem:hover {
color: var(--klein);
background: rgba(0, 47, 167, 0.055);
transform: translateX(2px);
}
.yz-resource-sub a.active {
background: #e9eefb;
color: #2148bf;
font-weight: 600;
aside.sidebar .nav-subitem.active {
color: var(--klein);
background: rgba(0, 47, 167, 0.09);
}
.yz-resource-sub a.active .dot {
width: 7px;
height: 7px;
background: #3158d4;
aside.sidebar .nav-subitem.active::before {
opacity: 1;
box-shadow: 0 0 0 4px rgba(0, 47, 167, 0.10);
}
.yz-resource-sub a .pill-mini {
aside.sidebar .nav-subitem .pill-mini {
margin-left: auto;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 0;
border-radius: 999px;
border: 1px solid #d4dcf4;
padding: 0 7px;
line-height: 18px;
background: #fff;
color: var(--klein);
font-size: 11px;
color: #4a5ca0;
background: #f4f7ff;
font-weight: 600;
line-height: 18px;
text-align: center;
}
.yz-resource-sub.collapsed {
display: none;
max-height: 0;
opacity: 0;
margin-top: 0;
aside.sidebar .liquid-indicator {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 132px;
height: 76px;
pointer-events: none;
transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.yz-side-link {
min-height: 48px;
margin-left: -16px;
margin-right: 10px;
border-radius: 0 24px 24px 0;
aside.sidebar .liquid-indicator.is-hidden {
opacity: 0;
visibility: hidden;
}
aside.sidebar .liquid-shape {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.14));
transform-origin: left center;
}
aside.sidebar .liquid-indicator.pulse .liquid-shape {
animation: liquid-pulse 520ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes liquid-pulse {
0%, 100% { transform: scaleX(1); }
42% { transform: scaleX(1.012); }
72% { transform: scaleX(0.998); }
}
aside.sidebar .indicator-content {
position: absolute;
top: 0;
left: 33px;
height: 76px;
width: 94px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px 0 14px;
color: #17191d;
font-size: 14px;
font-weight: 600;
gap: 9px;
color: #fff;
white-space: nowrap;
overflow: hidden;
transition: background .22s ease, color .22s ease, box-shadow .22s ease;
font-size: 15px;
font-weight: 600;
transition: opacity 140ms ease, transform 280ms ease;
}
.yz-side-link:hover { background: #eceef2; }
.yz-side-link > svg {
width: 18px;
height: 18px;
flex-shrink: 0;
color: #4f5562;
transition: color .22s ease;
aside.sidebar .liquid-indicator.wide-label .indicator-content {
left: 26px;
gap: 7px;
}
.yz-side-link.active {
color: #ffffff;
background: var(--nav-active);
box-shadow: 0 6px 18px rgba(17, 19, 24, .14);
aside.sidebar .liquid-indicator.pulse .indicator-content {
animation: label-pulse 520ms ease;
}
.yz-side-link.active > svg {
color: rgba(255, 255, 255, .96);
@keyframes label-pulse {
0%, 100% { transform: translateX(0); }
44% { transform: translateX(2px); }
}
aside.sidebar .indicator-icon-slot {
width: 24px;
display: grid;
place-items: center;
}
aside.sidebar .indicator-icon-slot svg {
width: 20px;
height: 20px;
stroke-width: 1.9;
}
aside.sidebar .nav-panel > nav {
padding: 8px 16px 20px;
}
@media (max-height: 780px) {
aside.sidebar .nav-panel { padding-top: 8px; }
aside.sidebar .nav-item { height: 68px; }
}
.aside-foot {
margin-top: auto;
@@ -602,8 +656,8 @@ nav a.disabled:hover { background: transparent; color: var(--black-alpha-32); }
line-height: 1;
}
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 aside.sidebar { padding: 0; }
body.sidebar-collapsed .sidebar-head { gap: 6px; margin: 0; padding: 0; height: 116px; min-height: 116px; }
body.sidebar-collapsed .brand-clip {
width: 34px;
height: 34px;
@@ -648,20 +702,51 @@ body.sidebar-collapsed .user {
justify-content: center;
padding: 8px 0;
}
body.sidebar-collapsed .yz-side-main,
body.sidebar-collapsed .nav-panel,
body.sidebar-collapsed .user .em,
body.sidebar-collapsed .user::after { display: none; }
/* ─── Main + grid background ─── */
main { position: relative; background: var(--background-base); min-width: 0; }
main { position: relative; background: #fff; min-width: 0; }
.grid-bg {
position: absolute; inset: 0; pointer-events: none;
position: absolute;
inset: 0;
pointer-events: none;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='60' height='60'><circle cx='60' cy='60' r='0.9' fill='%23d8d8d8'/></svg>"),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23ebebeb' stroke-width='1' fill='none' stroke-dasharray='1.5 4'><path d='M240 0 L240 240'/><path d='M0 240 L240 240'/></g></svg>");
background-size: 60px 60px, 240px 240px;
mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
-webkit-mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px);
background-size: 48px 48px;
background-position: -1px -1px;
-webkit-mask-image: linear-gradient(
90deg,
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
mask-image: linear-gradient(
90deg,
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
}
.scatter {
position: absolute;
@@ -694,37 +779,81 @@ main { position: relative; background: var(--background-base); min-width: 0; }
.crumbs .sep { color: var(--black-alpha-32); }
.crumbs .here { color: var(--accent-black); font-weight: 500; }
.crumbs a:hover { color: var(--accent-black); }
.yz-mods {
.topbar .yz-mods {
position: relative;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
flex: 0 0 auto;
width: max-content;
max-width: none;
gap: 16px;
min-height: 44px;
}
.yz-mod {
.topbar .yz-mods.no-active .yz-mod-indicator-mask { opacity: 0; }
.yz-mod-clip-defs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.yz-mod-indicator-mask {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
clip-path: url(#yzModeButtonMask);
-webkit-clip-path: url(#yzModeButtonMask);
transition: opacity 180ms ease;
}
.yz-mod-indicator {
position: absolute;
top: 0;
left: 0;
width: 104px;
height: 44px;
border-radius: 22px;
background: #101012;
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.16);
pointer-events: none;
transform-origin: left center;
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1), width 320ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform, width;
}
.topbar .yz-mods .yz-mod {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: auto;
min-width: 104px;
height: 44px;
padding: 0 22px;
gap: 0;
border: 1px solid #d9dde3;
border-radius: 22px;
background: rgba(31, 39, 51, 0.025);
color: #2b2e33;
background: rgba(31, 39, 51, 0.025);
font: inherit;
font-size: 14px;
font-weight: 500;
text-align: center;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
flex: 0 0 auto;
transition: background 180ms ease, color 180ms ease, border-color 180ms ease;
}
.yz-mod:hover { background: rgba(31, 39, 51, 0.06); color: #2b2e33; }
.yz-mod.active {
background: #101012;
.topbar .yz-mods .yz-mod:hover:not(.active) { background: rgba(31, 39, 51, 0.06); color: #2b2e33; }
.topbar .yz-mods .yz-mod.active {
color: #fff;
font-weight: 500;
background: transparent;
border-color: transparent;
box-shadow: none;
}
.topbar .yz-mods .yz-mod.active:hover { background: transparent; color: #fff; }
.top-search {
display: inline-flex;
align-items: center;
@@ -739,9 +868,9 @@ main { position: relative; background: var(--background-base); min-width: 0; }
font: inherit;
font-size: 14px;
cursor: pointer;
transition: border-color var(--t-base), background var(--t-base);
transition: border-color var(--t-base), background var(--t-base), transform var(--t-base);
}
.top-search:hover { border-color: #e0e0e0; background: #f0f0f0; }
.top-search:hover { border-color: #e0e0e0; background: #f0f0f0; transform: translateY(-1px); }
.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 {
@@ -763,9 +892,9 @@ main { position: relative; background: var(--background-base); min-width: 0; }
font-size: 13px;
color: var(--black-alpha-56);
cursor: pointer;
transition: background var(--t-base), border-color var(--t-base);
transition: background var(--t-base), border-color var(--t-base), transform var(--t-base);
}
.balance-chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.balance-chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); transform: translateY(-1px); }
.balance-chip strong { color: var(--accent-black); font-weight: 600; font-variant-numeric: tabular-nums; }
.balance-chip svg { width: var(--icon-m); height: var(--icon-m); color: var(--black-alpha-48); }
.icon-btn {
@@ -777,9 +906,9 @@ main { position: relative; background: var(--background-base); min-width: 0; }
color: var(--black-alpha-56);
cursor: pointer;
position: relative;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
transition: background var(--t-base), border-color var(--t-base), color var(--t-base), transform var(--t-base);
}
.icon-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--border-loud); }
.icon-btn:hover { background: var(--background-lighter); color: var(--accent-black); border-color: var(--border-loud); transform: translateY(-1px); }
.icon-btn svg { width: var(--icon-m); height: var(--icon-m); }
.icon-btn .dot-noti {
position: absolute; top: 8px; right: 9px;
@@ -853,6 +982,11 @@ main { position: relative; background: var(--background-base); min-width: 0; }
position: relative;
z-index: 1;
min-height: calc(100vh - 116px);
animation: yz-page-enter 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes yz-page-enter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.content > .corner-mark { display: none; }
@@ -907,7 +1041,7 @@ main { position: relative; background: var(--background-base); min-width: 0; }
border: 1px solid var(--black-alpha-12);
transition: background var(--t-base), border-color var(--t-base), transform var(--t-fast);
}
.btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.btn:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); transform: translateY(-1px); }
.btn:active { background: var(--black-alpha-7); transform: scale(.99); }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--background-base), 0 0 0 4px var(--heat-40); }
.btn:disabled, .btn.disabled {
@@ -936,6 +1070,7 @@ main { position: relative; background: var(--background-base); min-width: 0; }
background: var(--heat-hover);
border-color: var(--heat-hover);
box-shadow: var(--shadow-cta-hover);
transform: translateY(-2px);
}
.btn-primary:active {
background: var(--heat);
@@ -1129,9 +1264,9 @@ main { position: relative; background: var(--background-base); min-width: 0; }
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer;
font-family: inherit;
transition: background var(--t-base), border-color var(--t-base), color var(--t-base);
transition: background var(--t-base), border-color var(--t-base), color var(--t-base), transform var(--t-base);
}
.chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); color: var(--accent-black); }
.chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); color: var(--accent-black); transform: translateY(-1px); }
.chip.active { border-color: var(--heat-40); color: var(--heat); background: var(--heat-12); }
.chip svg { width: 12px; height: 12px; }
/* 清空筛选:幽灵态(无边框/透明底),仅在有筛选时出现,点击重置全部筛选 */
@@ -2050,15 +2185,23 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
position: fixed;
inset: 0;
z-index: 1800;
display: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12vh 16px 24px;
background: var(--black-alpha-48);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .2s ease, visibility .2s ease;
}
.shell-command-bg.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.shell-command-bg.show { display: flex; }
.shell-command {
width: min(640px, 100%);
max-height: min(680px, 76vh);
@@ -2069,6 +2212,13 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
overflow: hidden;
transform: translateY(-10px) scale(.98);
opacity: 0;
transition: transform .22s cubic-bezier(0.22, 1, 0.36, 1), opacity .22s ease;
}
.shell-command-bg.show .shell-command {
transform: none;
opacity: 1;
}
.shell-command-h {
display: flex;
@@ -2140,6 +2290,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background var(--t-base);
}
.shell-command-item:hover,
.shell-command-item.active {
@@ -2221,14 +2372,24 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
position: fixed;
z-index: 1700;
min-width: 232px;
display: none;
display: block;
padding: 8px;
background: var(--surface-raised);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
box-shadow: var(--shadow-floating);
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-6px);
transition: opacity .18s ease, transform .18s cubic-bezier(0.22, 1, 0.36, 1), visibility .18s ease;
}
.shell-account-menu.show {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: none;
}
.shell-account-menu.show { display: block; }
.shell-account-head {
display: flex;
align-items: center;
@@ -2279,6 +2440,7 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.shell-account-menu button svg {
width: 14px;
@@ -2302,26 +2464,28 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
background: rgba(21, 20, 15, .42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: none; align-items: center; justify-content: center;
display: flex; align-items: center; justify-content: center;
z-index: 999;
opacity: 0;
transition: opacity .2s;
visibility: hidden;
pointer-events: none;
transition: opacity .2s ease, visibility .2s ease;
}
.modal-bg.show { display: flex; opacity: 1; }
.modal-bg.show { opacity: 1; visibility: visible; pointer-events: auto; }
.modal {
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
max-width: 480px; width: 90%;
position: relative;
transform: scale(.96);
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1);
transform: translateY(8px) scale(.98);
transition: transform .22s cubic-bezier(0.22, 1, 0.36, 1);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-bg.show .modal { transform: scale(1); }
.modal-bg.show .modal { transform: none; }
.modal::before, .modal::after {
content: '';
position: absolute;
@@ -2428,10 +2592,14 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
.drawer-bg {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .32);
display: none;
display: block;
z-index: 90;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .2s ease, visibility .2s ease;
}
.drawer-bg.show { display: block; }
.drawer-bg.show { opacity: 1; visibility: visible; pointer-events: auto; }
.drawer {
position: fixed; right: 0; top: 0; bottom: 0;
width: 540px; max-width: 100vw;
@@ -2492,14 +2660,14 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
aside.sidebar {
display: block;
position: fixed; top: 0; left: 0; bottom: 0;
width: 164px;
width: var(--sidebar-width);
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: 164px; }
body.sidebar-collapsed aside.sidebar { width: var(--sidebar-width); }
aside.sidebar.mobile-open { transform: translateX(0); }
.sidebar-toggle { display: none; }
/* 抽屉内汉堡键无意义,藏掉,留出顶部空间给页面自己的汉堡 */
@@ -2691,8 +2859,8 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
.recent-meta .name { font-weight: 600; font-size: 14px; color: var(--accent-black); }
.recent-meta .sub { font-size: 12px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .01em; }
.shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: flex; align-items: flex-start; gap: 11px; cursor: pointer; text-align: left; transition: background var(--t-base), border-color var(--t-base); }
.shortcut:hover { background: var(--black-alpha-4); }
.shortcut { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 14px; display: flex; align-items: flex-start; gap: 11px; cursor: pointer; text-align: left; transition: background var(--t-base), border-color var(--t-base), transform var(--t-base); }
.shortcut:hover { background: var(--black-alpha-4); transform: translateY(-2px); }
.shortcut .ic { width: 30px; height: 30px; background: var(--heat-12); color: var(--heat); display: grid; place-items: center; border: 1px solid var(--heat-20); border-radius: var(--r-sm); flex-shrink: 0; }
.shortcut .t { font-size: 13px; font-weight: 600; }
.shortcut .d { font-size: 12px; color: var(--black-alpha-48); margin-top: 2px; font-family: var(--font-mono); letter-spacing: .01em; }
@@ -2713,3 +2881,19 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
transition: color var(--t-base);
}
.dash-funds-mono:hover { color: var(--heat); }
@media (prefers-reduced-motion: reduce) {
.yz-mod-indicator,
.yz-mod-indicator-mask,
.modal-bg,
.modal,
.drawer,
.drawer-bg,
.shell-command-bg,
.shell-command,
.shell-account-menu,
.content {
transition: none !important;
animation: none !important;
}
}
+1 -1
View File
@@ -372,7 +372,7 @@
.library-page .lib-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
left: calc(var(--sidebar-width) + (100vw - var(--sidebar-width)) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
+7 -19
View File
@@ -105,8 +105,8 @@
.models-page .ml-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.models-page .ml-card {
position: relative;
@@ -142,7 +142,7 @@
border: 0;
border-radius: 0;
}
.models-page .ml-meta { padding: 15px 16px 17px; }
.models-page .ml-meta { padding: 12px 13px 14px; }
.models-page .ml-meta h3 {
margin: 0 0 6px;
font-size: 15px;
@@ -152,21 +152,6 @@
white-space: nowrap;
}
.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: 10px;
overflow: hidden;
border-radius: 8px;
background: rgba(34, 42, 54, 0.06);
}
.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;
@@ -232,7 +217,7 @@
.models-page .ml-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
left: calc(var(--sidebar-width) + (100vw - var(--sidebar-width)) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
@@ -269,6 +254,9 @@
.models-page .ml-sel-confirm { color: #fff; background: #002fa7; }
.models-page .ml-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1280px) {
.models-page .ml-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.models-page .ml-sel-bar { left: 50%; }
+1 -1
View File
@@ -470,7 +470,7 @@
/* 影擎吸底选择条 · 管理模式即出现 */
.products-page .pl-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
left: calc(var(--sidebar-width) + (100vw - var(--sidebar-width)) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
+8 -2
View File
@@ -71,7 +71,13 @@
color: var(--vc-muted);
font-size: 12px;
}
.projects-page .vc-list-h { margin: 32px 0 17px; }
.projects-page .vc-list-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: 22px;
margin: 32px 0 17px;
}
.projects-page .vc-list-h h2 { font-size: 21px; }
.projects-page .vc-list-h p { font-size: 13px; }
@@ -456,7 +462,7 @@
.projects-page .vc-sel-bar {
position: fixed;
left: calc(164px + (100vw - 164px) / 2);
left: calc(var(--sidebar-width) + (100vw - var(--sidebar-width)) / 2);
bottom: 26px;
z-index: 100;
min-width: 410px;
+98 -97
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, CreditCard, X } from "lucide-react";
import { CreditCard, X } from "lucide-react";
import { createPortal } from "react-dom";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
@@ -63,11 +63,11 @@ 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(--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" }
const STAGES: Array<{ k: string; short: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
{ k: "视频片段(Seedance)", short: "视频生成", color: "var(--klein)", bucket: "video" },
{ k: "故事板(image-2)", short: "故事板", color: "var(--accent-forest)", bucket: "storyboard" },
{ k: "基础资产", short: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
{ k: "脚本 LLM", short: "脚本生成", color: "var(--black-alpha-32)", bucket: "script" }
];
// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮)
@@ -116,7 +116,7 @@ function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
);
}
export function AccountPage({ billing, projects, team, navigate, onRecharge, onNotify }: {
export function AccountPage({ billing, projects, team, onRecharge, onNotify }: {
billing: BillingSummary | null;
projects: Project[];
// team prop 用于读取团队级月限额(与团队管理页保持同一来源 · #14)
@@ -244,6 +244,11 @@ export function AccountPage({ billing, projects, team, navigate, onRecharge, onN
const total14 = Number(activeTrend?.total_14d ?? used);
const avgValue = daily.length ? total14 / daily.length : 0;
const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used;
const topStage = STAGES.reduce((best, s) => {
const amt = Number(trend?.by_stage[s.bucket] || 0);
return amt > best.amt ? { label: s.short, amt } : best;
}, { label: "暂无消耗", amt: 0 });
const topPct = stageTotal > 0 ? Math.round((topStage.amt / stageTotal) * 100) : 0;
const projectSpend = (id: string) => Number(trend?.by_project[id] || 0);
// ── 项目筛选(状态;真实数据无 owner/per-range,故只做状态过滤)──
@@ -282,101 +287,87 @@ export function AccountPage({ billing, projects, team, navigate, onRecharge, onN
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>
<header className="ac-head">
<div>
<h1></h1>
<div className="sub">{money(balance)} · {money(used)} · </div>
<h1></h1>
<p></p>
</div>
</div>
</header>
<div className="top-grid">
<div className="balance-banner">
<span className="corner-tr" aria-hidden="true"></span><span className="corner-bl" aria-hidden="true"></span>
<div className="balance-hero">
<div className="lbl"></div>
<div className="v">{money(balance)}</div>
<div className="meta"> · </div>
<div className="ac-top">
<div className="ac-hero">
<div>
<span></span>
<strong>{money(balance)}</strong>
<em> · </em>
</div>
<div className="balance-sub">
<div className="col">
<div className="lbl"></div>
<div className="v">{money(limit)}</div>
<div className="meta"></div>
<div className="ac-hero-stats">
<div>
<span></span>
<strong>{money(limit)}</strong>
</div>
<div className="col">
<div className="lbl"></div>
<div className="v">{money(used)}</div>
<div className="meta"> {pct.toFixed(1)}% · {pct >= 80 ? "注意" : "健康"}</div>
<div>
<span></span>
<strong>{money(used)}</strong>
</div>
</div>
<div className="balance-foot">
<div className="balance-meter" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}><span style={{ width: `${pct}%` }} /></div>
<div className="balance-foot-meta">
<div>
<div className="ac-progress" aria-label={`本月额度使用率 ${pct.toFixed(1)}%`}>
<span style={{ width: `${Math.max(pct, pct > 0 ? 2 : 0)}%` }} />
</div>
<div className="ac-progress-meta">
<span> {money(left)}</span>
<span>使 {pct.toFixed(1)}%</span>
</div>
</div>
</div>
<div className="pane topup-pane">
<div className="topup-head">
<div>
<h3></h3>
<div className="desc">, · </div>
</div>
<div className="topup-selected"> ¥{effectiveAmount}( {Math.round(effectiveAmount * pointsRate + effectiveBonus)} ){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
</div>
<div className="recharge-row">
<div className="ac-recharge">
<h2></h2>
<p> · · ¥{effectiveAmount} {Math.round(effectiveAmount * pointsRate + effectiveBonus)} {effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</p>
<div className="ac-recharge-grid">
{RECHARGE.map((item) => (
<div
<button
key={item.amt}
className={`recharge-card ${recharge === item.amt && !customAmt ? "selected" : ""}`}
role="button"
tabIndex={0}
type="button"
className={`ac-option${recharge === item.amt && !customAmt ? " active" : ""}`}
aria-pressed={recharge === item.amt && !customAmt}
onClick={() => { setRecharge(item.amt); setCustomAmt(""); }}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setRecharge(item.amt); setCustomAmt(""); } }}
>
{item.ribbon && <span className="ribbon">{item.ribbon}</span>}
<div className="amt">¥{item.amt}</div>
<div className={`gift ${item.bonus ? "bonus" : ""}`}>{item.gift}</div>
</div>
<strong>¥{item.amt}</strong>
<span className={item.bonus ? "" : "plain"}>{item.gift}</span>
</button>
))}
</div>
<div className="pay-row">
<div className="pay-title"></div>
<input
className={`input${!customAmountIsValid ? " is-invalid" : ""}`}
placeholder="最低 ¥50,可输入任意金额"
type="number"
min={MIN_RECHARGE_AMOUNT}
step="1"
inputMode="numeric"
value={customAmt}
aria-invalid={!customAmountIsValid}
aria-describedby="custom-recharge-error"
onChange={(event) => setCustomIntegerAmount(event.target.value)}
/>
{!customAmountIsValid && <div className="field-hint is-error" id="custom-recharge-error" role="alert"> {MIN_RECHARGE_AMOUNT} </div>}
<div className="pay-btn-row">
<button className="btn pay-method-btn pay-wechat" type="button" aria-label="微信支付" onClick={() => openTopup("wechat")}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
</button>
<button className="btn pay-method-btn pay-alipay" type="button" aria-label="支付宝" onClick={() => openTopup("alipay")}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
</button>
</div>
<div className="ac-pay-title"></div>
<input
className={`input${!customAmountIsValid ? " is-invalid" : ""}`}
placeholder="最低 ¥50,可输入任意金额"
type="number"
min={MIN_RECHARGE_AMOUNT}
step="1"
inputMode="numeric"
value={customAmt}
aria-invalid={!customAmountIsValid}
aria-describedby="custom-recharge-error"
onChange={(event) => setCustomIntegerAmount(event.target.value)}
/>
{!customAmountIsValid && <div className="field-hint is-error" id="custom-recharge-error" role="alert"> {MIN_RECHARGE_AMOUNT} </div>}
<div className="ac-pay-row">
<button className="ac-pay" type="button" aria-label="微信支付" onClick={() => openTopup("wechat")}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-wechat.png" alt="" /></span>
</button>
<button className="ac-pay" type="button" aria-label="支付宝" onClick={() => openTopup("alipay")}>
<span className="pay-logo" aria-hidden="true"><img src="/assets/pay-alipay.png" alt="" /></span>
</button>
</div>
<div className="tip" style={{ marginTop: 14 }}>
<strong></strong>
1 = 10 / 10 /; 20 /; 10 /500 ;
( 15 720P 224 ),退;
,退
<div className="ac-tip">
<strong> </strong>
1 = 10 / 10 / 20 / 10 /500
</div>
</div>
</div>
@@ -399,24 +390,34 @@ export function AccountPage({ billing, projects, team, navigate, onRecharge, onN
<button key={r} className={`ac-seg${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
))}
</div>
<div className="ac-summary">
<div>
<span></span>
<strong>{money(used)}</strong>
</div>
<div>
<span></span>
<strong>{money(left)}</strong>
</div>
<div>
<span></span>
<strong>{topStage.amt > 0 ? `${topStage.label} ${topPct}%` : "暂无消耗"}</strong>
</div>
</div>
<div className="trend-chart">
<div className="bars">
{daily.map((d) => {
const amt = Number(d.amount);
const h = peak > 0 ? Math.max(amt > 0 ? 4 : 0, (amt / peak) * 100) : 0;
const isPeak = peak > 0 && amt === peak;
return (
<div className={`bar${isPeak ? " peak" : ""}`} key={d.date} title={`${d.label} · ${money(amt)}`}>
<span style={{ height: `${h}%` }} />
</div>
);
})}
</div>
<div className="x-axis">
{daily.map((d, i) => (
<span key={d.date}>{i % 2 === 0 ? d.label : ""}</span>
))}
</div>
{daily.length === 0 ? (
<div className="trend-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="ac-bar-group" key={d.date} title={`${d.label} · ${money(amt)}`}>
<strong className="ac-bar-value">{amt > 0 ? Math.round(amt) : ""}</strong>
<div className="ac-bar" style={{ height: `${h}%` }} />
<span>{d.label}</span>
</div>
);
})}
</div>
<div className="trend-foot">
<div className="item"><span className="k">{meta.totalLabel}</span><span className="v">{money(total14)}</span></div>
+1 -1
View File
@@ -2566,7 +2566,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
<div className="dm-form-cta">
<div className="dm-cost">
<span> <span className="v"> 20 /</span></span>
<span></span>
<span></span>
</div>
<button className="dm-gen" type="button" onClick={toRealTool}>
<Sparkles size={15} />
-6
View File
@@ -428,12 +428,6 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
<div className="ml-meta">
<h3 title={m.name}>{m.name}</h3>
<p>{m.description?.trim() || (m.source === "upload" ? "真人上传的人物参考" : "AI 生成的人物参考")}</p>
<div
className={`placeholder ml-triview${m.triview ? " has-mock-media" : ""}`}
style={m.triview ? mediaStyle(m.triview) : undefined}
>
{!m.triview && <span className="ph-frame"></span>}
</div>
<div className="ml-line">
{m.is_official ? (
<>
+8 -8
View File
@@ -571,12 +571,6 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<h1></h1>
<p></p>
</div>
<div className="vc-actions">
<button className={`vc-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理项目"}</span>
</button>
</div>
</header>
<section className="vc-sec">
@@ -622,8 +616,14 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
</section>
<div className="vc-sec-h vc-list-h">
<h2></h2>
<p></p>
<div>
<h2></h2>
<p></p>
</div>
<button className={`vc-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<Settings2 />
<span>{editMode ? "完成" : "管理项目"}</span>
</button>
</div>
<div className="vc-toolbar">
+2 -2
View File
@@ -75,7 +75,7 @@ export const mainNav: NavItem[] = [
{ page: "projects", label: "视频创作", icon: FolderKanban },
{ page: "pipeline", label: "生产管线", icon: WandSparkles },
{ page: "library", label: "成品库", icon: Library },
{ page: "account", label: "账", icon: Wallet }
{ page: "account", label: "账单库", icon: Wallet }
];
export const supplementNav: NavItem[] = [
@@ -96,7 +96,7 @@ export const routeLabels: Record<Page, string> = {
projectWizard: "新建视频项目",
pipeline: "生产管线",
library: "成品库",
account: "账",
account: "账单库",
team: "团队",
messages: "消息",
assetFactory: "图片工具",
+53 -16
View File
@@ -92,7 +92,7 @@ img, svg, video { display: block; max-width: 100%; }
.divider { height: 1px; background: var(--border); margin: 14px 0; }
/* ─── App shell ─── */
.app { display: grid; grid-template-columns: 164px 1fr; min-height: 100vh; }
.app { display: grid; grid-template-columns: var(--sidebar-width, 192px) 1fr; min-height: 100vh; }
/* ─── Sidebar ─── */
aside.sidebar {
@@ -177,15 +177,46 @@ nav a.disabled:hover { background: transparent; color: var(--ink-4); }
.user .em { font-size: 13px; }
/* ─── Main + grid background ─── */
main { position: relative; overflow: hidden; background: var(--bg); }
main { position: relative; overflow: hidden; background: #fff; }
.grid-bg {
position: absolute; inset: 0; pointer-events: none;
position: absolute;
inset: 0;
pointer-events: none;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='60' height='60'><circle cx='60' cy='60' r='0.9' fill='%23CFCABB'/></svg>"),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23E2DED2' stroke-width='1' fill='none' stroke-dasharray='1.5 4'><path d='M240 0 L240 240'/><path d='M0 240 L240 240'/></g></svg>");
background-size: 60px 60px, 240px 240px;
mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
-webkit-mask-image: radial-gradient(ellipse 95% 80% at 50% 35%, #000 25%, transparent 95%);
linear-gradient(rgba(0, 47, 167, 0.16) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 47, 167, 0.16) 1px, transparent 1px);
background-size: 48px 48px;
background-position: -1px -1px;
-webkit-mask-image: linear-gradient(
90deg,
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
mask-image: linear-gradient(
90deg,
#000 0%,
rgba(0, 0, 0, 0.58) 2%,
rgba(0, 0, 0, 0.3) 16%,
rgba(0, 0, 0, 0.12) 30%,
rgba(0, 0, 0, 0.03) 40%,
transparent 46%,
transparent 54%,
rgba(0, 0, 0, 0.03) 60%,
rgba(0, 0, 0, 0.12) 70%,
rgba(0, 0, 0, 0.3) 84%,
rgba(0, 0, 0, 0.58) 98%,
#000 100%
);
}
.scatter { position: absolute; font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.05; color: var(--ink-4); white-space: pre; pointer-events: none; opacity: .8; letter-spacing: .04em; }
.tag-corner { position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; letter-spacing: .06em; pointer-events: none; opacity: .85; z-index: 1; }
@@ -490,21 +521,23 @@ table.t tbody tr:hover { background: var(--bg-soft); }
.modal-bg {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .42);
display: none; align-items: center; justify-content: center;
display: flex; align-items: center; justify-content: center;
z-index: 999;
opacity: 0;
transition: opacity .2s;
visibility: hidden;
pointer-events: none;
transition: opacity .2s ease, visibility .2s ease;
}
.modal-bg.show { display: flex; opacity: 1; }
.modal-bg.show { opacity: 1; visibility: visible; pointer-events: auto; }
.modal {
background: var(--card);
border: 1px solid var(--border);
max-width: 480px; width: 90%;
position: relative;
transform: scale(.96);
transition: transform .25s cubic-bezier(0.34, 1.56, 0.64, 1);
transform: translateY(8px) scale(.98);
transition: transform .22s cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-bg.show .modal { transform: scale(1); }
.modal-bg.show .modal { transform: none; }
.modal::before, .modal::after { content: '+'; position: absolute; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 13px; line-height: 1; }
.modal::before { top: -8px; left: -8px; }
.modal::after { bottom: -8px; right: -8px; }
@@ -524,10 +557,14 @@ table.t tbody tr:hover { background: var(--bg-soft); }
.drawer-bg {
position: fixed; inset: 0;
background: rgba(21, 20, 15, .32);
display: none;
display: block;
z-index: 90;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .2s ease, visibility .2s ease;
}
.drawer-bg.show { display: block; }
.drawer-bg.show { opacity: 1; visibility: visible; pointer-events: auto; }
.drawer {
position: fixed; right: 0; top: 0; bottom: 0;
width: 540px; max-width: 100vw;
+9 -5
View File
@@ -7,12 +7,15 @@
--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;
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 { margin-bottom: 34px; }
.page-head h1 { font-size: 32px; line-height: 1.2; letter-spacing: -.02em; }
.page-head .sub { margin-top: 10px; font-size: 15px; color: var(--st-muted); }
.tc-ghost,
.tc-primary,
@@ -364,6 +367,7 @@
.list-pager { padding: 12px 20px 16px; border-top: 1px solid rgba(34, 42, 54, 0.07); }
@media (max-width: 1100px) {
margin: -28px -24px -48px;
.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; }