修改部份ui

This commit is contained in:
Azmat@qq.com
2026-08-18 18:32:19 +08:00
parent dfe94a923e
commit 18b3927817
19 changed files with 2151 additions and 681 deletions
+8 -1
View File
@@ -19,7 +19,10 @@ server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
# 上传视频提炼允许 200MB(见 video_digest.MAX_UPLOAD_BYTES)。multipart 还有表单开销,
# 给到 220m。以前 50m 会在进 Django 前直接 413,手机拍的参考片很容易超。
client_max_body_size 220m;
client_body_timeout 300s;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
@@ -31,7 +34,11 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $fwd_proto;
# web 容器只有 128Mi,不要把整段视频缓存在 nginx 里再转发。
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 60s;
}
# Django 自带后台(已从 /admin/ 挪到 /django-admin/);/admin/* 留给前端 SPA 平台后台,走底部 try_files 回落 index.html
+26 -36
View File
@@ -21,7 +21,7 @@ import type {
} from "./types";
import { publicModelDisplayName } from "./model-display";
import { generationErrorText } from "./generation-error";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import { CornerMarks, Decorations, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { SystemLoading } from "./components/loading";
import {
AccountPage,
@@ -43,35 +43,13 @@ import {
TeamPage
} from "./routes";
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
import { isOwnerOnlyPage, pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { isOwnerOnlyPage, pathForPage, resolveRoute } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash";
import { ModelsPage } from "./routes/models";
import { money } from "./routes/stage-config";
import type { ProductBatchResult } from "./routes/products";
const crumbLabels: Partial<Record<Page, string>> = {
dashboard: "工作台",
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "商品库",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
library: "资产库",
account: "消费",
team: "团队",
messages: "消息中心",
assetFactory: "图片生成",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
modelPhotoDemoB: "模特图方案 B",
platformCover: "平台套图",
settings: "设置",
settingsNotify: "设置"
};
/* 图片生成工作台·跨刷新持久化:把在跑的任务 id + 已出结果按 mode 存本地,
刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */
const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`;
@@ -1004,12 +982,13 @@ export function App() {
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
default:
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} loading={!dataLoaded} navigate={navigate} onCreateProduct={(payload) => action(() => api.createProduct(payload), "")} />;
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onCreateProduct={(payload) => action(() => api.createProduct(payload), "")} />;
}
}
const avatarChar = (currentTeam.name || currentUser.username || "A").slice(0, 1).toUpperCase();
const here = crumbLabels[page] || routeLabels[page] || "工作台";
const topModule = topModuleForPage(page);
const searchKbd = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K";
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
// 这里只认「当前激活项目的完整详情」(含 stages/timeline/script_versions/metadata 等嵌套字段)。
@@ -1153,18 +1132,29 @@ export function App() {
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
{page === "dashboard" ? (
<span className="here"></span>
) : (
<>
<a href="/dashboard" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}></a>
<span className="sep">/</span>
<span className="here">{here}</span>
</>
)}
<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>
<div className="right">
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
<span></span>
<span className="kbd">{searchKbd}</span>
</button>
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
<strong>{money(billing?.account.balance)}</strong>
+292 -83
View File
@@ -10,95 +10,308 @@
A · 图片生成入口(.asset-factory)
============================================================ */
/* ─── 三 Hero 卡片网格(模特上身图 / 平台套图 / 图片创作 · 等比)─── */
.asset-factory .factory-hero {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 56px;
}
@media (max-width: 1400px) {
.asset-factory .factory-hero { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 1000px) {
.asset-factory .factory-hero { grid-template-columns: 1fr; }
}
.asset-factory .factory-card {
position: relative;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 28px 30px;
overflow: hidden;
}
.asset-factory .factory-body {
display: flex;
flex-direction: column;
gap: 18px;
height: 100%;
/* 盖掉旧全局 .factory-body(styles.css) 漏进来的 align-items:center —— 否则内容被居中、宽卡里偏离左边老远 */
align-items: stretch;
}
.asset-factory .factory-text { display: flex; flex-direction: column; min-width: 0; }
.asset-factory .factory-tag {
align-self: flex-start;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .06em;
padding: 2px 8px;
border: 1px solid var(--border-faint);
background: var(--background-lighter);
border-radius: var(--r-sm);
margin-bottom: 14px;
}
.asset-factory .factory-title {
font-size: 22px;
/* ─── YZ 图片创作入口 · 页头 + 三张入口卡 + 最近创作网格 ─── */
.asset-factory .af-hero-head { margin: 4px 0 28px; }
.asset-factory .af-hero-head h1 {
font-size: 32px;
font-weight: 600;
letter-spacing: -.018em;
line-height: 1.25;
letter-spacing: -.03em;
line-height: 1.2;
color: var(--accent-black);
}
.asset-factory .factory-desc {
margin-top: 8px;
font-size: 13.5px;
color: var(--black-alpha-64);
line-height: 1.55;
.asset-factory .af-hero-head p {
margin-top: 10px;
font-size: 14px;
color: var(--black-alpha-48);
}
/* CTA 行:主按钮 + 价格 mono */
.asset-factory .factory-cta {
margin-top: auto;
padding-top: 24px;
.asset-factory .af-hero {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-bottom: 48px;
}
.asset-factory .af-hero-card {
min-width: 0;
aspect-ratio: 3 / 2;
position: relative;
display: flex;
flex-direction: column;
padding: 0;
border: 0;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow-floating), inset 0 0 0 1px var(--border-faint);
overflow: hidden;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
transition: transform var(--t-fast), box-shadow var(--t-base);
}
.asset-factory .af-hero-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(17, 19, 24, .08), inset 0 0 0 1px var(--border-faint);
}
.asset-factory .af-hero-visual {
flex: 1.65 1 0;
min-height: 0;
width: 100%;
background: var(--black-alpha-4);
overflow: hidden;
}
.asset-factory .af-hero-visual img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.asset-factory .af-hero-card.gradient .af-hero-visual {
background: linear-gradient(145deg, var(--accent-amethyst) 0%, #c4b5fd 42%, var(--heat) 100%);
}
.asset-factory .af-hero-copy {
flex: 1 1 0;
min-height: 0;
position: relative;
padding: 16px 40px 16px 18px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.asset-factory .af-hero-copy .t {
font-size: 16px;
font-weight: 600;
letter-spacing: -.01em;
color: var(--accent-black);
}
.asset-factory .af-hero-copy .d {
font-size: 13px;
line-height: 1.5;
color: var(--black-alpha-48);
}
.asset-factory .af-hero-arrow {
position: absolute;
top: 14px;
right: 14px;
width: 22px;
height: 22px;
color: var(--accent-bluetron);
display: grid;
place-items: center;
pointer-events: none;
}
.asset-factory .af-hero-arrow svg { width: 16px; height: 16px; }
.asset-factory .af-recent { margin-top: 8px; }
.asset-factory .af-recent-head { margin-bottom: 18px; }
.asset-factory .af-recent-head h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.asset-factory .af-recent-head p {
margin-top: 6px;
font-size: 13px;
color: var(--black-alpha-48);
}
.asset-factory .af-toolbar {
display: flex;
align-items: center;
gap: 14px;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.asset-factory .factory-cta .cost {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--black-alpha-48);
letter-spacing: .04em;
.asset-factory .af-pills {
display: flex;
flex-direction: row;
gap: 8px;
}
.asset-factory .af-pill {
height: 36px;
padding: 0 16px;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--black-alpha-56);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.asset-factory .af-pill:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.asset-factory .af-pill.active {
background: var(--nav-active);
color: var(--accent-white);
}
.asset-factory .af-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.asset-factory .af-search {
position: relative;
width: 200px;
}
.asset-factory .af-search svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--black-alpha-40);
pointer-events: none;
}
.asset-factory .af-search input {
width: 100%;
height: 36px;
padding: 0 12px 0 34px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
font: inherit;
font-size: 13px;
color: var(--accent-black);
}
.asset-factory .af-search input::placeholder { color: var(--black-alpha-40); }
.asset-factory .af-search input:focus {
outline: none;
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.asset-factory .af-view {
display: inline-flex;
height: 36px;
padding: 3px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
gap: 2px;
}
.asset-factory .af-view button {
width: 30px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--black-alpha-40);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.asset-factory .af-view button.active {
background: var(--black-alpha-7);
color: var(--accent-black);
}
.asset-factory .af-view button:hover { color: var(--accent-black); }
/* ─── 任务中心 · section header(基线 .section-h 带 mono sub)─── */
.asset-factory .section-h { margin-top: 24px; }
.asset-factory .section-h .sub-mono {
font-family: var(--font-mono);
.asset-factory .af-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.asset-factory .af-card {
background: var(--surface);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-floating);
outline: none;
}
.asset-factory .af-card:hover { box-shadow: 0 12px 32px rgba(17, 19, 24, .08); }
.asset-factory .af-card-visual {
position: relative;
aspect-ratio: 4 / 3;
background: var(--black-alpha-4);
overflow: hidden;
}
.asset-factory .af-card-visual img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.asset-factory .af-card-prog {
position: absolute;
left: 10px;
bottom: 10px;
height: 24px;
padding: 0 10px;
border-radius: var(--r-pill);
background: rgba(17, 19, 24, .72);
color: var(--accent-white);
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
}
.asset-factory .af-card-body { padding: 14px 14px 16px; }
.asset-factory .af-card-name {
font-size: 14px;
font-weight: 600;
color: var(--accent-black);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-factory .af-card-meta {
margin-top: 6px;
font-size: 12px;
color: var(--black-alpha-48);
letter-spacing: .02em;
}
.asset-factory .af-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 12px;
}
.asset-factory .af-tag {
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.asset-factory .af-tag.ok {
background: var(--forest-bg);
color: var(--accent-forest);
}
.asset-factory .af-tag.info {
background: var(--heat-12);
color: var(--accent-bluetron);
}
.asset-factory .af-tag.err {
background: var(--crimson-bg);
color: var(--accent-crimson);
}
.asset-factory .af-tag.type {
background: transparent;
color: var(--black-alpha-56);
box-shadow: inset 0 0 0 1px var(--border-muted);
}
.asset-factory .af-tag.type.image {
color: var(--accent-bluetron);
box-shadow: inset 0 0 0 1px rgba(42, 109, 251, .28);
}
.asset-factory .result-meta {
font-family: var(--font-mono);
font-size: 12px;
color: var(--black-alpha-48);
letter-spacing: .02em;
margin: 2px 0 12px;
@media (max-width: 1280px) {
.asset-factory .af-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.asset-factory .af-hero { grid-template-columns: 1fr; }
.asset-factory .af-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.asset-factory .af-grid { grid-template-columns: 1fr; }
}
/* ─── 列表视图 · 表格容器(基线 #task-list-view)─── */
@@ -149,6 +362,8 @@
50% { opacity: .55; transform: scaleY(.7); }
}
.asset-factory .list-pager { margin-top: 20px; }
.asset-factory .task-empty {
padding: 60px 20px;
text-align: center;
@@ -259,18 +474,12 @@
border-radius: var(--r-sm);
font-size: 12px; font-weight: 600;
font-family: inherit; cursor: pointer;
box-shadow:
inset 0 -2px 4px rgba(250, 93, 25, 0.20),
0 1px 1px rgba(250, 93, 25, 0.12),
0 2px 4px rgba(250, 93, 25, 0.10);
box-shadow: var(--shadow-cta);
transition: filter var(--t-base), transform var(--t-fast), box-shadow var(--t-base);
}
.image-workbench .iw-list-h .new-prod:hover {
filter: brightness(.96);
box-shadow:
inset 0 -2px 4px rgba(250, 93, 25, 0.20),
0 1px 1px rgba(250, 93, 25, 0.16),
0 4px 8px rgba(250, 93, 25, 0.20);
box-shadow: var(--shadow-cta-hover);
}
.image-workbench .iw-list-h .new-prod:active { transform: scale(.98); }
.image-workbench .iw-list-h .new-prod svg { width: 12px; height: 12px; }
+4
View File
@@ -139,6 +139,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
if (response.status === 403 && message === "permission denied") {
message = "当前账号权限不足";
}
// nginx / 网关在请求进 Django 前拦大文件,响应体是 HTML「413 Request Entity Too Large」。
if (response.status === 413) {
message = "文件太大,服务器没接收。视频请压到 200MB 以内再传";
}
throw new ApiError(response.status, message);
}
if (response.status === 204) return undefined as T;
+5 -1
View File
@@ -28,7 +28,11 @@ const iconPaths: Record<string, string> = {
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
sliders: '<line x1="4" x2="4" y1="21" y2="14"/><line x1="4" x2="4" y1="10" y2="3"/><line x1="12" x2="12" y1="21" y2="12"/><line x1="12" x2="12" y1="8" y2="3"/><line x1="20" x2="20" y1="21" y2="16"/><line x1="20" x2="20" y1="12" y2="3"/><line x1="2" x2="6" y1="14" y2="14"/><line x1="10" x2="14" y1="8" y2="8"/><line x1="18" x2="22" y1="16" y2="16"/>',
logOut: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
film: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18"/><path d="M17 3v18"/><path d="M3 7.5h4"/><path d="M3 16.5h4"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/><path d="M3 12h4"/><path d="M17 12h4"/>'
film: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18"/><path d="M17 3v18"/><path d="M3 7.5h4"/><path d="M3 16.5h4"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/><path d="M3 12h4"/><path d="M17 12h4"/>',
wand: '<path d="m21.64 3-1.28 1.28a5.5 5.5 0 0 0-7.78 7.78l-8.5 8.5a2.12 2.12 0 0 0 3 3l8.5-8.5a5.5 5.5 0 0 0 7.78-7.78Z"/><path d="m14 7 3 3"/>',
scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/>',
swap: '<path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>',
arrowRight: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'
};
const iconAliases: Record<string, string> = {
+105 -34
View File
@@ -205,6 +205,35 @@ const NAV: NavDef[] = [
{ id: "trash", page: "trash", label: "垃圾桶", icon: "trash" },
{ 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: "trash", page: "trash", label: "垃圾桶" },
];
export type TopModule = "workbench" | "image" | "video";
const OPEN_PALETTE_EVENT = "airshelf:open-palette";
export function openCommandPalette() {
window.dispatchEvent(new Event(OPEN_PALETTE_EVENT));
}
export function topModuleForPage(page: Page): TopModule | null {
if (page === "dashboard") return "workbench";
if (
page === "assetFactory"
|| page === "imageOptimize"
|| page === "modelPhoto"
|| page === "modelPhotoDemoA"
|| page === "modelPhotoDemoB"
|| page === "platformCover"
) return "image";
if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate") return "video";
return null;
}
const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
dashboard: "dashboard",
@@ -251,6 +280,14 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
const activeNav = PAGE_TO_NAV[page];
// 子账号:导航里去掉「团队」「消费」两项(命令面板、账户菜单同步过滤)。
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 =
activeNav === "team" ? "team"
: activeNav === "settings" ? "settings"
: topModule ? null
: resourceNavItems.some((item) => item.page === activeNav) ? "resource"
: null;
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
// YYX#row22:有未读生成任务才显示胶囊(0 不显示)
@@ -275,6 +312,12 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mobileNavOpen]);
// 二级类目展开/收起:进资源中心时展开;切到顶栏模块(工作台/图片/视频)时收起,避免左边再出现选中。
const [resourceOpen, setResourceOpen] = useState(activeMajor === "resource");
useEffect(() => {
if (activeMajor === "resource") setResourceOpen(true);
else if (topModule) setResourceOpen(false);
}, [activeMajor, topModule]);
// 账户下拉菜单:点侧栏 .user 用其 DOMRect 当锚点展开,再次点击收起(toggle)。
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
@@ -290,9 +333,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
window.location.href = "/login";
};
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
// 命令面板:Ctrl/Cmd K,以及顶栏搜索按钮
const [paletteOpen, setPaletteOpen] = useState(false);
const openCommandPalette = () => setPaletteOpen(true);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
@@ -300,8 +342,13 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
setPaletteOpen((value) => !value);
}
};
const onOpen = () => setPaletteOpen(true);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
window.addEventListener(OPEN_PALETTE_EVENT, onOpen);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener(OPEN_PALETTE_EVENT, onOpen);
};
}, []);
return (
@@ -328,28 +375,61 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
<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="search-box" title="搜索 (Ctrl K)" role="button" tabIndex={0} onClick={openCommandPalette} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openCommandPalette(); } }}>
<IconKitSvg name="search" />
<input id="global-search" placeholder="搜索" readOnly aria-label="打开全局搜索" />
<span className="kbd">Ctrl K</span>
<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" />
<IconKitSvg name="library" />
<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); }}
>
<span className="dot" />
<span>{item.label}</span>
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
</a>
))}
</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" />
<IconKitSvg name="users" />
<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" />
<IconKitSvg name="settings" />
<span></span>
</a>
</div>
<div className="nav-section"></div>
<nav>
{navItems.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); }}
>
<IconKitSvg name={item.icon} />
<span>{item.label}</span>
{badges[item.id] !== undefined && <span className="pill-mini">{badges[item.id]}</span>}
</a>
))}
</nav>
{user.is_platform_admin && onOpenAdmin && (
<>
<div className="nav-section"></div>
@@ -399,16 +479,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
}
export function Decorations() {
// 与设计稿 shell.js 一致:grid-bg 底纹 + 4 个 sq-mark(写死坐标,不压内容)
return (
<>
<div className="grid-bg" />
<span className="sq-mark" style={{ top: 238, left: 478 }} />
<span className="sq-mark" style={{ top: 478, left: 1198 }} />
<span className="sq-mark" style={{ bottom: 300, left: 238 }} />
<span className="sq-mark" style={{ top: 718, right: 240 }} />
</>
);
return <div className="grid-bg" />;
}
export function CornerMarks() {
+353
View File
@@ -0,0 +1,353 @@
/* 工作台 · YZ 影擎对照布局。仅 scope 在 .dashboard-page,不改共享 .btn/.pill */
.dashboard-page {
max-width: 1080px;
}
.dashboard-page .dash-hero {
margin: 8px 0 36px;
}
.dashboard-page .dash-hero h1 {
font-size: 32px;
font-weight: 600;
letter-spacing: -.03em;
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;
}
.dashboard-page .dash-overview-cell:hover .v {
color: var(--nav-active);
}
.dashboard-page .dash-mine {
margin-top: 36px;
}
.dashboard-page .dash-mine-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.dashboard-page .dash-mine-head h2 {
font-size: 20px;
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 {
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;
gap: 12px;
}
.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 {
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 .dash-proj-stage {
width: 168px;
flex-shrink: 0;
}
.dashboard-page .dash-proj-stage-lbl {
font-size: 12px;
color: var(--black-alpha-48);
margin-bottom: 4px;
}
.dashboard-page .dash-proj-stage-name {
font-size: 14px;
font-weight: 600;
color: var(--accent-black);
margin-bottom: 10px;
}
.dashboard-page .dash-proj-prog {
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;
border: 0;
border-radius: 999px;
background: var(--nav-active);
color: var(--accent-white);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
}
.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;
min-height: 0;
}
.dashboard-page .dash-overview-cell {
flex: 1 1 40%;
padding: 8px 0;
}
.dashboard-page .dash-proj-card {
flex-wrap: wrap;
}
.dashboard-page .dash-proj-stage {
width: 100%;
}
}
+295 -40
View File
@@ -49,25 +49,26 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* ===== Backgrounds (冷灰) ===== */
--background-base: #f9f9f9;
--background-lighter: #fbfbfb;
/* ===== Backgrounds (冷灰 · YZ 影擎) ===== */
--background-base: #f7f8fa;
--background-lighter: #fbfbfc;
--surface: #ffffff;
--surface-raised: #ffffff;
/* ===== Borders (冷灰 3 档,语义优先) ===== */
--border-faint: #ededed;
--border-muted: #e8e8e8;
--border-loud: #e6e6e6;
--border-faint: #eceef2;
--border-muted: #e6e8ee;
--border-loud: #e2e5eb;
/* ===== Accent multi-color (5 色信号) ===== */
--accent-black: #262626;
--accent-black: #17191d;
--accent-white: #ffffff;
--accent-amethyst: #9061ff;
--accent-bluetron: #2a6dfb;
--accent-crimson: #eb3424;
--accent-forest: #42c366;
--accent-honey: #ecb730;
--nav-active: #111318;
/* status 配套底/边 */
--forest-bg: rgba(66, 195, 102, .08);
@@ -77,15 +78,16 @@
--honey-bg: rgba(236, 183, 48, .08);
--honey-bd: rgba(236, 183, 48, .20);
/* ===== Heat · 单 hue + 8 档 alpha ===== */
--heat: #fa5d19;
--heat-90: rgba(250, 93, 25, .90);
--heat-40: rgba(250, 93, 25, .40);
--heat-20: rgba(250, 93, 25, .20);
--heat-16: rgba(250, 93, 25, .16);
--heat-12: rgba(250, 93, 25, .12);
--heat-8: rgba(250, 93, 25, .08);
--heat-4: rgba(250, 93, 25, .04);
/* ===== Heat · 单 hue + 8 档 alpha · 丁香蓝(对齐 YZ 主 CTA) ===== */
--heat: #A5B4FC;
--heat-hover: #8ea0f8;
--heat-90: rgba(165, 180, 252, .90);
--heat-40: rgba(165, 180, 252, .40);
--heat-20: rgba(165, 180, 252, .20);
--heat-16: rgba(165, 180, 252, .16);
--heat-12: rgba(165, 180, 252, .12);
--heat-8: rgba(165, 180, 252, .08);
--heat-4: rgba(165, 180, 252, .04);
/* ===== Black-alpha 阶梯 (20 档) ===== */
--black-alpha-1: rgba(0, 0, 0, .01);
@@ -128,19 +130,14 @@
/* ===== Shadows ===== */
--shadow-cta:
inset 0 -4px 8px rgba(250, 93, 25, .20),
0 1px 1px rgba(250, 93, 25, .12),
0 2px 4px rgba(250, 93, 25, .10),
0 .5px .5px rgba(250, 93, 25, .16);
0 1px 2px rgba(129, 140, 248, .16),
0 6px 16px rgba(165, 180, 252, .28);
--shadow-cta-hover:
inset 0 -4px 8px rgba(250, 93, 25, .20),
0 1px 1px rgba(250, 93, 25, .16),
0 4px 8px rgba(250, 93, 25, .20),
0 .5px .5px rgba(250, 93, 25, .16);
0 2px 4px rgba(129, 140, 248, .20),
0 10px 22px rgba(165, 180, 252, .34);
--shadow-cta-active:
inset 0 -4px 8px rgba(250, 93, 25, .28),
0 1px 2px rgba(250, 93, 25, .16);
--shadow-floating: 0 4px 20px rgba(21, 20, 15, .06);
0 1px 2px rgba(129, 140, 248, .18);
--shadow-floating: 0 8px 28px rgba(17, 19, 24, .06);
}
::selection { background: var(--heat-20); color: var(--heat); }
@@ -235,6 +232,8 @@ aside.sidebar {
top: 0;
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
transition: padding var(--t-base);
}
.sidebar-head {
@@ -251,7 +250,7 @@ aside.sidebar {
transition: width var(--t-base);
}
.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(--heat); }
.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 {
@@ -380,6 +379,203 @@ 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 左侧导航预览(资源中心胶囊 + 子库) ─── */
.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: 54px;
margin-left: -16px;
margin-right: 22px;
border: 0;
border-radius: 0 28px 28px 0;
background: transparent;
color: #2d3442;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 0 18px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.yz-resource-head.active {
background: var(--nav-active);
color: var(--accent-white);
}
.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: 15px;
height: 15px;
color: #2d3442;
}
.yz-resource-head.active > svg { color: rgba(255, 255, 255, .95); }
.yz-resource-head .chev {
margin-left: auto;
display: inline-flex;
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 {
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;
}
.yz-resource-sub::before {
content: "";
position: absolute;
left: 0;
top: 4px;
bottom: 6px;
width: 1px;
background: #d7dbe3;
}
.yz-resource-sub a {
min-height: 38px;
border-radius: 11px;
padding: 0 12px;
color: #7f838b;
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 500;
transition: background .15s, color .15s;
}
.yz-resource-sub a .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #c8c8c8;
}
.yz-resource-sub a:hover {
background: #f1f3f8;
color: #5e6571;
}
.yz-resource-sub a.active {
background: #e9eefb;
color: #2148bf;
font-weight: 600;
}
.yz-resource-sub a.active .dot {
width: 7px;
height: 7px;
background: #3158d4;
}
.yz-resource-sub a .pill-mini {
margin-left: auto;
border-radius: 999px;
border: 1px solid #d4dcf4;
padding: 0 7px;
line-height: 18px;
font-size: 11px;
color: #4a5ca0;
background: #f4f7ff;
}
.yz-resource-sub.collapsed {
display: none;
max-height: 0;
opacity: 0;
margin-top: 0;
pointer-events: none;
}
.yz-side-link {
min-height: 54px;
margin-left: -16px;
margin-right: 22px;
border-radius: 0 28px 28px 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 0 18px;
color: #2d3442;
font-size: 15px;
font-weight: 600;
transition: background .22s ease, color .22s ease, box-shadow .22s ease;
}
.yz-side-link:hover { background: #f2f4f8; }
.yz-side-link > svg {
width: 20px;
height: 20px;
color: #4f5562;
transition: color .22s ease;
}
.yz-side-link.active {
color: #ffffff;
background: var(--nav-active);
box-shadow: 0 6px 18px rgba(17, 19, 24, .14);
}
.yz-side-link.active > svg {
color: rgba(255, 255, 255, .96);
}
.aside-foot {
margin-top: auto;
padding-top: 14px;
border-top: 0;
}
.user {
border-radius: 999px;
background: #f3f4f7;
justify-content: space-between;
padding: 8px 14px 8px 8px;
}
.user .av {
width: 36px;
height: 36px;
border-radius: 50%;
background: #17191d;
font-size: 14px;
}
.user .em {
flex: 1;
margin-left: 8px;
font-size: 14px;
color: #2f3340;
line-height: 1.2;
}
.user::after {
content: "";
color: #575f6f;
font-size: 20px;
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 .brand-clip {
@@ -420,16 +616,18 @@ body.sidebar-collapsed .user {
justify-content: center;
padding: 8px 0;
}
body.sidebar-collapsed .yz-side-main,
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; }
.grid-bg {
position: absolute; inset: 0; pointer-events: none;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23c8c8c8' stroke-width='1' fill='none'><path d='M-5 0 L5 0 M0 -5 L0 5'/><path d='M235 0 L245 0 M240 -5 L240 5'/><path d='M-5 240 L5 240 M0 235 L0 245'/><path d='M235 240 L245 240 M240 235 L240 245'/></g></svg>"),
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: 240px 240px, 60px 60px, 240px 240px;
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%);
}
@@ -450,20 +648,77 @@ main { position: relative; background: var(--background-base); min-width: 0; }
/* ─── Topbar ─── */
.topbar {
display: flex; align-items: center; gap: 12px;
padding: 14px 28px;
display: flex; align-items: center; gap: 16px;
padding: 16px 28px;
border-bottom: 1px solid var(--border-faint);
background: var(--background-base);
background: var(--surface);
position: sticky; top: 0; z-index: 50;
min-height: 72px;
}
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--black-alpha-48); }
.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 {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
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);
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);
}
.yz-mod:hover { background: var(--black-alpha-8); color: var(--accent-black); }
.yz-mod.active {
background: var(--nav-active);
color: var(--accent-white);
font-weight: 600;
}
.top-search {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
min-width: 196px;
padding: 0 12px;
border: 1px solid var(--border-faint);
border-radius: var(--r-pill);
background: var(--background-lighter);
color: var(--black-alpha-48);
font: inherit;
font-size: 13px;
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 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 {
margin-left: auto;
font-family: var(--font-inter);
font-weight: 700;
font-size: 12px;
color: var(--black-alpha-40);
letter-spacing: .02em;
}
.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.balance-chip {
display: inline-flex; align-items: center; gap: 7px;
height: 36px;
height: 40px;
padding: 0 14px 0 12px;
background: var(--surface);
border: 1px solid var(--border-faint);
@@ -475,9 +730,9 @@ main { position: relative; background: var(--background-base); min-width: 0; }
}
.balance-chip:hover { background: var(--black-alpha-4); border-color: var(--black-alpha-24); }
.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(--heat); }
.balance-chip svg { width: var(--icon-m); height: var(--icon-m); color: var(--black-alpha-48); }
.icon-btn {
width: 36px; height: 36px;
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
background: var(--surface);
border: 1px solid var(--border-faint);
@@ -492,7 +747,7 @@ main { position: relative; background: var(--background-base); min-width: 0; }
.icon-btn .dot-noti {
position: absolute; top: 8px; right: 9px;
width: 7px; height: 7px; border-radius: 50%;
background: var(--heat); border: 1.5px solid var(--surface);
background: var(--accent-crimson); border: 1.5px solid var(--surface);
}
.icon-btn .count-noti {
position: absolute; top: -4px; right: -4px;
@@ -534,7 +789,7 @@ main { position: relative; background: var(--background-base); min-width: 0; }
/* ─── Topbar · 头像 ─── */
.topbar-avatar {
width: 36px; height: 36px;
width: 40px; height: 40px;
border-radius: var(--r-pill);
background: var(--accent-black);
color: var(--accent-white);
@@ -641,8 +896,8 @@ main { position: relative; background: var(--background-base); min-width: 0; }
box-shadow: var(--shadow-cta);
}
.btn-primary:hover {
background: var(--heat);
border-color: var(--heat);
background: var(--heat-hover);
border-color: var(--heat-hover);
box-shadow: var(--shadow-cta-hover);
}
.btn-primary:active {
+1
View File
@@ -2,6 +2,7 @@ import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
import "./design-restraint.css";
import "./dashboard-page.css";
import "./account-page.css";
import "./product-detail-page.css";
import "./team-page.css";
+352 -1
View File
@@ -1,7 +1,358 @@
/* 视频项目页 · public/exact/projects.html 内联 <style> 忠实移植,整段 scope .projects-page
tabs/chip/toolbar/search-inline/result-meta 走全局 design-restraint(restraint );此处只覆盖 proj-* / view-toggle 等页面专属 */
.projects-page {
/* ─── List view ─── */
/* ─── 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); }
.vc-sec { margin-bottom: 40px; }
.vc-sec-h { margin-bottom: 16px; }
.vc-sec-h h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.vc-sec-h p { margin-top: 6px; font-size: 13px; color: var(--black-alpha-48); }
.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)); }
.vc-card {
min-width: 0;
height: 240px;
position: relative;
display: flex;
flex-direction: column;
padding: 0;
border: 0;
border-radius: 12px;
background: var(--surface);
box-shadow: var(--shadow-floating), inset 0 0 0 1px var(--border-faint);
overflow: hidden;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
transition: transform var(--t-fast), box-shadow var(--t-base);
}
.vc-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(17, 19, 24, .08), inset 0 0 0 1px var(--border-faint);
}
.vc-visual {
flex: 1.65 1 0;
min-height: 0;
width: 100%;
background: var(--black-alpha-4);
overflow: hidden;
}
.vc-visual img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vc-copy {
flex: 1 1 0;
min-height: 0;
position: relative;
padding: 16px 40px 16px 18px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.vc-copy .t { font-size: 16px; font-weight: 600; letter-spacing: -.01em; color: var(--accent-black); }
.vc-copy .d { font-size: 13px; line-height: 1.5; color: var(--black-alpha-48); }
.vc-arrow {
position: absolute;
top: 14px;
right: 14px;
width: 22px;
height: 22px;
color: var(--accent-bluetron);
display: grid;
place-items: center;
pointer-events: none;
}
.vc-arrow svg { width: 16px; height: 16px; }
.vc-projects { margin-top: 8px; }
.vc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.vc-pills { display: flex; flex-direction: row; gap: 8px; }
.vc-pill {
height: 36px;
padding: 0 16px;
border: 0;
border-radius: var(--r-pill);
background: transparent;
color: var(--black-alpha-56);
font: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.vc-pill:hover { background: var(--black-alpha-4); color: var(--accent-black); }
.vc-pill.active { background: var(--nav-active); color: var(--accent-white); }
.vc-toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.vc-search { position: relative; width: 200px; }
.vc-search svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--black-alpha-40);
pointer-events: none;
}
.vc-search input {
width: 100%;
height: 36px;
padding: 0 12px 0 34px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
font: inherit;
font-size: 13px;
color: var(--accent-black);
}
.vc-search input::placeholder { color: var(--black-alpha-40); }
.vc-search input:focus { outline: none; border-color: var(--heat-40); box-shadow: inset 0 0 0 1px var(--heat-40); }
.vc-view {
display: inline-flex;
height: 36px;
padding: 3px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
gap: 2px;
}
.vc-view button {
width: 30px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--black-alpha-40);
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.vc-view button.active { background: var(--black-alpha-7); color: var(--accent-black); }
.vc-view button:hover { color: var(--accent-black); }
.vc-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.vc-proj {
background: var(--surface);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-floating), inset 0 0 0 1px var(--border-faint);
outline: none;
display: flex;
flex-direction: column;
aspect-ratio: 3 / 4;
}
.vc-proj:hover { box-shadow: 0 12px 32px rgba(17, 19, 24, .08), inset 0 0 0 1px var(--border-faint); }
.vc-proj-visual {
flex: 1.7 1 0;
min-height: 0;
position: relative;
background: var(--black-alpha-4);
overflow: hidden;
}
.vc-proj-visual img { width: 100%; height: 100%; object-fit: cover; display: block; }
.vc-proj-body {
flex: 1 1 0;
min-height: 0;
padding: 14px 14px 16px;
display: flex;
flex-direction: column;
}
.vc-proj-name {
font-size: 14px;
font-weight: 600;
color: var(--accent-black);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-proj-sub {
margin-top: 6px;
font-size: 12px;
color: var(--black-alpha-48);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vc-proj-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
padding-top: 12px;
}
.vc-tag {
height: 22px;
padding: 0 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.vc-tag.done { background: var(--forest-bg); color: var(--accent-forest); }
.vc-tag.wip { background: var(--heat-12); color: var(--accent-bluetron); }
.vc-tag.draft { background: var(--black-alpha-4); color: var(--black-alpha-56); }
.vc-tag.fail { background: var(--crimson-bg); color: var(--accent-crimson); }
.vc-proj-cta {
height: 28px;
padding: 0 12px;
border: 0;
border-radius: var(--r-pill);
background: var(--black-alpha-4);
color: var(--accent-black);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.vc-proj-cta:hover { background: var(--black-alpha-7); }
.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; }
}
/* ─── YZ 预览 · 筛选胶囊(全部 / 进行中 / 已成片) ─── */
.proj-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 22px;
}
.proj-pill {
height: 36px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid var(--border-faint);
background: var(--surface);
color: var(--black-alpha-56);
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
}
.proj-pill:hover { background: var(--background-lighter); color: var(--accent-black); }
.proj-pill .count { font-family: var(--font-mono); font-size: 13px; letter-spacing: .02em; }
.proj-pill.active {
background: var(--surface);
border-color: var(--heat-40);
color: var(--heat);
box-shadow: 0 0 0 1px var(--heat-12) inset;
}
.proj-pill.active .count { color: var(--heat); }
/* ─── YZ 预览 · 项目卡行(缩略图 + 阶段 + 黑按钮) ─── */
.proj-card-list { display: flex; flex-direction: column; gap: 14px; }
.proj-card-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) minmax(140px, 200px) auto;
align-items: center;
gap: 20px;
padding: 16px 20px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
position: relative;
}
.proj-card-row:hover { border-color: var(--black-alpha-24); box-shadow: 0 2px 12px rgba(0, 0, 0, .04); }
.proj-card-row.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
.proj-card-row-thumb {
width: 72px;
height: 72px;
border-radius: var(--r-md);
flex-shrink: 0;
}
.proj-card-row-title { font-size: 15px; font-weight: 600; color: var(--accent-black); line-height: 1.35; }
.proj-card-row-sub { font-size: 13px; color: var(--black-alpha-48); margin-top: 6px; line-height: 1.4; }
.proj-card-row-stage-lbl { font-size: 12px; color: var(--black-alpha-48); margin-bottom: 4px; }
.proj-card-row-stage-name { font-size: 14px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
.proj-card-row-cta {
height: 40px;
min-width: 88px;
padding: 0 22px;
border: 0;
border-radius: 999px;
background: var(--accent-black);
color: var(--accent-white);
font-size: 14px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
justify-self: end;
transition: opacity .15s;
}
.proj-card-row-cta:hover { opacity: .88; }
.proj-card-row-more {
position: absolute;
top: 14px;
right: 14px;
opacity: 0;
transition: opacity .15s;
}
.proj-card-row:hover .proj-card-row-more { opacity: 1; }
/* 进度条:已完成绿 · 当前橙 · 未到灰(对齐 YZ 稿,去掉橙色脉动) */
.proj-prog-yz { display: flex; gap: 4px; }
.proj-prog-yz span {
flex: 1;
min-width: 0;
max-width: 28px;
height: 6px;
border-radius: 3px;
background: var(--black-alpha-8);
}
.proj-prog-yz span.done { background: var(--accent-forest); animation: none; }
.proj-prog-yz span.cur { background: var(--accent-honey); animation: none; transform: none; opacity: 1; }
.proj-prog-yz span.fail { background: var(--accent-crimson); animation: none; }
/* ─── List view (legacy table helpers,编辑态仍复用) ─── */
.proj-name-cell { display: flex; align-items: center; gap: 12px; }
.proj-thumb { width: 40px; height: 52px; flex-shrink: 0; border-radius: var(--r-md); }
.proj-name { font-weight: 600; color: var(--accent-black); font-size: 14px; }
+125 -136
View File
@@ -4,6 +4,7 @@ import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
ArrowUpRight,
Bookmark,
Check,
ChevronDown,
@@ -48,6 +49,25 @@ const MODE_LABEL: Record<string, string> = {
cover: "平台套图",
image: "图片创作"
};
const MODE_TAG: Record<string, string> = {
model: "模特上身",
cover: "平台套图",
image: "自由创作",
};
function formatAfTime(iso: string) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
const pad = (n: number) => String(n).padStart(2, "0");
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = new Date();
if (d.toDateString() === now.toDateString()) return `今天 ${hm}`;
const yest = new Date(now);
yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return `昨天 ${hm}`;
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${hm}`;
}
const STATUS_LABEL: Record<string, string> = {
succeeded: "已完成",
@@ -108,36 +128,33 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return () => { alive = false; };
}, []);
const taskImage = useMemo(() => {
const map: Record<string, string> = {};
const map: Record<string, { url: string; name: string }> = {};
for (const asset of assets) {
const taskId = asset.origin_task;
if (!taskId || map[taskId]) continue;
const file = asset.files?.find((f) => f.preview_url && (f.content_type?.startsWith("image") ?? true));
if (file?.preview_url) map[taskId] = file.preview_url;
if (file?.preview_url) map[taskId] = { url: file.preview_url, name: asset.name || "" };
}
return map;
}, [assets]);
const cards = [
{
page: "modelPhoto" as Page,
tag: "[ MODEL · TRY-ON ]",
title: "模特上身图",
desc: "选择模特,AI 生成商品模特上身效果图",
cost: "≈ 20 积分 / 张"
title: "模特上身",
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
image: "/exact/assets/mock/person-linxi.png",
},
{
page: "platformCover" as Page,
tag: "[ PLATFORM · KIT ]",
title: "平台套图",
desc: "选择平台模板,AI 生成电商平台套图",
cost: "≈ 20 积分 / 张"
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
image: "/exact/assets/mock/cover-earbuds.png",
},
{
page: "imageOptimize" as Page,
tag: "[ IMAGE · STUDIO ]",
title: "图片创作",
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
cost: "≈ 20 积分 / 张"
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
image: "",
}
];
@@ -145,8 +162,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
// 注意 mode 也被脚本 agent 复用为 auto/theme/revise,故必须用白名单过滤,别把脚本任务混进来)。
// 每张图是一个独立 AITask,同次提交共享 batch_id → 按 batch_id 归成「批次卡」;旧图无 batch_id 则各自成单。
type TaskBatch = {
key: string; batchId: string | null; firstTaskId: string; label: string; mode: string;
count: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
key: string; batchId: string | null; firstTaskId: string; label: string; title: string; mode: string;
count: number; doneCount: number; status: "info" | "ok" | "err"; created_at: string; cover: string;
};
const taskBatches = useMemo<TaskBatch[]>(() => {
const groups = new Map<string, AITask[]>();
@@ -159,27 +176,25 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
}
const out: TaskBatch[] = [];
for (const [key, tasks] of groups) {
const first = tasks[0];
const first = tasks[0] as AITask & { product_title?: string; prompt?: string };
const pills = tasks.map((t) => statusPill(t.status));
const status: "info" | "ok" | "err" = pills.some((p) => p === "info") ? "info" : pills.some((p) => p === "ok") ? "ok" : "err";
const created = tasks.reduce((m, t) => ((t.created_at || "") > m ? (t.created_at || "") : m), "");
const cover = tasks.map((t) => taskImage[t.id]).find(Boolean) || "";
out.push({ key, batchId: first.batch_id || null, firstTaskId: first.id, label: MODE_LABEL[first.mode!], mode: first.mode!, count: tasks.length, status, created_at: created, cover });
const coverHit = tasks.map((t) => taskImage[t.id]).find(Boolean);
const doneCount = pills.filter((p) => p === "ok").length;
const title = first.product_title || first.prompt?.slice(0, 24) || coverHit?.name || MODE_LABEL[first.mode!] || "图片任务";
out.push({
key, batchId: first.batch_id || null, firstTaskId: first.id,
label: MODE_LABEL[first.mode!] || "图片创作",
title, mode: first.mode!,
count: tasks.length, doneCount, status, created_at: created,
cover: coverHit?.url || "",
});
}
out.sort((a, b) => b.created_at.localeCompare(a.created_at));
return out;
}, [aiTasks, taskImage]);
const counts = useMemo(() => {
const acc = { gen: 0, ok: 0, err: 0 };
for (const b of taskBatches) {
if (b.status === "ok") acc.ok += 1;
else if (b.status === "err") acc.err += 1;
else acc.gen += 1;
}
return acc;
}, [taskBatches]);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
@@ -187,8 +202,8 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
// 任务中心分页:每页 8 条(4 列网格两行),筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 8;
const [taskPage, setTaskPage] = useState(1);
// 批次详情弹窗:点批次卡 → 按 metadata.batch_id 拉该批全部图(含未入库)→ 网格展示,点图放大
const [openBatch, setOpenBatch] = useState<TaskBatch | null>(null);
@@ -232,7 +247,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${batch.label} ${batch.key}`.toLowerCase();
const hay = `${batch.label} ${batch.title} ${batch.key}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
@@ -245,140 +260,113 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
return (
<div className="asset-factory">
<div className="page-head">
<div>
<h1></h1>
<div className="sub">
<span className="mono">// 一键生成</span>
<span>·</span>
<span></span>
</div>
</div>
</div>
<header className="af-hero-head">
<h1></h1>
<p></p>
</header>
<div className="factory-hero">
<div className="af-hero">
{cards.map((card) => (
<article className="factory-card with-corners" key={card.title}>
<span className="corner-tr" aria-hidden />
<span className="corner-bl" aria-hidden />
<div className="factory-body">
<div className="factory-text">
<span className="factory-tag">{card.tag}</span>
<div className="factory-title">{card.title}</div>
<div className="factory-desc">{card.desc}</div>
{/* CTA factory-text ( asset-factory.html):,
factory-body height:100% + margin-top:auto */}
<div className="factory-cta">
<button className="btn btn-primary btn-lg" type="button" onClick={() => navigate(card.page)}>
<ArrowRight size={14} />
</button>
<span className="cost">[ {card.cost} ]</span>
</div>
</div>
<button className={`af-hero-card${card.image ? "" : " gradient"}`} type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="af-hero-visual">
{card.image ? <img src={card.image} alt="" /> : null}
</div>
</article>
<div className="af-hero-copy">
<span className="af-hero-arrow" aria-hidden="true"><ArrowUpRight size={16} /></span>
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</div>
</button>
))}
</div>
<div className="section-h">
<h2></h2>
<span className="sub-mono">
// {taskBatches.length} 批 · {counts.gen} 生成中 · {counts.ok} 已完成 · {counts.err} 失败
</span>
</div>
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
<div className="tabs" id="tc-tabs">
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}> <span className="count">{taskBatches.length}</span></div>
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}> <span className="count">{counts.gen}</span></div>
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}> <span className="count">{counts.ok}</span></div>
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}> <span className="count">{counts.err}</span></div>
</div>
<div className="toolbar">
<div className="search-inline">
<Search size={14} />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
<section className="af-recent">
<div className="af-recent-head">
<h2></h2>
<p></p>
</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">{TIME_OPTS.find((o) => o.value === timeFilter)?.label !== "全部时间" ? TIME_OPTS.find((o) => o.value === timeFilter)?.label : "时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
<div className="af-toolbar">
<div className="af-pills" role="tablist" aria-label="任务状态">
<button className={`af-pill${filter === "all" ? " active" : ""}`} type="button" onClick={() => setFilter("all")}></button>
<button className={`af-pill${filter === "gen" ? " active" : ""}`} type="button" onClick={() => setFilter("gen")}></button>
<button className={`af-pill${filter === "ok" ? " active" : ""}`} type="button" onClick={() => setFilter("ok")}></button>
<button className={`af-pill${filter === "err" ? " active" : ""}`} type="button" onClick={() => setFilter("err")}></button>
</div>
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{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>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); 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="af-toolbar-right">
<div className="af-search">
<Search size={14} />
<input placeholder="搜索任务名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); 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>{t}
<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">{TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
))}
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{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>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); 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>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); 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>{t}
</div>
))}
</div>
</div>
<div className="af-view">
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
<Grid2X2 size={14} />
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
<List size={14} />
</button>
</div>
</div>
</div>
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && (
<button className="clear-filters" type="button" onClick={() => { setFilter("all"); setQuery(""); setTimeFilter("all"); setTypeFilter(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
)}
<span className="spacer" />
<div className="view-toggle">
<button type="button" className={view === "grid" ? "active" : ""} data-view="grid" onClick={() => setView("grid")}>
<Grid2X2 size={13} />
</button>
<button type="button" className={view === "list" ? "active" : ""} data-view="list" onClick={() => setView("list")}>
<List size={13} />
</button>
</div>
</div>
<div className="result-meta">
// 显示 {paged.length} / {visible.length} 批
</div>
{tasksLoading && aiTasks.length === 0 ? (
<SkeletonRows count={5} />
) : aiTasks.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO TASKS YET</div>
<div></div>
<div></div>
</div>
) : visible.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO MATCH</div>
<div></div>
</div>
) : view === "grid" ? (
<div className="history-grid">
<div className="af-grid">
{paged.map((batch) => {
const statusLabel = batch.status === "info" ? "生成中" : batch.status === "err" ? "失败" : "已完成";
return (
<article className="task-card history-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
<article className="af-card" key={batch.key} role="button" tabIndex={0} title="查看本批图片"
onClick={() => setOpenBatch(batch)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenBatch(batch); } }}>
<div className={`placeholder${batch.cover ? " has-img" : ""}`}>{batch.cover ? <img src={batch.cover} alt={batch.label} loading="lazy" decoding="async" /> : <span className="ph-frame">{batch.label}</span>}</div>
<div className="history-body">
<div className="history-name">{batch.label}</div>
<div className="history-type">// {batch.count} 张</div>
<div className="history-foot">
<span className="mono">{(batch.created_at || "").slice(0, 10)}</span>
<span className={`pill ${batch.status}`}><span className="dot" />{statusLabel}</span>
<div className={`af-card-visual${batch.cover ? " has-img" : ""}`}>
{batch.cover ? <img src={batch.cover} alt={batch.title} loading="lazy" decoding="async" /> : null}
{batch.status === "info" ? (
<div className="af-card-prog"> · {batch.doneCount}/{batch.count}</div>
) : null}
</div>
<div className="af-card-body">
<div className="af-card-name">{batch.title}</div>
<div className="af-card-meta">{batch.status === "err" ? "生成失败" : `${formatAfTime(batch.created_at)} · ${batch.count}`}</div>
<div className="af-card-foot">
<span className={`af-tag ${batch.status}`}>{statusLabel}</span>
<span className={`af-tag type ${batch.mode}`}>{MODE_TAG[batch.mode] || batch.label}</span>
</div>
</div>
</article>
@@ -446,6 +434,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</section>
{/* 批次详情弹窗:展示该批生成的全部图片(参考资产库),点图放大 */}
{openBatch && createPortal(
+207 -102
View File
@@ -1,138 +1,243 @@
import { useEffect, useState } from "react";
import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import type { BillingSummary, Product, Project } from "../types";
import type { NavigateFn } from "./route-config";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { money, stageMeta, statusPill } from "./stage-config";
import type { NavigateFn, Page } from "./route-config";
import { IconKitSvg } from "../components/IconKitSvg";
import { ProductCreateDrawer, type ProductCreatePayload } from "../components/product-create-drawer";
// 工作台「最近项目」进度条:4 段(与视频项目菜单一致)
// script→1 / base_assets→2 / storyboard→3 / video|export→4
// completed:全 done(绿);failed:当前格 fail(红),之前 done;其余:之前 done,当前 cur(橙闪)
const DASH_STAGE_TOTAL = 4;
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 4 };
type CreateTone = "blue-a" | "blue-b" | "light";
type DashTab = "all" | "wip" | "done";
const CREATE_GROUPS: Array<{
label: string;
cards: Array<{
title: string;
desc: string;
icon: string;
tone: CreateTone;
page: Page;
}>;
}> = [
{
label: "从商品开始",
cards: [
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", icon: "wand", tone: "blue-a", page: "projectWizard" },
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", icon: "clapperboard", tone: "blue-b", page: "projectWizard" },
],
},
{
label: "从参考视频开始",
cards: [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", icon: "scan", tone: "light", page: "freeCreate" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", icon: "swap", tone: "light", page: "freeCreate" },
],
},
];
const DASH_STAGE_TOTAL = 5;
const DASH_STAGE_NO: Record<string, number> = { script: 1, base_assets: 2, storyboard: 3, video: 4, export: 5 };
const DASH_TABS: Array<{ filter: DashTab; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "wip", label: "进行中" },
{ filter: "done", label: "已成片" },
];
function dashBucket(project: Project): DashTab {
return project.status === "completed" ? "done" : "wip";
}
function dashStageNo(project: Project) {
return project.status === "completed" ? DASH_STAGE_TOTAL : (DASH_STAGE_NO[project.current_stage] || 1);
}
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" : "";
}
function DashProgress({ project }: { project: Project }) {
const no = project.status === "completed"
? DASH_STAGE_TOTAL
: (DASH_STAGE_NO[project.current_stage] || 1);
return (
<div className="prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
);
function dashStageLabel(project: Project): string {
if (project.status === "failed") return "失败";
const map: Record<string, string> = {
script: "脚本",
base_assets: "资产创建",
storyboard: "故事板",
video: "视频生成",
export: "视频生成",
};
return map[project.current_stage] || "视频生成";
}
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(" / ");
}
// 与「视频项目」共用项目列表返回的商品主图;空值继续走现有 9:16 占位图。
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
export function Dashboard({ products, projects, productTotal, projectTotal, billing, userName, loading = false, navigate, onCreateProduct }: {
export function Dashboard({
products,
projects,
productTotal: _productTotal,
projectTotal,
billing: _billing,
userName,
loading: _loading = false,
navigate,
}: {
products: Product[];
projects: Project[];
productTotal?: number; // 后端真实总数(分页 count),用于「N 个/SKU」展示
productTotal?: number;
projectTotal?: number;
billing: BillingSummary | null;
userName?: string;
loading?: boolean;
navigate: NavigateFn;
// YYX#11:工作台点「新建商品」直接弹抽屉(复用商品库同一组件),而非跳商品库
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
onCreateProduct?: (payload: unknown) => Promise<Product | null | undefined> | void;
}) {
// YYX#11:新建商品抽屉开关
const [createDrawer, setCreateDrawer] = useState(false);
const [tab, setTab] = useState<DashTab>("all");
const projCount = projectTotal ?? projects.length;
const prodCount = productTotal ?? products.length;
// 资产条目总数:与资产库六个 Tab 一致,包含按项目聚合的「视频成品」素材包。
const [assetCount, setAssetCount] = useState<number | null>(null);
useEffect(() => {
let alive = true;
Promise.all([api.assetSummary(), api.videoPacks()])
.then(([c, packs]) => {
if (!alive) return;
const imageCreations = c.image_creations ?? c.creations ?? 0;
setAssetCount((c.tryon || 0) + (c.kits || 0) + imageCreations + (c.video_creations || 0) + packs.length + (c.others || 0));
})
.catch(() => {});
return () => { alive = false; };
}, []);
const completed = projects.filter((project) => project.status === "completed").length;
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const counts = {
all: projCount,
wip: running,
done: completed,
};
// 日期 mono:`// 06.19 · 周四`(对齐 V1 mono 注释风,补零到两位)
const now = new Date();
const pad2 = (n: number) => String(n).padStart(2, "0");
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
const dateMono = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())} · 周${weekdays[now.getDay()]}`;
// 「总项目」delta = 本月新建数(真实,按 created_at 算)。
// 但 projects 仅首页分页数据;当存在未加载的项目(projCount > 已加载)时,本月数会少算 → 宁可不显示也不显假值。
const dateLabel = `${pad2(now.getMonth() + 1)}.${pad2(now.getDate())}`;
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
const newThisMonth = projects.filter((p) => p.created_at && new Date(p.created_at).getTime() >= monthStart).length;
const allProjectsLoaded = projects.length >= projCount;
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
const greetName = userName ? userName.split("@")[0] : "";
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const listed = useMemo(() => {
const rows = tab === "all" ? projects : projects.filter((project) => dashBucket(project) === tab);
return rows.slice(0, 3);
}, [projects, tab]);
// 冻结信息:真实「冻结 X 积分 / 共 Y 积分」,无 budget/已用字段,故不复刻 V1「已用/共」。
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
const balanceNum = Number(billing?.account.balance ?? 0);
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
const fundsTotal = balanceNum + reservedNum;
return (
<>
<div className="page-head">
<div style={{ flex: 1, minWidth: 0 }}>
<h1>{userName ? `${userName.split("@")[0]}` : ""}</h1>
<div className="sub">
<span className="mono">// {dateMono}</span><span>·</span><span>你有 <b>{running} 个项目</b> 正在进行中</span>
<span className="dash-funds-mono" style={{ marginLeft: "auto" }} onClick={() => navigate("account")}>
{money(balanceNum)} · {money(reservedNum)} / {money(fundsTotal)}
</span>
<section className="dashboard-page">
<header className="dash-hero">
<h1>{greetName ? `${greetName}` : ""}</h1>
<p className="dash-hero-sub">
{dateLabel} · {running}
</p>
</header>
<section className="dash-create" aria-label="开始创作">
<h2></h2>
{CREATE_GROUPS.map((group) => (
<div className="dash-create-group" key={group.label}>
<div className="dash-create-label">{group.label}</div>
<div className="dash-create-row">
{group.cards.map((card) => (
<button
key={card.title}
className={`dash-create-card ${card.tone}`}
type="button"
onClick={() => navigate(card.page)}
>
<span className="dash-create-ic" aria-hidden="true">
<IconKitSvg name={card.icon} size={22} strokeWidth={1.7} />
</span>
<span className="dash-create-copy">
<span className="t">{card.title}</span>
<span className="d">{card.desc}</span>
</span>
<span className="dash-create-arrow" aria-hidden="true">
<IconKitSvg name="arrowRight" size={18} strokeWidth={1.8} />
</span>
</button>
))}
</div>
</div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={() => (onCreateProduct ? setCreateDrawer(true) : navigate("products"))}>
<IconKitSvg name="productPlus" size={16} />
))}
</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="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<IconKitSvg name="clapperboard" size={16} />
<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>
</div>
<div className="stats with-corners" style={{ gridTemplateColumns: "repeat(3, 1fr)" }}><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => {
const cover = project.cover_preview_url || "";
return <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className={`placeholder thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>;
})}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
<ProductCreateDrawer
open={createDrawer}
close={() => setCreateDrawer(false)}
onCreate={onCreateProduct}
onCreated={() => { setCreateDrawer(false); navigate("products"); }}
/>
)}
</>
</section>
<section className="dash-mine" aria-label="我的项目">
<div className="dash-mine-head">
<h2></h2>
<button className="dash-mine-all" type="button" onClick={() => navigate("projects")}>
<IconKitSvg name="chevronRight" size={14} />
</button>
</div>
<div className="dash-mine-pills" role="tablist" aria-label="项目筛选">
{DASH_TABS.map((item) => (
<button
key={item.filter}
className={`dash-mine-pill${tab === item.filter ? " active" : ""}`}
type="button"
role="tab"
aria-selected={tab === item.filter}
onClick={() => setTab(item.filter)}
>
{item.label} {counts[item.filter]}
</button>
))}
</div>
<div className="dash-mine-list">
{listed.length === 0 ? (
<div className="dash-mine-empty"></div>
) : listed.map((project) => {
const no = dashStageNo(project);
const cover = project.cover_preview_url || "";
const openProject = () => navigate("pipeline", { projectId: project.id });
return (
<article
key={project.id}
className="dash-proj-card"
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
<div className={`placeholder dash-proj-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}>
<span className="ph-frame">9:16</span>
</div>
<div className="dash-proj-main">
<div className="dash-proj-title">{project.name}</div>
<div className="dash-proj-sub">{dashCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="dash-proj-stage">
<div className="dash-proj-stage-lbl"></div>
<div className="dash-proj-stage-name">{dashStageLabel(project)}</div>
<div className="dash-proj-prog">
{Array.from({ length: DASH_STAGE_TOTAL }, (_, k) => k + 1).map((i) => (
<span key={i} className={dashProgClass(project, i, no)} />
))}
</div>
</div>
<button
className="dash-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{project.status === "completed" ? "查看" : "继续"}
</button>
</article>
);
})}
</div>
</section>
</section>
);
}
export function KpiStat({ label, badge, value, delta, onClick }: { label: string; badge: string; value: string | number; delta?: string; onClick?: () => void }) {
const inner = <><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</>;
// 可点的统计块复用现有 .stat 类(与余额块一致),不新造样式/色值。
if (onClick) return <button className="stat" type="button" onClick={onClick}>{inner}</button>;
return <div className="stat">{inner}</div>;
}
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
return <a className="shortcut" onClick={onClick}><div className="ic"><IconKitSvg name={name} size={16} /></div><div><div className="t">{title}</div><div className="d">{desc}</div></div></a>;
}
+6
View File
@@ -1766,6 +1766,12 @@ export function PipelinePage(props: {
onNotify?.("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
return;
}
// 与后端 video_digest.MAX_UPLOAD_BYTES 对齐;超了别发请求,否则网关直接 413。
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
if (file.size > VIDEO_DIGEST_MAX_BYTES) {
onNotify?.("error", "视频不能超过 200MB,请压缩后再传");
return;
}
setVideoDigestBusy(true);
pushMsg("user", `已上传视频《${file.name}`);
pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…");
+255 -199
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { ArrowUpRight, Grid2X2, List, Search } from "lucide-react";
import type { Asset, Product, Project, ScriptTemplate } from "../types";
import { api } from "../api";
import type { Page } from "./route-config";
@@ -11,7 +12,7 @@ import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
const PROJ_PAGE_SIZE = 8; // 4 列网格两行
// 创建页只保留商品与项目名;时长在脚本阶段统一落定,避免前置参数脱钩。
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
@@ -380,7 +381,12 @@ function projProgClass(project: Project, i: number, no: number): string {
if (project.status === "failed") return i === no ? "fail" : i < no ? "done" : "";
return i < no ? "done" : i === no ? "cur" : "";
}
function projBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; }
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] || "进行中";
}
@@ -416,9 +422,53 @@ function projShotMeta(project: Project): string {
// (原先按项目名关键词映射静态 mock 假图,与真实数据脱节,已废弃。)
const coverStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; label: string }> = [
{ filter: "all", label: "全部" },
{ filter: "draft", label: "草稿" },
{ filter: "wip", label: "生成中" },
{ filter: "done", label: "已完成" },
{ filter: "fail", label: "失败" },
];
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_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" },
];
function projCardSub(project: Project): string {
if (project.status === "failed") return project.failure_reason || "生成失败,可重新拍摄";
return projStageLabel(project);
}
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> = {
script: "脚本",
base_assets: "资产创建",
storyboard: "故事板",
video: "视频生成",
export: "视频生成",
};
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[];
@@ -431,12 +481,12 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
// 工作台统计块点击带入的初始 tab(all/wip/done/fail);变化时覆盖当前 tab。
initialTab?: string;
}) {
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "grid");
const [tab, setTab] = useState<ProjTab>("all");
// initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。
useEffect(() => {
if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as "all" | "wip" | "done" | "fail");
if (initialTab && ["all", "draft", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as ProjTab);
}
}, [initialTab]);
const [query, setQuery] = useState("");
@@ -460,28 +510,21 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
}
// 成片播放:合成过的项目点播放键直接弹窗播成片,不再跳去视频生成页
const [playing, setPlaying] = useState<Project | null>(null);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
const [openChip, setOpenChip] = useState<"" | "product" | "time">("");
const [catFilter, setCatFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
const [timeFilter, setTimeFilter] = useState<"all" | "7" | "30" | "90">("all");
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const productCat = (id: string) => products.find((product) => product.id === id)?.category || "";
// 筛选选项(全部来自真实数据)
const projectCategories = Array.from(new Set(projects.map((p) => productCat(p.product)).filter(Boolean))) as string[];
const SRC_OPTS: Array<{ value: "all" | "has" | "none"; label: string }> = [
{ value: "all", label: "全部来源" },
{ value: "has", label: "AI 已生成脚本" },
{ value: "none", label: "暂无脚本" }
];
const TIME_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
{ value: "all", label: "全部时间" },
{ value: "7", label: "近 7 天" },
{ value: "30", label: "近 30 天" },
{ value: "90", label: "近 90 天" }
];
const srcLabel = SRC_OPTS.find((o) => o.value === sourceFilter)?.label || "脚本来源";
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "创建时间";
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "全部时间";
useEffect(() => {
if (!openChip) return;
@@ -492,21 +535,10 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
return () => document.removeEventListener("click", close);
}, [openChip]);
const counts = {
all: projects.length,
wip: projects.filter((p) => projBucket(p) === "wip").length,
done: projects.filter((p) => projBucket(p) === "done").length,
fail: projects.filter((p) => projBucket(p) === "fail").length
};
const filtered = projects.filter((project) => {
if (tab !== "all" && projBucket(project) !== tab) return false;
if (!`${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase())) return false;
if (catFilter && productCat(project.product) !== catFilter) return false;
if (sourceFilter !== "all") {
const hasScript = (project.script_version_count ?? project.script_versions?.length ?? 0) > 0;
if (sourceFilter === "has" && !hasScript) return false;
if (sourceFilter === "none" && hasScript) return false;
}
if (timeFilter !== "all" && project.created_at) {
const days = (Date.now() - new Date(project.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
@@ -520,7 +552,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, timeFilter]);
useEffect(() => { setPage(1); }, [tab, query, catFilter, timeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJ_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROJ_PAGE_SIZE, curPage * PROJ_PAGE_SIZE);
@@ -546,192 +578,216 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
return (
<section className="projects-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="proj-manage-btn" onClick={() => setEditMode((v) => !v)}>
<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="proj-manage-label">{editMode ? "完成" : "管理项目"}</span>
</button>
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
</button>
</div>
</div>
<header className="vc-head">
<h1></h1>
<p></p>
</header>
<div className="tabs" id="status-tabs">
{PROJ_TABS.map((t) => (
<div className={`tab${tab === t.filter ? " active" : ""}`} key={t.filter} data-filter={t.filter} onClick={() => setTab(t.filter)}>{t.label} <span className="count">{counts[t.filter]}</span></div>
))}
</div>
<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)} />
<section className="vc-sec">
<div className="vc-sec-h">
<h2></h2>
<p></p>
</div>
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{catFilter || "商品品类"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
<div className="vc-row cols-2">
{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>
</div>
</button>
))}
</div>
</section>
<section className="vc-sec">
<div className="vc-sec-h">
<h2>AI </h2>
<p></p>
</div>
<div className="vc-row cols-3">
{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>
</div>
</button>
))}
</div>
</section>
<section className="vc-projects">
<div className="vc-sec-h">
<h2></h2>
<p></p>
</div>
<div className="vc-toolbar">
<div className="vc-pills" role="tablist" aria-label="项目状态">
{PROJ_TABS.map((t) => (
<button
type="button"
role="tab"
aria-selected={tab === t.filter}
className={`vc-pill${tab === t.filter ? " active" : ""}`}
key={t.filter}
onClick={() => setTab(t.filter)}
>
{t.label}
</button>
))}
</div>
<div className="vc-toolbar-right">
<div className="vc-search">
<Search size={14} />
<input placeholder="搜索项目名称" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{projectCategories.length > 0 && <div className="mi-sep" />}
{projectCategories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{timeLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
))}
</div>
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{catFilter || "全部类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{projectCategories.length > 0 && <div className="mi-sep" />}
{projectCategories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
</div>
))}
</div>
</div>
<div className="vc-view">
<button type="button" className={view === "grid" ? "active" : ""} aria-label="网格" onClick={() => setView("grid")}>
<Grid2X2 size={14} />
</button>
<button type="button" className={view === "list" ? "active" : ""} aria-label="列表" onClick={() => setView("list")}>
<List size={14} />
</button>
</div>
</div>
</div>
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source">
<button className={`chip${sourceFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{sourceFilter === "all" ? "脚本来源" : srcLabel}</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">
{SRC_OPTS.map((opt) => (
<div className={`mi${sourceFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setSourceFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{timeFilter === "all" ? "创建时间" : timeLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
{(query || catFilter || sourceFilter !== "all" || timeFilter !== "all" || tab !== "all") && (
<button className="chip chip-clear" type="button" onClick={() => { setQuery(""); setCatFilter(""); setSourceFilter("all"); setTimeFilter("all"); setTab("all"); setOpenChip(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
)}
<span className="spacer"></span>
<div className="view-toggle">
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="5" height="5" /><rect x="9" y="2" width="5" height="5" /><rect x="2" y="9" width="5" height="5" /><rect x="9" y="9" width="5" height="5" /></svg>
</button>
<button className={view === "list" ? "active" : ""} type="button" data-view="list" onClick={() => setView("list")}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 4h12M2 8h12M2 12h12" /></svg>
</button>
</div>
</div>
<div className="result-meta" id="result-meta">// {loading && projects.length === 0 ? "加载中…" : <>显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</>}</div>
{loading && projects.length === 0 ? (
<SkeletonRows count={6} />
) : view === "list" ? (
<div id="list-view">
<table className="t">
<thead>
<tr>
<th style={{ width: "32%" }}></th>
<th></th>
<th></th>
<th style={{ width: "200px" }}></th>
<th></th>
<th style={{ width: "120px" }}></th>
<th style={{ width: "60px" }}></th>
</tr>
</thead>
<tbody id="list-tbody">
{pageItems.map((project) => {
const no = projStageNo(project);
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
return (
<tr key={project.id} data-status={projBucket(project)} data-name={project.name} className={editMode && isSel ? "row-selected" : undefined} onClick={() => (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
<td>
<div className="proj-name-cell">
{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-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div><div className="proj-name">{project.name}</div><div className="proj-sub">{projShotMeta(project)}</div></div>
</div>
</td>
<td>{productTitle(project.product)}</td>
<td><span className="muted">{(project.script_version_count ?? project.script_versions?.length ?? 0) > 0 ? "AI 已生成" : "暂无脚本"}</span></td>
<td>
<div className="hstack">
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
</div>
</td>
<td><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span></td>
<td className="muted-2">{projDate(project)}</td>
<td>
{/* 编辑态走整行勾选 + 批量删除栏,不再显示行末单删(去重);常驻态保留 ⋯ 气泡删除 */}
{!editMode && (
<div className="row-action">
{/* 合成过成片 → 直接弹窗播成片;没合成过 → 照旧进流水线继续做 */}
<a
href="#"
title={project.final_video_url ? "播放成片" : "继续"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
}}
><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
<span className="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>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : view === "grid" ? (
<div className="vc-grid">
{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const bucket = projBucket(project);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
className={`vc-proj${editMode && isSel ? " selected" : ""}`}
key={project.id}
role="button"
tabIndex={0}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
>
<div className={`vc-proj-visual${cover ? " has-img" : ""}`}>
{cover ? <img src={cover} alt="" loading="lazy" decoding="async" /> : null}
</div>
<div className="vc-proj-body">
<div className="vc-proj-name">{project.name}</div>
<div className="vc-proj-sub">{projCardSub(project)}</div>
<div className="vc-proj-foot">
<span className={`vc-tag ${bucket}`}>{bucket === "fail" ? "失败" : bucket === "done" ? "已完成" : bucket === "draft" ? "草稿" : "生成中"}</span>
<button
className="vc-proj-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projCardCta(project)}
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="proj-grid">{pageItems.map((project) => {
const cover = project.cover_preview_url || "";
const no = projStageNo(project);
const isSel = selected.has(project.id);
return (
<article className={`proj-card${editMode && isSel ? " selected" : ""}`} key={project.id} onClick={() => (editMode ? toggleSelect(project.id) : openPipeline(project.id))}>
<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 card-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="card-body">
<div>
<div className="card-name">{project.name}</div>
<div className="card-sub">{productTitle(project.product)} · {projShots(project)} </div>
<div className="proj-card-list" id="list-view">
{pageItems.map((project) => {
const no = projStageNo(project);
const cover = project.cover_preview_url || "";
const isSel = selected.has(project.id);
const openProject = () => {
if (editMode) toggleSelect(project.id);
else if (project.final_video_url) setPlaying(project);
else openPipeline(project.id);
};
return (
<article
key={project.id}
className={`proj-card-row${editMode && isSel ? " selected" : ""}`}
data-status={projBucket(project)}
data-name={project.name}
onClick={openProject}
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openProject(); } }}
role="button"
tabIndex={0}
>
{editMode && (
<span className={`row-check${isSel ? " on" : ""}`} aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
)}
<div className={`placeholder proj-card-row-thumb${cover ? " has-mock-media" : ""}`} style={cover ? coverStyle(cover) : undefined}><span className="ph-frame">9:16</span></div>
<div className="proj-card-row-main">
<div className="proj-card-row-title">{project.name}</div>
<div className="proj-card-row-sub">{projCardMeta(project, productTitle(project.product))}</div>
</div>
<div className="hstack">
<div className="prog">{Array.from({ length: PROJ_STAGE_TOTAL }, (_, k) => k + 1).map((i) => <span key={i} className={projProgClass(project, i, no)} />)}</div>
<span className="muted-2 mono" style={{ fontSize: "10.5px" }}>{no}/{PROJ_STAGE_TOTAL}</span>
<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>
<div className="card-foot"><span className={`pill ${projPillClass(project)}`}><span className="dot" />{projStatusLabel(project)}</span><span className="card-time">{projDate(project)}</span></div>
</div>
</article>
);
})}</div>
{!editMode && (
<button
className="proj-card-row-cta"
type="button"
onClick={(event) => { event.stopPropagation(); openProject(); }}
>
{projRowCta(project)}
</button>
)}
{!editMode && (
<span className="row-more proj-card-row-more" onClick={(event) => event.stopPropagation()}>
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
<div className="row-more-tip"><button className="mi" type="button" onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button></div>
</span>
)}
</article>
);
})}
</div>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
{filtered.length === 0 && !loading && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
</section>
{/* 批量管理栏 · 吸底浮动(显隐绑「有无选中」)· 蓝本同 products-page */}
<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>
+3 -3
View File
@@ -375,13 +375,13 @@ export function SettingsPage({
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
<div className="sub"></div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={discardChanges} disabled={!isDirty || saving}></button>
<button className="btn btn-primary" type="button" onClick={handleSaveAll} disabled={!isDirty || saving}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
{isDirty ? <span className="save-count">· {dirtyCount} </span> : null}
</button>
</div>
@@ -459,7 +459,7 @@ export function SettingsPage({
<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>
<span className="switch-note">// 在「保存所有更」中一并提交</span>
</div>
</div>
<div className="form-row">
+38 -21
View File
@@ -1,45 +1,62 @@
/* 设置页 · 从 public/exact/settings.html 内联 <style> 忠实移植,整段 scope 进 .settings-page 防外泄 · 只用 token */
.settings-page {
/* ─── 设置布局:左 nav + 右 panel ─── */
.settings-grid { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; align-items: start; }
/* ─── 设置布局:左 nav + 右 panel · 单张白卡(对齐 YZ 设置页) ─── */
.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;
}
.settings-nav { position: sticky; top: 16px; }
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
.settings-nav :where(a, button) { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; font: inherit; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); 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); }
.settings-nav {
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(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.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(--heat); background: var(--accent-white); border-color: var(--heat-20); }
.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 { right: -4px; }
/* 改过的区:nav 文字也带橙提示(active 时已是橙,此处管未选中区) */
.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 {
width: calc(100% - 24px);
height: 38px;
margin: 4px 12px 0;
width: 100%;
height: 40px;
margin-top: auto;
justify-content: center;
border-radius: var(--r-pill);
background: var(--accent-black);
border-color: var(--accent-black);
color: var(--accent-white);
border-radius: 10px;
background: var(--crimson-bg);
border-color: transparent;
color: var(--accent-crimson);
font-weight: 500;
}
.settings-nav .logout-pill:hover,
.settings-nav .logout-pill:focus-visible {
background: var(--black-alpha-88);
border-color: var(--black-alpha-88);
color: var(--accent-white);
background: rgba(235, 52, 36, .14);
border-color: transparent;
color: var(--accent-crimson);
}
/* ─── pane ─── */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
.pane { background: transparent; border: 0; border-radius: 0; padding: 28px 32px; margin-bottom: 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; }
+23 -24
View File
@@ -47,19 +47,19 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #FAF9F5;
--bg-soft: #F4F2EC;
--bg: #F7F8FA;
--bg-soft: #F1F3F7;
--card: #FFFFFF;
--border: #E9E5DB;
--border-soft: #EFEBE0;
--ink: #15140F;
--ink-2: #5A584F;
--ink-3: #9C988C;
--ink-4: #C8C4B8;
--orange: #E55B26;
--orange-soft: #FAE8DC;
--orange-tint: #FFF4ED;
--orange-hover: #D04E1F;
--border: #ECEEF2;
--border-soft: #F0F2F6;
--ink: #17191D;
--ink-2: #5A5F6A;
--ink-3: #8B909A;
--ink-4: #C5C9D2;
--orange: #A5B4FC;
--orange-soft: #E4E9FE;
--orange-tint: #F0F3FF;
--orange-hover: #8B9CF7;
--green: #3F6B3F;
--green-bg: #EAF2EA;
--green-bd: #D5E5D5;
@@ -181,10 +181,9 @@ main { position: relative; overflow: hidden; background: var(--bg); }
.grid-bg {
position: absolute; inset: 0; pointer-events: none;
background-image:
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><g stroke='%23B8B3A4' stroke-width='1' fill='none'><path d='M-5 0 L5 0 M0 -5 L0 5'/><path d='M235 0 L245 0 M240 -5 L240 5'/><path d='M-5 240 L5 240 M0 235 L0 245'/><path d='M235 240 L245 240 M240 235 L240 245'/></g></svg>"),
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: 240px 240px, 60px 60px, 240px 240px;
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%);
}
@@ -363,7 +362,7 @@ main { position: relative; overflow: hidden; background: var(--bg); }
font-family: inherit;
color: var(--ink);
}
.input:focus, .textarea:focus, .select:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(229, 91, 38, 0.10); }
.input:focus, .textarea:focus, .select:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(165, 180, 252, 0.18); }
.input::placeholder, .textarea::placeholder { color: var(--ink-3); }
.textarea { height: auto; min-height: 80px; padding: 10px 12px; line-height: 1.55; resize: vertical; }
.select { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none'><path d='M4 6l4 4 4-4' stroke='%239C988C' stroke-width='1.4'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
@@ -664,22 +663,22 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
/* ─── Exact design parity: 电商AI平台/login.html ─── */
.auth-exact-page {
--background-base: #f9f9f9;
--background-lighter: #fbfbfb;
--background-base: #f7f8fa;
--background-lighter: #fbfbfc;
--surface: #ffffff;
--surface-raised: #ffffff;
--border-faint: #ededed;
--border-muted: #e8e8e8;
--border-loud: #e6e6e6;
--accent-black: #262626;
--border-faint: #eceef2;
--border-muted: #e6e8ee;
--border-loud: #e2e5eb;
--accent-black: #17191d;
--accent-white: #ffffff;
--accent-amethyst: #9061ff;
--accent-bluetron: #2a6dfb;
--accent-crimson: #eb3424;
--accent-forest: #42c366;
--accent-honey: #ecb730;
--heat: #fa5d19;
--heat-12: rgba(250, 93, 25, .12);
--heat: #A5B4FC;
--heat-12: rgba(165, 180, 252, .12);
--black-alpha-32: rgba(38, 38, 38, .32);
--black-alpha-48: rgba(38, 38, 38, .48);
--black-alpha-56: rgba(38, 38, 38, .56);
@@ -984,7 +983,7 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
justify-content: center;
gap: 8px;
}
.auth-exact-page .btn-cta:hover { box-shadow: 0 4px 14px rgba(250, 93, 25, .28); }
.auth-exact-page .btn-cta:hover { box-shadow: 0 4px 14px rgba(165, 180, 252, .28); }
.auth-exact-page .btn-cta:active { transform: translateY(1px); }
.auth-exact-page .btn-cta:disabled { opacity: .82; cursor: wait; }
.auth-exact-page .btn-cta .busy-copy {
+23
View File
@@ -0,0 +1,23 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = process.env.BASE || "http://127.0.0.1:5173";
const API = process.env.API || "http://127.0.0.1:8010";
const OUT = process.argv[2] || "shots-proj-yz";
mkdirSync(OUT, { recursive: true });
const login = await (await fetch(`${API}/api/auth/login/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "airshelf", password: "Restraint2026" })
})).json();
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), login.token);
const page = await ctx.newPage();
await page.goto(`${BASE}/projects`, { waitUntil: "networkidle", timeout: 40000 });
await page.waitForTimeout(2000);
await page.screenshot({ path: `${OUT}/projects-yz-preview.png`, fullPage: false });
console.log("shot projects-yz-preview");
await browser.close();
+30
View File
@@ -0,0 +1,30 @@
import { chromium } from 'playwright';
const BASE = process.env.BASE || 'http://127.0.0.1:5173';
const API = process.env.API || 'http://127.0.0.1:8010';
const OUT = process.argv[2] || 'shots-sidebar';
const login = await (await fetch(`${API}/api/auth/login/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'airshelf', password: 'Restraint2026' })
})).json();
const tok = login.token;
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 440, height: 800 }, deviceScaleFactor: 2 });
await ctx.addInitScript((t) => localStorage.setItem('airshelf_token', t), tok);
const page = await ctx.newPage();
async function shot(url, name) {
await page.goto(`${BASE}${url}`, { waitUntil: 'networkidle', timeout: 40000 });
await page.waitForTimeout(800);
await page.screenshot({ path: `${OUT}-${name}.png`, clip: { x: 0, y: 0, width: 220, height: 780 } });
console.log('shot', name);
}
await shot('/projects', 'projects');
await shot('/team', 'team');
await shot('/settings', 'settings');
await browser.close();