后端生成闸+多项修复;前端全站更新;QA 审计与报告

后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
+48 -30
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, getToken, setToken } from "./api";
import { api, ApiError, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
@@ -177,12 +177,24 @@ export function App() {
let cancelled = false;
(async () => {
try {
const identity = await api.me();
if (cancelled) return;
// 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
identity = await api.me();
break;
} catch (error) {
const status = error instanceof ApiError ? error.status : 0;
if (status === 401 || status === 403 || attempt === 2) throw error;
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
if (cancelled || !identity) return;
setUser(identity.user);
setTeam(identity.team);
await loadData();
} catch {
} catch (bootError) {
console.error("[boot] failed:", bootError);
setToken(null);
if (!cancelled) setAuthed(false);
} finally {
@@ -233,18 +245,23 @@ export function App() {
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
// 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行
// ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。
const projectDetailRef = useRef<Project | null>(null);
useEffect(() => {
projectDetailRef.current = projectDetail;
}, [projectDetail]);
const pollVideosQuiet = useCallback(async () => {
if (!activeProjectId) return;
const detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) {
let detail = projectDetailRef.current;
if (!detail || detail.id !== activeProjectId) {
detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
setProjectDetail(detail);
return;
}
for (const segment of active) {
await api.pollVideo(activeProjectId, segment.id).catch(() => undefined);
}
const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) return;
await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined)));
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
@@ -457,6 +474,7 @@ export function App() {
return (
<ProjectWizardPage
products={products}
projects={projects}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
const created = await action(() => api.createProject(payload), "项目已创建");
@@ -532,9 +550,9 @@ export function App() {
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} />;
return <SettingsPage user={currentUser} team={currentTeam} 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} />;
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 })} />;
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} assets={assets} billing={billing} navigate={navigate} />;
}
@@ -546,9 +564,11 @@ export function App() {
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
const pipelineProject = projectDetail || activeProject;
if (page === "pipeline" && pipelineProject) {
const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
return (
<PipelinePage
project={pipelineProject}
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
loading={loading}
navigate={navigate}
user={currentUser}
@@ -561,9 +581,13 @@ export function App() {
unreadCount={unreadCount}
avatarChar={avatarChar}
logout={logout}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
onGenerateScript={(prompt, source) => action(() => api.generateScript(pipelineProject.id, { prompt, source }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")}
onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")}
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
@@ -571,16 +595,17 @@ export function App() {
await api.generateStoryboard(pipelineProject.id, { prompt });
for (let i = 0; i < 60; i += 1) {
const res = await api.pollStoryboard(pipelineProject.id);
if (res.status === "succeeded") break;
if (res.status === "failed") throw new Error("故事板生成失败,请重试");
if (res.status === "succeeded") return true;
if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
return true;
// 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast
throw new Error("故事板生成超时,请稍后刷新查看或重试");
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onPollVideo={(segmentId) => action(() => api.pollVideo(pipelineProject.id, segmentId), "片段状态已刷新")}
onSubmitAllVideos={(prompt) =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
@@ -594,17 +619,9 @@ export function App() {
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
onPollAllVideos={() =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.pollVideo(pipelineProject.id, segment.id).catch(() => undefined);
}
return targets.length;
}, "视频片段状态已刷新")
}
exportResult={exportResult}
onRefreshExport={refreshExport}
onRefreshProject={refreshProjectDetail}
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
@@ -621,7 +638,8 @@ export function App() {
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
return null;
// 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填)
throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看");
}, "成片已导出")
}
/>
+38 -3
View File
@@ -17,7 +17,8 @@ import type {
Team,
TeamMember,
User,
UserPreference
UserPreference,
VoiceoverInfo
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
@@ -53,7 +54,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, text || `${response.status} ${response.statusText}`);
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
let message = text || `${response.status} ${response.statusText}`;
try {
const data = JSON.parse(text) as Record<string, unknown>;
const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0];
if (typeof first === "string") message = first;
else if (Array.isArray(first) && typeof first[0] === "string") message = first[0];
} catch {
/* 非 JSON(如网关 HTML)保持原文 */
}
throw new ApiError(response.status, message);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
@@ -168,7 +179,7 @@ export const api = {
deleteProject(id: string) {
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
},
generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) {
generateScript(projectId: string, payload: { prompt: string; source?: string; selling_point_ids?: string[] }) {
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
method: "POST",
body: JSON.stringify(payload)
@@ -180,6 +191,21 @@ export const api = {
body: JSON.stringify({ script_version_id })
});
},
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
addScriptSegment(projectId: string, payload: { after_segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
deleteScriptSegment(projectId: string, payload: { segment_id: string }) {
return request<ScriptVersion>(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) {
return request<Project>(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) });
},
generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) {
return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) });
},
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) {
return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
@@ -231,6 +257,15 @@ export const api = {
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
async fetchAssetBlob(id: string): Promise<Blob> {
const token = getToken();
const response = await fetch(`${API_BASE}/api/assets/${id}/raw/`, {
headers: token ? { Authorization: `Token ${token}` } : undefined
});
if (!response.ok) throw new ApiError(response.status, `fetch asset raw failed: ${response.status}`);
return response.blob();
},
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
async allAssets(): Promise<Asset[]> {
+19 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock } from "./overlays";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -28,6 +29,7 @@ const SHELL_COMMANDS: Command[] = [
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
const [query, setQuery] = useState("");
useBodyScrollLock(open);
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -149,6 +151,16 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
return () => document.body.classList.remove("sidebar-collapsed");
}, [collapsed]);
// 移动端导航抽屉:窄屏侧栏默认收起(display:none),靠左上汉堡键拉出为浮层 + 背景遮罩。
// 点导航项 / 遮罩 / Esc 关闭。桌面端(>1100px)汉堡键 CSS 隐藏,不影响原有收窄逻辑。
const [mobileNavOpen, setMobileNavOpen] = useState(false);
useEffect(() => {
if (!mobileNavOpen) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setMobileNavOpen(false); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mobileNavOpen]);
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
const [paletteOpen, setPaletteOpen] = useState(false);
const openCommandPalette = () => setPaletteOpen(true);
@@ -165,7 +177,12 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
return (
<>
<aside className="sidebar">
{/* 移动端汉堡键(桌面 CSS 隐藏)+ 抽屉遮罩 */}
<button className="mobile-nav-btn" type="button" aria-label="打开导航" aria-expanded={mobileNavOpen} onClick={() => setMobileNavOpen(true)}>
<span /><span /><span />
</button>
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
<div className="sidebar-head">
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
@@ -196,7 +213,7 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
className={activeNav === item.page ? "active" : ""}
title={item.label}
aria-label={item.label}
onClick={(event) => { event.preventDefault(); navigate(item.page); }}
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate(item.page); }}
>
<IconKitSvg name={item.icon} />
<span>{item.label}</span>
+27 -3
View File
@@ -1,16 +1,32 @@
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Shield, X } from "lucide-react";
import { Music, Shield, X } from "lucide-react";
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放(复用 .np-lightbox 样式)
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
// 多浮层叠开用计数,最后一个关闭才解锁。
let scrollLockCount = 0;
export function useBodyScrollLock(active: boolean) {
useEffect(() => {
if (!active) return;
scrollLockCount += 1;
document.body.style.overflow = "hidden";
return () => {
scrollLockCount -= 1;
if (scrollLockCount <= 0) { scrollLockCount = 0; document.body.style.overflow = ""; }
};
}, [active]);
}
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
open: boolean;
src: string;
kind?: "image" | "video";
kind?: "image" | "video" | "audio";
name?: string;
close: () => void;
}) {
useBodyScrollLock(open && !!src);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); };
@@ -31,6 +47,11 @@ export function MediaLightbox({ open, src, kind, name, close }: {
onClick={(event) => event.stopPropagation()}
style={{ maxWidth: "90vw", maxHeight: "88vh", borderRadius: "var(--r-md)", boxShadow: "0 20px 60px rgba(0,0,0,.5)", background: "#000", cursor: "default" }}
/>
) : kind === "audio" ? (
<div className="lb-audio" onClick={(event) => event.stopPropagation()}>
<Music aria-hidden="true" />
<audio src={src} controls autoPlay />
</div>
) : (
<img src={src} alt={name || "预览"} onClick={(event) => event.stopPropagation()} style={{ cursor: "default" }} />
)}
@@ -58,6 +79,7 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
children: ReactNode;
footer?: ReactNode;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
@@ -81,6 +103,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
onCancel: () => void;
onConfirm: () => void | Promise<unknown>;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
@@ -97,6 +120,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
+42
View File
@@ -0,0 +1,42 @@
// 通用列表分页器:沿用项目向导 pp-pager / 消费页 bill-pager 的视觉(mono 总数 + 页码窗口 + 每页条数)。
// 纯前端切片分页:父组件持有 page state,自己 slice;本组件只画控件。
// total <= pageSize 时不渲染(单页无需分页器)。
// 页码窗口:超过 7 页折叠成 1 … cur-1 cur cur+1 … last
export function pageWindow(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const items: Array<number | "ellipsis"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("ellipsis");
for (let p = start; p <= end; p++) items.push(p);
if (end < total - 1) items.push("ellipsis");
items.push(total);
return items;
}
export function Pager({ page, total, pageSize, onChange }: {
page: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const cur = Math.min(Math.max(1, page), totalPages);
if (total <= pageSize) return null;
return (
<div className="list-pager">
<span className="total">// 共 {total} 条 · 第 {cur} / {totalPages} 页</span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => onChange(cur - 1)} aria-label="上一页"></button>
{pageWindow(cur, totalPages).map((p, i) => (
p === "ellipsis"
? <span key={`e${i}`} className="ellipsis"></span>
: <button type="button" key={p} className={p === cur ? "active" : ""} onClick={() => onChange(p)}>{p}</button>
))}
<button type="button" disabled={cur === totalPages} onClick={() => onChange(cur + 1)} aria-label="下一页"></button>
</div>
<span className="page-size"> {pageSize} </span>
</div>
);
}
+64 -1
View File
@@ -1483,6 +1483,38 @@ select.duration-select:focus,
color: rgba(255, 255, 255, .7);
letter-spacing: .04em;
}
/* ─── 通用列表分页器(components/pager.tsx,视觉同向导 pp-pager)─── */
.list-pager {
display: flex; align-items: center; gap: 16px;
margin-top: 18px; padding-top: 14px;
border-top: 1px solid var(--border-faint);
font-size: 12.5px; color: var(--black-alpha-56);
}
.list-pager .total { font-family: var(--font-mono); letter-spacing: .02em; }
.list-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
.list-pager .pages button {
min-width: 28px; height: 28px; padding: 0 8px;
border: 1px solid var(--border-faint); background: var(--surface);
border-radius: var(--r-sm);
cursor: pointer; font-size: 12.5px; color: var(--black-alpha-72); font-family: inherit;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.list-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
.list-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
.list-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
.list-pager .pages .ellipsis { min-width: 22px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--black-alpha-48); }
.list-pager .page-size { font-family: var(--font-mono); letter-spacing: .02em; }
/* 音频试听面板:灯箱内居中,音符 + 原生播放器 */
.np-lightbox .lb-audio {
display: flex; flex-direction: column; align-items: center; gap: 20px;
background: rgba(255, 255, 255, .08);
border-radius: var(--r-md);
padding: 36px 44px;
cursor: default;
}
.np-lightbox .lb-audio svg { width: 34px; height: 34px; color: rgba(255, 255, 255, .8); }
.np-lightbox .lb-audio audio { width: min(420px, 76vw); }
/* Bullet list · 弹窗内 (复用样式) */
.np-body .bullet-list { list-style: none; padding: 0; }
@@ -2121,14 +2153,45 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
}
/* ─── Responsive ─── */
/* 桌面端汉堡键不出现(基础态,放在 @media 前,避免被后置规则覆盖) */
.mobile-nav-btn { display: none; }
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
aside.sidebar { display: none; }
/* 窄屏:侧栏改为左侧抽屉(默认移出视口),汉堡键拉出;桌面收窄键隐藏 */
aside.sidebar {
display: block;
position: fixed; top: 0; left: 0; bottom: 0;
width: 248px;
z-index: 1200;
transform: translateX(-100%);
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
box-shadow: 0 0 40px rgba(0, 0, 0, .18);
}
/* 抽屉里收窄态不适用,强制展开宽度,避免 body.sidebar-collapsed 把抽屉压成 96px */
body.sidebar-collapsed aside.sidebar { width: 248px; }
aside.sidebar.mobile-open { transform: translateX(0); }
.sidebar-toggle { display: none; }
/* 抽屉内汉堡键无意义,藏掉,留出顶部空间给页面自己的汉堡 */
.mobile-nav-btn {
display: flex; flex-direction: column; gap: 4px; justify-content: center; align-items: center;
position: fixed; top: 14px; left: 14px; z-index: 60;
width: 38px; height: 38px;
background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm);
cursor: pointer;
}
.mobile-nav-btn span { display: block; width: 16px; height: 1.6px; background: var(--accent-black); border-radius: 2px; }
.mobile-nav-backdrop { position: fixed; inset: 0; z-index: 1150; background: rgba(21, 20, 15, .42); }
/* 顶栏给汉堡键让出左侧空间 */
.topbar { padding-left: 64px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.stat:nth-child(2) { border-right: 0; }
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
/* 统计卡标签不要被挤到逐字竖排 */
.stat .lbl { white-space: nowrap; }
.content { padding: 28px 24px 48px; }
/* 宽数据表(项目列表/成员表/账单表)在窄屏改横向滚动,否则右侧列被裁切、点不到 */
#list-view, .members-table-wrap, .billing-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.members-table, .billing-table, #list-view table.t { min-width: 640px; }
}
/* ─── Spinner ─── */
+6 -1
View File
@@ -5,7 +5,12 @@
.asset-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; position: relative; }
.asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
/* width:100% 必须显式给:否则 max-height 会经 aspect-ratio 把宽度也钳到 157.5px,卡片右侧露白 */
.asset-card.video .asset-thumb { width: 100%; aspect-ratio: 9/16; max-height: 280px; }
/* 音频资产无封面:默认音符占位(居中、mono 标签),点击灯箱播放 */
.lib-audio-ph { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--black-alpha-48); }
.lib-audio-ph svg { width: 26px; height: 26px; }
.lib-audio-ph .mono { font-size: 10px; letter-spacing: .08em; }
/* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.asset-body { padding: 12px 14px; }
+75 -3
View File
@@ -173,6 +173,60 @@
.shot-card .shot-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.shot-card .shot-meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.shot-card .shot-narration { font-size: 13px; color: var(--accent-black); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
/* 画面描述行(narration/visual_prompt 结构化分离后,旁白下方的次要画面说明) */
.shot-card .shot-visual { font-size: 12px; color: var(--black-alpha-56); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
/* 镜头卡编辑态(对齐设计稿:旁白/画面逐字段 contenteditable + 卡间插入分镜) */
.shot-card .shot-actions { display: inline-flex; gap: 6px; margin-left: auto; }
.icon-mini-btn { width: 24px; height: 24px; display: grid; place-items: center; color: var(--black-alpha-48); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); cursor: pointer; font-size: 14px; line-height: 1; padding: 0; }
.icon-mini-btn:hover { color: var(--heat); border-color: var(--heat); }
.icon-mini-btn.armed { width: auto; padding: 0 8px; font-size: 11px; color: var(--accent-crimson); border-color: var(--accent-crimson); background: var(--surface); }
.shot-card .shot-meta-row { display: flex; align-items: center; gap: 8px; }
.shot-row { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 4px 0; }
.shot-k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding-top: 2px; letter-spacing: .04em; }
.shot-v { font-size: 12.5px; color: var(--accent-black); line-height: 1.55; outline: none; border-radius: var(--r-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-base); white-space: pre-wrap; word-break: break-word; }
.shot-v[contenteditable="true"]:hover { background: var(--heat-12); cursor: text; }
.shot-v[contenteditable="true"]:focus { background: var(--surface); box-shadow: inset 0 0 0 1px var(--heat); }
.shot-v[data-empty="true"]::before { content: attr(data-placeholder); color: var(--black-alpha-32); font-style: italic; }
.shot-insert-gap { height: 10px; position: relative; display: flex; align-items: center; justify-content: center; padding: 0; transition: height .24s cubic-bezier(.18,.72,.28,1), padding .24s cubic-bezier(.18,.72,.28,1); }
.shot-insert-gap:hover { height: 72px; padding: 14px 0; }
.shot-insert-gap .add-shot-btn { opacity: 0; transform: translateY(4px) scale(.96); height: 28px; padding: 0 14px; background: var(--surface); color: var(--heat); border: 1px dashed var(--heat-40); border-radius: var(--r-md); font-size: 12.5px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base); display: inline-flex; align-items: center; gap: 6px; pointer-events: none; white-space: nowrap; }
.shot-insert-gap .add-shot-btn svg { width: 12px; height: 12px; }
.shot-insert-gap:hover .add-shot-btn { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.shot-insert-gap .add-shot-btn:hover { background: var(--heat-12); border-style: solid; border-color: var(--heat); }
/* ── Stage 4 · 视频详情弹窗(对齐设计稿 video-detail-modal) ── */
.asset-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1010; display: flex; align-items: center; justify-content: center; padding: 40px; }
.asset-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(880px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.asset-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }
.asset-modal-h h2 { font-size: 15px; font-weight: 600; }
.asset-modal-h .x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; }
.asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; }
.asset-modal-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 8px; }
.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }
.vd-main { flex: 0 0 280px; aspect-ratio: 9/16; max-height: 460px; background: #000; border-radius: var(--r-md); overflow: hidden; position: relative; }
.vd-main video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.vd-info { flex: 1; min-width: 0; }
.vd-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
.vd-kv { display: grid; grid-template-columns: 72px 1fr; gap: 6px 10px; font-size: 12.5px; }
.vd-kv .k { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .03em; padding-top: 1px; }
.vd-kv .v { color: var(--accent-black); min-width: 0; overflow-wrap: anywhere; }
.vd-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
.vd-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.vd-history-thumb { flex: 0 0 64px; min-width: 64px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); position: relative; }
.vd-history-thumb:hover { border-color: var(--heat); }
.vd-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
.vd-history-thumb.adopted::after { content: ''; position: absolute; top: 2px; right: 2px; width: 14px; height: 14px; background: var(--heat); border-radius: 50%; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 9px 9px; }
.vd-history-thumb .placeholder { aspect-ratio: 9/16; }
.vd-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }
.vd-prompt-field { margin-top: 16px; }
.vd-prompt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.vd-prompt-head .label { font-family: var(--font-mono); font-size: 11px; font-weight: 500; color: var(--black-alpha-56); letter-spacing: .04em; }
.vd-prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 96px; outline: none; letter-spacing: .01em; cursor: text; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
.vd-prompt-edit:hover { border-color: var(--heat-20); }
.vd-prompt-edit:focus { border-color: var(--heat); background: var(--surface); box-shadow: 0 0 0 3px var(--heat-12); }
.vd-modal-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
/* 对话空态三胶囊 */
.chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
@@ -240,6 +294,17 @@
.asset-card-2.prod-lib-card .prod-action .btn-aigen:hover { background: #FB6E2E; box-shadow: inset 0 -2px 4px rgba(250,93,25,.24), 0 2px 4px rgba(250,93,25,.20), 0 4px 12px rgba(250,93,25,.18); transform: translateY(-1px); }
.asset-card-2.prod-lib-card .prod-action .btn-aigen .ai-spark { width: 14px; height: 14px; flex-shrink: 0; }
/* 区头版生成按钮(人物/场景 sec-h 复用 btn-aigen,商品卡版被 .prod-action 作用域锁死,这里给区头补壳) */
.asset-sec .sec-h .btn-aigen {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px; background: var(--heat); color: var(--accent-white);
border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12.5px; font-weight: 500; cursor: pointer; font-family: inherit;
box-shadow: inset 0 -2px 4px rgba(250,93,25,.20), 0 1px 1px rgba(250,93,25,.12);
transition: background var(--t-base), box-shadow var(--t-base), transform var(--t-base);
}
.asset-sec .sec-h .btn-aigen:hover { background: #FB6E2E; transform: translateY(-1px); }
.asset-sec .sec-h .btn-aigen .ai-spark { width: 14px; height: 14px; flex-shrink: 0; }
/* ── 生成中统一置灰:凡 set disabled 的按钮都灰显 + 禁手势 + 去 hover/动效(设计师要求:已点击/生成中要置灰不可点)── */
.btn-aigen:disabled,
.asset-card-2.prod-lib-card .prod-action .btn-aigen:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; transform: none; }
@@ -264,6 +329,8 @@
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard { gap: 0; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas { border: 0; border-radius: 0; background: var(--surface); padding: 18px 14px 18px 28px; align-items: center; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img { width: 100%; }
/* 有真图时大图占满画布整个高度(不再 16/9 居中留上下空白) */
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img.has-mock-media { align-self: stretch; aspect-ratio: auto; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side { display: flex; flex-direction: column; min-height: 0; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side > .pane { flex: 1 1 0; min-height: 0; overflow-y: auto; border: 0; border-radius: 0; background: var(--surface); padding: 18px 28px; }
.content--fh-flat .stage[data-stage-pane="3"].active .sb-scenes-col { max-height: none; }
@@ -280,6 +347,8 @@
.sb-scene-thumb .nm { font-size: 11.5px; font-weight: 500; color: var(--accent-black); }
.sb-scene-thumb .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }
.sb-main-img { aspect-ratio: 16/9; min-height: 0; }
/* 整张故事板是竖长拼图,cover 会裁掉上下 —— 用 contain 完整显示 */
.sb-main-img.has-mock-media { background-size: contain; }
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
@@ -344,9 +413,9 @@
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.editor-preview { padding: 16px; border-right: 1px solid var(--border-faint); border-bottom: 1px solid var(--border-faint); display: flex; flex-direction: column; gap: 12px; }
.editor-preview .canvas { position: relative; overflow: hidden; flex: 1 1 0; min-height: 0; aspect-ratio: 9/16; margin: 0 auto; background: repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px), var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; }
/* 转场实时预览:切片段时画面淡场(导出才是真 xfade) */
.editor-preview .canvas .ed-xfade-flash { position: absolute; inset: 0; z-index: 3; background: #000; pointer-events: none; animation: edXfadeFlash 0.45s ease forwards; }
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
/* 转场实时预览:切片段时画面淡场(导出才是真 xfade)
opacity:0 兜底——@keyframes 必须在顶层(嵌套块内不生效),若动画没跑也不能变成永久黑盖板 */
.editor-preview .canvas .ed-xfade-flash { position: absolute; inset: 0; z-index: 3; background: #000; opacity: 0; pointer-events: none; animation: edXfadeFlash 0.45s ease forwards; }
.editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }
.ctl-btn { width: 36px; height: 36px; border: 1px solid var(--border-faint); background: var(--surface); color: var(--black-alpha-56); border-radius: var(--r-md); display: grid; place-items: center; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.ctl-btn:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }
@@ -420,3 +489,6 @@
.playhead::before { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%) rotate(45deg); width: 10px; height: 10px; background: var(--heat); box-shadow: 0 0 0 1.5px var(--surface); border-radius: 1px; pointer-events: none; }
.playhead .ph-grab { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 24px; height: 24px; cursor: ew-resize; pointer-events: auto; border-radius: 50%; }
}
/* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
+7 -12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
import { pageWindow } from "../components/pager";
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
@@ -30,18 +31,6 @@ const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
// 分页页码窗口:页数多时折叠成 1 … 当前±1 … 末页(≤7 页则全展开)
function pageWindow(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const items: Array<number | "ellipsis"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("ellipsis");
for (let p = start; p <= end; p++) items.push(p);
if (end < total - 1) items.push("ellipsis");
items.push(total);
return items;
}
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
@@ -282,6 +271,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
</div>
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th></th><th> / </th><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
@@ -299,6 +289,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
))}
</tbody>
</table>
</div>
{ledgerCount > BILLS_PER_PAGE && (
<div className="bill-pager">
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
@@ -316,6 +307,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
</div>
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
@@ -327,9 +319,11 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
})}
</tbody>
</table>
</div>
</div>
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th></th><th></th><th> / </th><th></th></tr></thead>
<tbody>
@@ -341,6 +335,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
})}
</tbody>
</table>
</div>
</div>
</section>
);
+10 -14
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
@@ -133,9 +134,9 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置
const TASKS_PER_LOAD = 12;
const [shown, setShown] = useState(TASKS_PER_LOAD);
// 任务中心分页:每 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
const [taskPage, setTaskPage] = useState(1);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
@@ -163,10 +164,11 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
}
return true;
});
// 筛选条件变化时回到第一屏(12 条)
useEffect(() => { setShown(TASKS_PER_LOAD); }, [filter, query, timeFilter, typeFilter]);
const paged = visible.slice(0, shown);
const hasMore = visible.length > paged.length;
// 筛选条件变化时回到第 1 页
useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]);
const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE));
const taskCurPage = Math.min(taskPage, taskTotalPages);
const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE);
return (
<div className="asset-factory">
@@ -366,13 +368,7 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
</div>
)}
{hasMore && (
<div className="task-load-more">
<button className="btn" type="button" onClick={() => setShown((n) => n + TASKS_PER_LOAD)}>
<span className="lm-rest"> {visible.length - paged.length} </span>
</button>
</div>
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</div>
);
}
+13 -4
View File
@@ -30,8 +30,9 @@ export function AuthScreen({
onAuthed: (payload: { token: string; user: User; team: Team }) => void;
}) {
const [mode, setMode] = useState<AuthMode>(initialMode);
const [username, setUsername] = useState("li@shop.com");
const [password, setPassword] = useState("demo-1234");
// 不预填设计稿假账号:真实登录页字段必须从空开始
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [teamName, setTeamName] = useState("");
const [email, setEmail] = useState("");
const [registerPassword, setRegisterPassword] = useState("");
@@ -66,6 +67,9 @@ export function AuthScreen({
setBusy(true);
setError("");
try {
if (mode === "login") {
if (!username.trim() || !password) throw new Error("请输入邮箱和密码");
}
if (mode === "register") {
if (registerPassword.length < 8) throw new Error("密码至少 8 位");
if (registerPassword !== registerPassword2) throw new Error("两次密码不一致");
@@ -82,7 +86,12 @@ export function AuthScreen({
});
onAuthed(payload);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
const raw = err instanceof Error ? err.message : "";
// 后端错误是英文原文(invalid credentials 等),映射成用户可读的中文
const friendly = /invalid credentials|unable to log in|non_field_errors/i.test(raw)
? "邮箱或密码不正确"
: raw || (mode === "login" ? "登录失败,请稍后重试" : "注册失败,请稍后重试");
setError(friendly);
} finally {
setBusy(false);
}
@@ -141,7 +150,7 @@ export function AuthScreen({
</div>
<div className="row-between">
<label><input type="checkbox" defaultChecked /> 7 </label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>?</a>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", `请到 ${username.trim() || "登录邮箱"} 收件箱查看 · 链接 30 分钟有效`); }}>?</a>
</div>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
+69 -21
View File
@@ -2,10 +2,25 @@ import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Asset } from "../types";
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
const LIB_PAGE_SIZE = 10;
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
const CATEGORY_LABELS: Record<string, string> = { person: "人物", scene: "场景", product_image: "商品图", video_clip: "视频片段", final_video: "成片", upload: "上传", uncategorized: "未分类" };
// 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image)
function inferAssetType(file: File): string {
const mime = file.type || "";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
const ext = (file.name.split(".").pop() || "").toLowerCase();
if (["srt", "vtt", "ass"].includes(ext)) return "subtitle";
return "document";
}
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
@@ -44,6 +59,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const [drawer, setDrawer] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [uploading, setUploading] = useState(false);
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
@@ -52,13 +68,17 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const [editMode, setEditMode] = useState(false);
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(null);
// 资产预览灯箱(图片放大 / 视频播放)
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
// 资产预览灯箱(图片放大 / 视频播放 / 音频试听)
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
// 分页:每页 10 个,切 tab / 改筛选 / 改排序回到第 1 页(数据多时全量渲染卡顿,视频卡每张挂一个 <video>)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, srcFilter, kindFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
useEffect(() => {
@@ -86,17 +106,29 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return sortDesc ? cmp : -cmp;
});
// 当前页切片(删除资产后页数缩水时钳回最后一页)
const totalPages = Math.max(1, Math.ceil(filtered.length / LIB_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * LIB_PAGE_SIZE, curPage * LIB_PAGE_SIZE);
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
if (!file || uploading) return;
const formData = new FormData();
formData.append("file", file);
formData.append("name", name || file.name);
formData.append("asset_type", "image");
formData.append("asset_type", inferAssetType(file));
formData.append("category", "upload");
await onUpload(formData);
setDrawer(false);
// 上传 + 列表刷新要数秒,按钮给「上传中…」反馈,防止用户重复点
setUploading(true);
try {
await onUpload(formData);
setDrawer(false);
setFile(null);
setName("");
} finally {
setUploading(false);
}
}
return (
@@ -209,14 +241,21 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {inTab.length} 个资产</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个资产{filtered.length !== inTab.length ? "(已筛选)" : ""}</div>
{filtered.length ? (
<div className="asset-grid" id="asset-grid">
{filtered.map((asset) => {
{pageItems.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
const isVideo = asset.asset_type === "video";
const openPreview = cover ? () => setPreview({ src: cover, kind: isVideo ? "video" : "image", name: asset.name }) : undefined;
const isAudio = asset.asset_type === "audio";
const previewKind = isVideo ? "video" : isAudio ? "audio" : "image";
const openPreview = cover ? () => setPreview({ src: cover, kind: previewKind, name: asset.name }) : undefined;
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
{editMode && onDelete && (
@@ -224,17 +263,24 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
{cover
? (isVideo
? <>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
: <img src={cover} alt={asset.name} loading="lazy" />)
: <span className="ph-frame">{asset.asset_type}</span>}
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : isAudio ? "点击试听" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo || isAudio ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
{cover && isVideo && (
<>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
)}
{isAudio && (
// 音频没有可视封面,preview_url 是音频文件本身,塞 <img> 必裂图 → 用默认音符占位
<span className="lib-audio-ph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg>
<span className="mono">AUDIO</span>
</span>
)}
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
</div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{catLabel} · {srcLabel}{exportedAt && <span className="asset-meta-time"> · {exportedAt}</span>}</div></div>
</article>
);
})}
@@ -243,6 +289,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<Pager page={curPage} total={filtered.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
<ConfirmModal
@@ -254,7 +302,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) await onDelete?.(id); }}
/>
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label"></label><input className="input file-input" type="file" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label"></label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit" disabled={!file}></button></div></form></Drawer>
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label"></label><input className="input file-input" type="file" accept="image/*,video/*,audio/*,.srt,.vtt,.ass" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label"></label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit" disabled={!file || uploading}>{uploading ? "上传中…" : "上传资产"}</button></div></form></Drawer>
</section>
);
}
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft } from "lucide-react";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { Pager } from "../components/pager";
const PROD_PAGE_SIZE = 10;
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import "../product-create-page.css";
@@ -66,6 +69,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
setSelected(new Set());
};
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
useBodyScrollLock(drawer);
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
@@ -120,6 +124,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
}
return matchQuery && matchCat && matchDate;
});
// 分页:每页 10 个,搜索/筛选变化回第 1 页
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return;
@@ -210,12 +220,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
</div>
<div className="result-meta" id="result-meta">
<span>// 显示 <span className="count">{filtered.length}</span> / {products.length} 个商品</span>
<span>// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</span>
</div>
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{filtered.map((product) => (
{pageItems.map((product) => (
<ProductCard
key={product.id}
product={product}
@@ -227,6 +237,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
/>
))}
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</div>
</div>
+51 -21
View File
@@ -3,8 +3,11 @@ import type { CSSProperties, FormEvent } from "react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { Pager } from "../components/pager";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
const WIZ_DURATIONS = [
@@ -29,14 +32,18 @@ const WIZ_PERSONAS = [
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
export function ProjectWizardPage({ products, onBack, onCreate }: {
export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: {
products: Product[];
projects?: Project[];
onBack: () => void;
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
}) {
const [productId, setProductId] = useState(products[0]?.id || "");
const product = products.find((item) => item.id === productId) || products[0];
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
// 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1);
// 用户手动改过名字后不再覆盖。
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
// Step 1 · 商品选择器本地交互态
const [pickSearch, setPickSearch] = useState("");
@@ -56,6 +63,15 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
if (!productId && products[0]) setProductId(products[0].id);
}, [productId, products]);
useEffect(() => {
if (nameTouched) return;
const styleName = WIZ_STYLES.find((s) => s.id === scriptStyle)?.name || "痛点种草";
const base = `${(product?.title || "商品").split(" ")[0]} · ${styleName}`;
let version = 1;
while (projects.some((p) => p.name === `${base} · v${version}`)) version += 1;
setName(`${base} · v${version}`);
}, [nameTouched, product, scriptStyle, projects]);
// 分类清单
const cats = useMemo(
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
@@ -119,23 +135,30 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
const canStart = product1Done && config2Done;
function submit(event: FormEvent) {
// 提交中状态:创建项目后 App 端全量刷新需数秒,按钮要给「创建中…」反馈,避免用户以为没点上
const [starting, setStarting] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product) return;
if (!canStart || !product || starting) return;
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
void onCreate({
name: name.trim() || `${product.title} · 短视频`,
product: product.id,
metadata: {
wizard: {
duration,
script_style: scriptStyle,
persona,
selling_point_ids: selectedPoints
setStarting(true);
try {
await onCreate({
name: name.trim() || `${product.title} · 短视频`,
product: product.id,
metadata: {
wizard: {
duration,
script_style: scriptStyle,
persona,
selling_point_ids: selectedPoints
}
}
}
});
});
} finally {
setStarting(false);
}
}
const productCover = (p: Product): CSSProperties | undefined => {
@@ -273,7 +296,7 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
<div className="config-row">
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={name} onChange={(event) => setName(event.target.value)} />
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
@@ -346,9 +369,9 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
{/* ── 底部「开始」CTA ── */}
<div className="wiz-start-bar">
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
<button className={`btn-start${canStart && !starting ? "" : " disabled"}`} type="submit" disabled={!canStart || starting}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
<span></span>
<span>{starting ? "创建中…" : "开始"}</span>
</button>
</div>
</div>
@@ -455,6 +478,12 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
}
return true;
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, 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);
async function confirmDelete() {
if (!deleteTarget) return;
@@ -545,7 +574,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {projects.length} 个项目</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</div>
{view === "list" ? (
<div id="list-view">
@@ -562,7 +591,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</tr>
</thead>
<tbody id="list-tbody">
{filtered.map((project) => {
{pageItems.map((project) => {
const no = projStageNo(project);
const shots = project.video_segments.length || 4;
const cover = projCover(project.name);
@@ -603,7 +632,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</table>
</div>
) : (
<div className="proj-grid">{filtered.map((project) => {
<div className="proj-grid">{pageItems.map((project) => {
const cover = projCover(project.name);
return (
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
@@ -617,6 +646,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
);
})}</div>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
</section>
+3 -1
View File
@@ -104,6 +104,7 @@ export function SettingsPage({
onUploadAvatar,
onResetAvatar,
onNotify,
onLogout,
}: {
user: User;
team: Team;
@@ -118,6 +119,7 @@ export function SettingsPage({
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
onResetAvatar?: () => void | Promise<unknown>;
onNotify?: (text: string) => void;
onLogout?: () => void | Promise<void>;
}) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
@@ -676,7 +678,7 @@ export function SettingsPage({
icon={<LogOut size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
<button className="btn btn-primary" type="button" onClick={() => { setModal(""); void onLogout?.(); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><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" /></svg>
退
</button>
+17 -3
View File
@@ -1,9 +1,12 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
import type { BillingSummary, Notification, Team, TeamMember, User } from "../types";
import type { Page } from "./route-config";
import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays";
import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10;
// 角色 → pill key/label(对齐 api-bridge roleUi)
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
@@ -84,6 +87,12 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
const email = member.user.email || "";
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
});
// 成员分页:每页 10 人,搜索变化回第 1 页
const [memberPage, setMemberPage] = useState(1);
useEffect(() => { setMemberPage(1); }, [search]);
const memberTotalPages = Math.max(1, Math.ceil(list.length / MEMBERS_PER_PAGE));
const memberCurPage = Math.min(memberPage, memberTotalPages);
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
function openEdit(member: TeamMember) {
setEditTarget(member);
@@ -93,13 +102,15 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
async function submitCreate() {
if (!cuUser.trim() || cuPass.length < 8) return;
await onCreateMember({
const created = await onCreateMember({
username: cuUser.trim(),
password: cuPass,
name: cuName.trim() || undefined,
role: cuRole,
monthly_credit_limit: Number(cuMonthly) || 0
});
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
if (!created) return;
setModal("");
setCuUser("");
setCuPass("");
@@ -241,6 +252,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
<span className="spacer"></span>
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<div className="members-table-wrap">
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
<thead>
<tr>
@@ -253,7 +265,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
</tr>
</thead>
<tbody id="members-tbody">
{list.map((member) => {
{pagedMembers.map((member) => {
const rawName = (member.user.username || "").trim();
const email = (member.user.email || "").trim();
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
@@ -287,6 +299,8 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
})}
</tbody>
</table>
</div>
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
</div>
</div>
+4 -2
View File
@@ -233,7 +233,8 @@ main { position: relative; overflow: hidden; background: var(--bg); }
.icon-btn .dot-noti { position: absolute; top: 8px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1.5px solid var(--card); }
/* ─── Content ─── */
.content { padding: 36px 48px 60px; position: relative; z-index: 1; max-width: 1480px; }
/* 内容区始终紧贴侧栏铺满整个剩余宽度(与生产管线全屏页一致),不限宽、不居中 —— 否则宽屏会右侧留白或内容居中变窄 */
.content { padding: 36px 48px 60px; position: relative; z-index: 1; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 28px; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 26px; font-weight: 600; letter-spacing: -.018em; line-height: 1.2; }
.page-head .sub { font-size: 13.5px; color: var(--ink-2); margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -1224,7 +1225,8 @@ nav button svg { width: 14px; height: 14px; opacity: .85; }
.asset-card { background: var(--card); border: 1px solid var(--border); cursor: pointer; transition: background .15s; }
.asset-card:hover { background: var(--bg-soft); border-color: var(--ink-3); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
/* width:100% 必须显式给:否则 max-height 会经 aspect-ratio 把宽度也钳到 157.5px,卡片右侧露白 */
.asset-card.video .asset-thumb { width: 100%; aspect-ratio: 9/16; max-height: 280px; }
.asset-body { padding: 10px 12px; }
.asset-name { font-size: 13px; font-weight: 600; }
.asset-meta { font-size: 11px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
+19 -2
View File
@@ -88,6 +88,15 @@ export type ScriptVersion = {
updated_at?: string;
};
export type VideoSegmentVersion = {
id: string;
asset: string | null;
asset_url: string;
prompt: string;
is_adopted: boolean;
created_at: string;
};
export type VideoSegment = {
id: string;
sort_order: number;
@@ -97,6 +106,7 @@ export type VideoSegment = {
adopted_version: string | null;
adopted_asset?: string | null;
adopted_asset_url?: string;
versions?: VideoSegmentVersion[];
};
export type StoryboardVersion = {
@@ -116,6 +126,9 @@ export type ExportPoll = {
error_message?: string;
};
export type VoiceoverItem = { index: number; cue?: number; offset_ms?: number; text: string; asset: string; asset_url: string; duration_ms: number };
export type VoiceoverInfo = { enabled: boolean; voice_type: string; speed_ratio: number; items: VoiceoverItem[] };
export type Timeline = {
id: string;
name: string;
@@ -123,10 +136,11 @@ export type Timeline = {
resolution: string;
duration_seconds: number;
metadata?: { transition?: { type: string }; draft?: Record<string, unknown> };
voiceover?: VoiceoverInfo | null;
clips: Array<{ id: string; asset: string; asset_url?: string; asset_is_video?: boolean; sort_order: number; start_ms: number; duration_ms: number; trim_start_ms?: number; trim_end_ms?: number | null }>;
subtitle_tracks?: Array<{
id: string;
content: Array<{ start_ms: number; text: string }>;
content: Array<{ start_ms: number; end_ms?: number; text: string }>;
style?: Record<string, unknown>;
enabled: boolean;
}>;
@@ -144,9 +158,10 @@ export type Timeline = {
export type TimelineSavePayload = {
clips?: Array<{ asset: string; duration_ms: number; trim_start_ms?: number; trim_end_ms?: number | null }>;
subtitle?: { enabled?: boolean; style_key?: string; content?: Array<{ start_ms: number; text: string }> };
subtitle?: { enabled?: boolean; style_key?: string; content?: Array<{ start_ms: number; end_ms?: number; text: string }> };
bgm?: { volume?: number; clear?: boolean };
transition?: { type: string };
voiceover?: { enabled?: boolean; clear?: boolean; items?: Array<{ asset: string; offset_ms: number }> };
draft?: Record<string, unknown>;
};
@@ -168,9 +183,11 @@ export type Project = {
adopted_asset_url?: string;
candidate_assets: string[];
candidate_asset_urls?: Record<string, string>;
created_at?: string;
}>;
storyboard_versions: StoryboardVersion[];
timeline: Timeline | null;
metadata?: { wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] } } & Record<string, unknown>;
created_at: string;
updated_at: string;
};