feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m51s

Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc 2026-06-09 14:46:42 +08:00
parent 92826dec14
commit 0873e724bf
29 changed files with 2393 additions and 423 deletions

View File

@ -13,7 +13,8 @@
src: local('Alibaba PuHuiTi 3.0'),
local('AlibabaPuHuiTi-3-55-Regular'),
local('Alibaba PuHuiTi 2.0'),
local('AlibabaPuHuiTi-2-55-Regular');
local('AlibabaPuHuiTi-2-55-Regular'),
url('/fonts/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -22,7 +23,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Medium'),
local('AlibabaPuHuiTi-3-65-Medium'),
local('AlibabaPuHuiTi-2-65-Medium');
local('AlibabaPuHuiTi-2-65-Medium'),
url('/fonts/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -30,7 +32,8 @@
font-style: normal;
font-display: swap;
src: local('AlibabaPuHuiTi-3-75-SemiBold'),
local('AlibabaPuHuiTi-2-75-SemiBold');
local('AlibabaPuHuiTi-2-75-SemiBold'),
url('/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -39,7 +42,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Bold'),
local('AlibabaPuHuiTi-3-85-Bold'),
local('AlibabaPuHuiTi-2-85-Bold');
local('AlibabaPuHuiTi-2-85-Bold'),
url('/fonts/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -1,18 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
Asset,
BillingSummary,
BillingTrend,
ExportPoll,
Ledger,
LoginSession,
ModelConfig,
Notification,
Product,
Project,
Team,
TeamMember,
User
User,
UserPreference
} from "./types";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
@ -41,7 +45,7 @@ const crumbLabels: Partial<Record<Page, string>> = {
dashboard: "工作台",
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "新建商品",
productCreateUpload: "商品",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
@ -76,9 +80,13 @@ export function App() {
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
const [preferences, setPreferences] = useState<UserPreference | null>(null);
const [sessions, setSessions] = useState<LoginSession[]>([]);
const [activeProductId, setActiveProductId] = useState(route.productId || "");
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
@ -95,13 +103,14 @@ export function App() {
);
const loadData = useCallback(async () => {
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData, notificationData] =
const [productData, projectData, assetData, billingData, ledgerData, trendData, memberData, modelData, taskData, notificationData] =
await Promise.all([
api.products(),
api.projects(),
api.assets(),
api.billingSummary().catch(() => null),
api.ledgers().catch(() => []),
api.billingTrend().catch(() => null),
api.teamMembers().catch(() => []),
api.modelConfigs().catch(() => null),
api.aiTasks().catch(() => null),
@ -115,6 +124,7 @@ export function App() {
setAiTasks(taskData?.results || []);
if (billingData) setBilling(billingData);
setLedgers(ledgerData);
setBillingTrend(trendData);
if (notificationData) {
setNotifications(notificationData.results);
setUnreadCount(notificationData.unread_count);
@ -123,6 +133,33 @@ export function App() {
setActiveProductId((current) => current || productData.results[0]?.id || "");
}, []);
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
const loadSettingsData = useCallback(async () => {
const [pref, sess] = await Promise.all([
api.preferences().catch(() => null),
api.loginSessions().catch(() => [])
]);
if (pref) setPreferences(pref);
setSessions(sess);
}, []);
async function savePreferences(payload: Partial<UserPreference>) {
const next = await api.updatePreferences(payload).catch(() => null);
if (next) setPreferences(next);
return next;
}
async function revokeSession(id: string) {
await action(() => api.revokeSession(id), "设备已下线");
setSessions(await api.loginSessions().catch(() => []));
}
async function revokeOtherSessions() {
const res = await action(() => api.revokeOtherSessions(), "其他设备已全部下线");
if (res?.token) setToken(res.token);
setSessions(await api.loginSessions().catch(() => []));
}
const reloadNotifications = useCallback(async () => {
const data = await api.listNotifications().catch(() => null);
if (data) {
@ -170,6 +207,12 @@ export function App() {
return () => window.removeEventListener("popstate", syncRouteFromHistory);
}, []);
// Load preferences + sessions when entering settings.
useEffect(() => {
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
loadSettingsData();
}, [authed, page, loadSettingsData]);
// Load full project detail when entering the pipeline.
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) {
@ -177,6 +220,7 @@ export function App() {
return;
}
let cancelled = false;
setExportResult(null); // 切项目/进管线时清空上个项目的导出态
api
.project(activeProjectId)
.then((detail) => {
@ -188,6 +232,30 @@ export function App() {
};
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
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) {
setProjectDetail(detail);
return;
}
for (const segment of active) {
await api.pollVideo(activeProjectId, segment.id).catch(() => undefined);
}
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
const refreshExport = useCallback(async () => {
if (!activeProjectId) return;
const res = await api.pollExport(activeProjectId).catch(() => null);
if (res) setExportResult(res);
}, [activeProjectId]);
function navigate(next: Page, options: NavigateOptions = {}) {
const productId = options.productId ?? activeProductId;
const projectId = options.projectId ?? activeProjectId;
@ -209,7 +277,16 @@ export function App() {
if (detail) setProjectDetail(detail);
}
// 防重复提交:已有操作在途时,后续 action 直接忽略(双击/连点/未及时置灰的按钮都安全)。
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
const actionInFlightRef = useRef(false);
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
if (actionInFlightRef.current) {
setNotice({ type: "error", text: "操作进行中,请稍候…" });
return null;
}
actionInFlightRef.current = true;
setLoading(true);
setNotice(null);
try {
@ -223,6 +300,7 @@ export function App() {
return null;
} finally {
setLoading(false);
actionInFlightRef.current = false;
}
}
@ -254,6 +332,11 @@ export function App() {
if (res) setUser(res);
}
async function resetOwnAvatar() {
const res = await action(() => api.resetAvatar(), "已恢复默认头像");
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return action(() => api.generateImage(payload), "图片已生成");
}
@ -322,23 +405,31 @@ export function App() {
return (
<ProductsPage
products={products}
projects={projects}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
/>
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
return (
<ProductCreateUploadPage
<ProductsPage
products={products}
projects={projects}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={async (payload) => {
const created = await action(() => api.createProduct(payload), "商品已创建");
if (created) navigate("productDetail", { productId: created.id });
}}
onBack={() => navigate("products")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
autoOpenCreate
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />;
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
@ -346,6 +437,9 @@ export function App() {
assets={assets}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onUploadImage={(formData) => action(() => api.uploadProductImage(activeProduct.id, formData), "商品图已上传")}
onDeleteImage={(imageId) => action(() => api.deleteProductImage(activeProduct.id, imageId), "商品图已移除")}
onGenerateImages={generateImages}
/>
);
case "projects":
@ -388,12 +482,13 @@ export function App() {
</div>
);
case "library":
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "account":
return (
<AccountPage
billing={billing}
ledgers={ledgers}
trend={billingTrend}
projects={projects}
teamMembers={teamMembers}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
@ -406,6 +501,7 @@ export function App() {
user={currentUser}
members={teamMembers}
billing={billing}
notifications={notifications}
navigate={navigate}
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
@ -433,13 +529,13 @@ export function App() {
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
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 })} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
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 })} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
@ -470,9 +566,21 @@ export function App() {
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
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("故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
return true;
}, "故事板已生成")
}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
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 () => {
@ -484,8 +592,9 @@ export function App() {
});
}
return targets.length;
}, "60s 多段视频任务已提交")
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
onPollAllVideos={() =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
@ -495,7 +604,27 @@ export function App() {
return targets.length;
}, "视频片段状态已刷新")
}
onSubmitExport={() => action(() => api.submitExport(pipelineProject.id), "导出任务已提交")}
exportResult={exportResult}
onRefreshExport={refreshExport}
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), "草稿已保存")}
onSubmitExport={(payload) =>
action(async () => {
// 导出前先落盘当前编辑态(片段/字幕/转场/BGM),成片即所见
if (payload) await api.saveTimeline(pipelineProject.id, payload);
await api.submitExport(pipelineProject.id);
// 后端在后台线程跑 ffmpeg 拼接,这里轮询 poll-export 直到成片/失败,实时回填进度
for (let i = 0; i < 160; i += 1) {
const res = await api.pollExport(pipelineProject.id);
setExportResult(res);
if (res.status === "succeeded") return res;
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
return null;
}, "成片已导出")
}
/>
);
}

View File

@ -239,6 +239,31 @@
color: var(--black-alpha-48); letter-spacing: .06em;
text-transform: uppercase;
}
/* 新建商品 主 CTA(转写自 model-photo.html .mp-list-h .new-prod) */
.image-workbench .iw-list-h .new-prod {
margin-left: auto;
height: 28px; padding: 0 12px 0 10px;
display: inline-flex; align-items: center; gap: 6px;
background: var(--heat); color: #fff;
border: 1px solid var(--heat);
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);
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);
}
.image-workbench .iw-list-h .new-prod:active { transform: scale(.98); }
.image-workbench .iw-list-h .new-prod svg { width: 12px; height: 12px; }
.image-workbench .iw-ps-search {
position: relative; height: 32px;
margin: 12px 14px 10px;
@ -359,6 +384,9 @@
transition: border-color var(--t-base), color var(--t-base);
}
.image-workbench .iw-main-h .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }
.image-workbench .iw-main-h .tb-chip svg { width: 10px; height: 10px; opacity: .6; }
.image-workbench .iw-main-h .tb-menu-wrap { position: relative; }
.image-workbench .iw-main-h .tb-search-wrap { display: inline-flex; align-items: center; }
.image-workbench .iw-main-body {
flex: 1; min-height: 0;
display: grid;
@ -1493,3 +1521,16 @@
box-shadow: var(--shadow-cta);
}
.model-demo.dm-b .dm-param .gen-btn svg { width: 14px; height: 14px; }
/* ─── 任务中心 · 网格视图(转写自 asset-factory.html .history-grid/.history-card)─── */
.asset-factory .history-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
@media (max-width: 1280px) { .asset-factory .history-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 960px) { .asset-factory .history-grid { grid-template-columns: repeat(2, 1fr); } }
.asset-factory .history-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: grid; grid-template-columns: 78px 1fr; gap: 14px; align-items: center; transition: background var(--t-base); position: relative; }
.asset-factory .history-card:hover { background: var(--black-alpha-4); }
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
.asset-factory .history-body { min-width: 0; }
.asset-factory .history-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
.asset-factory .history-type { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-factory .history-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
.asset-factory .history-foot .mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }

View File

@ -3,7 +3,9 @@ import type {
Asset,
AuthPayload,
BillingSummary,
BillingTrend,
Ledger,
LoginSession,
ModelConfig,
Notification,
NotificationList,
@ -14,7 +16,8 @@ import type {
ScriptVersion,
Team,
TeamMember,
User
User,
UserPreference
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
@ -75,6 +78,27 @@ export const api = {
uploadAvatar(formData: FormData) {
return request<User>("/api/auth/me/avatar/", { method: "POST", body: formData });
},
resetAvatar() {
return request<User>("/api/auth/me/avatar/", { method: "DELETE" });
},
deleteAsset(id: string) {
return request<void>(`/api/assets/${id}/`, { method: "DELETE" });
},
preferences() {
return request<UserPreference>("/api/auth/me/preferences/");
},
updatePreferences(payload: Partial<UserPreference>) {
return request<UserPreference>("/api/auth/me/preferences/", { method: "PUT", body: JSON.stringify(payload) });
},
loginSessions() {
return request<LoginSession[]>("/api/auth/me/sessions/");
},
revokeSession(id: string) {
return request<{ revoked: number }>(`/api/auth/me/sessions/${id}/revoke/`, { method: "POST" });
},
revokeOtherSessions() {
return request<{ token: string }>("/api/auth/me/sessions/revoke-others/", { method: "POST" });
},
logout() {
return request<void>("/api/auth/logout/", { method: "POST" });
},
@ -123,6 +147,12 @@ export const api = {
updateProduct(id: string, payload: Partial<Product>) {
return request<Product>(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
uploadProductImage(productId: string, formData: FormData) {
return request<Product>(`/api/products/${productId}/images/`, { method: "POST", body: formData });
},
deleteProductImage(productId: string, imageId: string) {
return request<Product>(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" });
},
deleteProduct(id: string) {
return request<void>(`/api/products/${id}/`, { method: "DELETE" });
},
@ -132,7 +162,7 @@ export const api = {
project(id: string) {
return request<Project>(`/api/projects/${id}/`);
},
createProject(payload: { name: string; product: string }) {
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
},
deleteProject(id: string) {
@ -159,6 +189,12 @@ export const api = {
generateStoryboard(projectId: string, payload: { prompt: string }) {
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
},
pollStoryboard(projectId: string) {
return request<{ status: "generating" | "succeeded" | "failed"; done: number; total: number; version_id: string; error?: string }>(
`/api/projects/${projectId}/poll-storyboard/`,
{ method: "POST" }
);
},
skipStoryboard(projectId: string) {
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
},
@ -174,6 +210,24 @@ export const api = {
submitExport(projectId: string) {
return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" });
},
pollExport(projectId: string) {
return request<import("./types").ExportPoll>(`/api/projects/${projectId}/poll-export/`, { method: "POST" });
},
uploadVideoSegment(projectId: string, segmentId: string, file: File) {
const form = new FormData();
form.append("video_segment_id", segmentId);
form.append("file", file);
return request<Project>(`/api/projects/${projectId}/upload-video-segment/`, { method: "POST", body: form });
},
uploadBgm(projectId: string, file: File, volume?: number) {
const form = new FormData();
form.append("file", file);
if (volume != null) form.append("volume", String(volume));
return request<Project>(`/api/projects/${projectId}/upload-bgm/`, { method: "POST", body: form });
},
saveTimeline(projectId: string, payload: import("./types").TimelineSavePayload) {
return request<Project>(`/api/projects/${projectId}/save-timeline/`, { method: "POST", body: JSON.stringify(payload) });
},
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
@ -186,6 +240,9 @@ export const api = {
ledgers() {
return request<Ledger[]>("/api/billing/ledgers/");
},
billingTrend(range?: "day" | "week" | "month") {
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
},
modelConfigs() {
return request<Paginated<ModelConfig>>("/api/ai/models/");
},

View File

@ -1,8 +1,97 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
const SHELL_COMMANDS: Command[] = [
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
{ id: "settings", group: "导航", label: "设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" },
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑、加入资产库", page: "imageOptimize", icon: "images" }
];
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
const [query, setQuery] = useState("");
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
return SHELL_COMMANDS.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
}, [query]);
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
if (!open) return null;
let lastGroup = "";
return createPortal(
<div
id="shell-command-bg"
className="show"
aria-hidden="false"
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
<div className="shell-command-head">
<IconKitSvg name="search" />
<input
id="shell-command-input"
autoFocus
placeholder="搜索页面、动作…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
else if (event.key === "Enter" && items[0]) { event.preventDefault(); run(items[0]); }
}}
/>
<span id="shell-command-count" className="shell-command-count">{items.length} </span>
<button id="shell-command-close" type="button" className="shell-command-close" aria-label="关闭" onClick={onClose}>Esc</button>
</div>
<div id="shell-command-list" className="shell-command-list">
{items.length === 0 && (
<div className="shell-command-empty">
<IconKitSvg name="search" />
<span></span>
<span className="shell-command-section">// 换个关键词试试</span>
</div>
)}
{items.map((cmd, i) => {
const section = cmd.group !== lastGroup ? <div className="shell-command-section">{cmd.group}</div> : null;
lastGroup = cmd.group;
return (
<div key={cmd.id}>
{section}
<button className={`shell-command-item${i === 0 ? " active" : ""}`} type="button" onClick={() => run(cmd)}>
<span className="cmd-ic"><IconKitSvg name={cmd.icon} /></span>
<span className="cmd-main">
<span className="cmd-title">{cmd.label}</span>
<span className="cmd-sub">{cmd.sub}</span>
</span>
{cmd.key && <span className="cmd-key">{cmd.key}</span>}
</button>
</div>
);
})}
</div>
</div>
</div>,
document.body
);
}
type Navigate = (page: Page) => void;
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
@ -50,18 +139,50 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
const activeNav = PAGE_TO_NAV[page];
const badges: Partial<Record<string, number>> = { products: products.length, projects: projects.length };
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
// 类(CSS 在 design-restraint.css),并持久化到 localStorage。
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1");
useEffect(() => {
document.body.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
return () => document.body.classList.remove("sidebar-collapsed");
}, [collapsed]);
// 命令面板: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") {
event.preventDefault();
setPaletteOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<>
<aside className="sidebar">
<div className="sidebar-head">
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
</a>
</div>
<button className="sidebar-toggle" type="button" aria-label="收窄导航" title="收窄导航">
<button
className="sidebar-toggle"
type="button"
aria-pressed={collapsed}
aria-label={collapsed ? "展开导航" : "收窄导航"}
title={collapsed ? "展开导航" : "收窄导航"}
onClick={() => setCollapsed((value) => !value)}
>
<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="搜索">
<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>
@ -90,6 +211,8 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
</div>
</div>
</aside>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
</>
);
}

View File

@ -13,7 +13,8 @@
src: local('Alibaba PuHuiTi 3.0'),
local('AlibabaPuHuiTi-3-55-Regular'),
local('Alibaba PuHuiTi 2.0'),
local('AlibabaPuHuiTi-2-55-Regular');
local('AlibabaPuHuiTi-2-55-Regular'),
url('/fonts/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -22,7 +23,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Medium'),
local('AlibabaPuHuiTi-3-65-Medium'),
local('AlibabaPuHuiTi-2-65-Medium');
local('AlibabaPuHuiTi-2-65-Medium'),
url('/fonts/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -30,7 +32,8 @@
font-style: normal;
font-display: swap;
src: local('AlibabaPuHuiTi-3-75-SemiBold'),
local('AlibabaPuHuiTi-2-75-SemiBold');
local('AlibabaPuHuiTi-2-75-SemiBold'),
url('/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -39,7 +42,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Bold'),
local('AlibabaPuHuiTi-3-85-Bold'),
local('AlibabaPuHuiTi-2-85-Bold');
local('AlibabaPuHuiTi-2-85-Bold'),
url('/fonts/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -12,4 +12,8 @@
.asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
.asset-card { position: relative; }
}
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
body.edit-mode .asset-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; }

View File

@ -11,5 +11,8 @@ import "./products-page.css";
import "./library-page.css";
import "./messages-page.css";
import "./settings-page.css";
import "./ai-tools-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
createRoot(document.getElementById("root")!).render(<App />);

View File

@ -166,6 +166,14 @@
.shots-empty .empty-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }
/* 镜头脚本卡(真实脚本镜头列表) */
.shot-card { display: flex; gap: 12px; padding: 12px 4px; border-bottom: 1px solid var(--border-faint); }
.shot-card:last-child { border-bottom: 0; }
.shot-card .shot-n { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 6px; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }
.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; }
/* 对话空态三胶囊 */
.chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.chat-empty .ce-title { font-size: 13.5px; color: var(--accent-black); font-weight: 500; }
@ -232,6 +240,19 @@
.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; }
/* ── 生成中统一置灰:凡 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; }
.btn-aigen:disabled:hover,
.asset-card-2.prod-lib-card .prod-action .btn-aigen:disabled:hover { background: var(--heat); box-shadow: none; transform: none; }
.chat-mode:disabled { opacity: .45; cursor: not-allowed; }
.chat-mode:disabled:hover { background: var(--surface); border-color: var(--border-faint); color: var(--accent-black); }
.pill-cta:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.pill-cta:disabled:hover { box-shadow: none; }
.tag-add:disabled { opacity: .45; cursor: not-allowed; }
.tl-toolbar .tl-action:disabled { opacity: .38; cursor: not-allowed; }
.tl-toolbar .tl-action:disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-72); }
.prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); }
.prompt-box[contenteditable="true"] { cursor: text; outline: none; }
.prompt-box[contenteditable="true"]:hover { border-color: var(--heat-20); }
@ -322,7 +343,10 @@
.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 { 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; }
.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; } }
.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); }

View File

@ -265,3 +265,172 @@
@media (max-width: 1100px) {
.product-create-page .form-grid { grid-template-columns: 1fr; }
}
/* ============================================================
新建商品 · 右侧 Drawer(在商品库页面原地打开)
像素基线: public/exact/products.html #pc-drawer + 其内联 <style>
全部 scope .pc-drawer ,与上方整页 .product-create-page 互不影响
============================================================ */
/* .drawer.pc-drawer(0,2,0)·提高特异性压过共享 .drawer{width:540px}
dev App.tsx 链路的 product-create-page.css design-restraint.css 先注入,
等特异性时基类反而后加载会赢,故必须双类提权 */
.drawer.pc-drawer { width: 820px; max-width: 100vw; }
.pc-drawer .drawer-h h3 { font-size: 16px; font-weight: 600; }
.pc-drawer .drawer-b { padding: 24px 28px; }
.pc-drawer .drawer-b .form-card { background: transparent; border: 0; padding: 0; border-radius: 0; }
.pc-drawer .drawer-f { padding: 14px 24px; background: var(--surface); align-items: center; }
.pc-drawer .drawer-f .btn-guide {
margin-right: auto;
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--black-alpha-56);
background: transparent; border: 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--r-md);
font-family: inherit;
transition: background var(--t-base), color var(--t-base);
}
.pc-drawer .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }
.pc-drawer .drawer-f .btn-guide svg { width: 14px; height: 14px; }
/* form-card · 表单容器(drawer 内字段) */
.pc-drawer .form-card .field { margin-bottom: 16px; }
.pc-drawer .form-card .field:last-child { margin-bottom: 0; }
.pc-drawer .form-card .field-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px;
}
.pc-drawer .form-card .field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--accent-black); margin-bottom: 6px;
}
.pc-drawer .form-card .field-label .req { color: var(--heat); margin-left: 2px; }
.pc-drawer .form-card .field-label .opt {
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
}
.pc-drawer .form-card .input,
.pc-drawer .form-card .select {
width: 100%; height: 38px;
background: var(--background-lighter);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
padding: 0 14px;
font-size: 13.5px; color: var(--accent-black);
outline: none; font-family: inherit;
transition: border-color var(--t-base);
}
.pc-drawer .form-card .input:focus,
.pc-drawer .form-card .select:focus {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
/* 商品主图 · 上传(左) + 示例(右) */
.pc-drawer .form-card .pf-upload-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px; align-items: stretch;
}
.pc-drawer .form-card .pf-upload-zone {
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-md);
padding: 28px 20px;
background: var(--background-lighter);
cursor: pointer; text-align: center;
transition: border-color var(--t-base), background var(--t-base);
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 180px;
}
.pc-drawer .form-card .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
.pc-drawer .form-card .pf-upload-zone .uz-ic {
width: 44px; height: 44px;
margin: 0 auto 10px;
background: var(--surface);
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
color: var(--heat);
display: grid; place-items: center;
}
.pc-drawer .form-card .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }
.pc-drawer .form-card .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }
.pc-drawer .form-card .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
.pc-drawer .form-card .pf-upload-zone .uz-d {
margin-top: 8px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
/* 示例图 · 纵向卡片 */
.pc-drawer .form-card .pf-example {
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
.pc-drawer .form-card .pf-example .ex-h {
font-size: 13px; font-weight: 600; color: var(--accent-black);
}
.pc-drawer .form-card .pf-example .ex-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb {
aspect-ratio: 1;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
display: grid; place-items: center;
color: var(--black-alpha-32);
}
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
pointer-events: none;
}
.pc-drawer .form-card .pf-example .ex-d {
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
}
.pc-drawer .form-card .pf-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px; margin-top: 12px;
}
.pc-drawer .form-card .pf-grid:empty { display: none; }
/* 核心卖点 · bullet-list(drawer 变体) */
.pc-drawer .form-card .bullet-list { list-style: none; padding: 0; margin: 0; }
.pc-drawer .form-card .bullet-list .bl-item,
.pc-drawer .form-card .bullet-list .bl-add {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 13.5px;
}
.pc-drawer .form-card .bullet-list .bl-add { background: transparent; border-style: dashed; }
.pc-drawer .form-card .bullet-list .num {
width: 22px; height: 22px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 11px; color: var(--heat); font-weight: 700;
display: grid; place-items: center; flex-shrink: 0;
}
.pc-drawer .form-card .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
.pc-drawer .form-card .bullet-list .bl-input {
flex: 1; background: transparent; border: 0; outline: none;
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
}
.pc-drawer .form-card .bullet-list .bl-x {
width: 22px; height: 22px;
color: var(--black-alpha-48);
cursor: pointer; display: grid; place-items: center;
border-radius: var(--r-sm);
transition: color var(--t-base), background var(--t-base);
}
.pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
.pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }
@media (max-width: 900px) {
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
}

View File

@ -30,4 +30,35 @@
/* 编辑模式 checkbox(默认隐藏) */
.product-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; }
.product-card .card-check svg { width: 11px; height: 11px; opacity: 0; }
}
/* 批量编辑模式(忠实移植自 products.html · body.edit-mode 全局态,控件仅商品库出现) */
body.edit-mode .product-card { cursor: pointer; }
body.edit-mode .product-card .card-check { display: grid; }
body.edit-mode .product-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .product-card.selected .card-check svg { opacity: 1; }
body.edit-mode .product-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
body.edit-mode .product-footer .stat,
body.edit-mode .product-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
.bulk-bar {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md);
padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 100; font-size: 13px;
}
body.edit-mode .bulk-bar { display: inline-flex; }
.bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
.bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }
.bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }
.bulk-bar button { height: 30px; padding: 0 12px; background: transparent; border: 1px solid rgba(255,255,255,.24); border-radius: var(--r-sm); color: var(--accent-white); font-size: 12.5px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: background .15s, border-color .15s; }
.bulk-bar button:hover { background: rgba(255,255,255,.08); }
.bulk-bar button.danger { background: var(--accent-crimson, #c43d3d); border-color: var(--accent-crimson, #c43d3d); }
.bulk-bar button.danger:hover { filter: brightness(1.06); }
.bulk-bar button:disabled { opacity: .4; cursor: not-allowed; }
.bulk-bar button svg { width: 12px; height: 12px; }
.bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }
.bulk-bar .clear-sel:hover { color: var(--accent-white); }
.btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }

View File

@ -1,7 +1,15 @@
import { useState } from "react";
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
import { useEffect, useState } from "react";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
type TrendRange = "day" | "week" | "month";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 ¥", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 ¥", totalLabel: "6 月合计", avgLabel: "月均" }
};
type Tab = "overview" | "by-project" | "by-member" | "bills";
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
@ -11,16 +19,17 @@ const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: num
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
];
const STAGES: Array<{ k: string; color: string }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)" },
{ k: "故事板(image-2)", color: "var(--accent-forest)" },
{ k: "基础资产", color: "var(--black-alpha-56)" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)" }
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)", bucket: "video" },
{ k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" },
{ k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
];
export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharge }: {
export function AccountPage({ billing, ledgers, trend, projects, teamMembers, onRecharge }: {
billing: BillingSummary | null;
ledgers: Ledger[];
trend: BillingTrend | null;
projects: Project[];
teamMembers: TeamMember[];
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
@ -46,6 +55,26 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 消费趋势(日/周/月可切)+ 按阶段 / 按项目分布 —— 全部来自真实 CHARGE 流水
// day 用首屏 prop;week/month 切换时按真接口拉对应区间(缺接口已补 ?range=)
const [range, setRange] = useState<TrendRange>("day");
const [rangeTrend, setRangeTrend] = useState<BillingTrend | null>(null);
useEffect(() => {
if (range === "day") { setRangeTrend(null); return; }
let alive = true;
api.billingTrend(range).then((data) => { if (alive) setRangeTrend(data); }).catch(() => {});
return () => { alive = false; };
}, [range]);
const activeTrend = range === "day" ? trend : rangeTrend;
const meta = RANGE_META[range];
const daily = activeTrend?.daily ?? [];
const peak = Number(activeTrend?.peak || 0);
const total14 = Number(activeTrend?.total_14d ?? used);
const avgValue = daily.length ? total14 / daily.length : 0;
const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used;
const projectSpend = (id: string) => Number(trend?.by_project[id] || 0);
return (
<section className="account-page">
<div className="page-head">
@ -137,32 +166,51 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
<div className="pane trend-pane">
<div className="trend-head">
<h3></h3>
<span className="sub">// 近 14 天 · 单位 ¥</span>
<span className="sub">{meta.sub}</span>
<span className="spacer"></span>
<button className="chip active" type="button"></button>
<button className="chip" type="button"></button>
<button className="chip" type="button"></button>
{(["day", "week", "month"] as TrendRange[]).map((r) => (
<button key={r} className={`chip${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
))}
</div>
<div className="trend-chart">
<div className="bars"></div>
<div className="x-axis"></div>
<div className="bars">
{daily.map((d) => {
const amt = Number(d.amount);
const h = peak > 0 ? Math.max(amt > 0 ? 4 : 0, (amt / peak) * 100) : 0;
const isPeak = peak > 0 && amt === peak;
return (
<div className={`bar${isPeak ? " peak" : ""}`} key={d.date} title={`${d.label} · ${money(amt)}`}>
<span style={{ height: `${h}%` }} />
</div>
);
})}
</div>
<div className="x-axis">
{daily.map((d, i) => (
<span key={d.date}>{i % 2 === 0 ? d.label : ""}</span>
))}
</div>
</div>
<div className="trend-foot">
<div className="item"><span className="k">14 </span><span className="v">{money(used)}</span></div>
<div className="item"><span className="k"></span><span className="v">{money(used / 14)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(used)}</span></div>
<div className="item"><span className="k">{meta.totalLabel}</span><span className="v">{money(total14)}</span></div>
<div className="item"><span className="k">{meta.avgLabel}</span><span className="v">{money(avgValue)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(peak)}</span></div>
</div>
</div>
<div className="pane stage-pane">
<h3></h3>
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
{STAGES.map((s) => (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(0)}</span></div>
<div className="usage-bar"><span style={{ width: "0%", background: s.color }} /></div>
</div>
))}
{STAGES.map((s) => {
const amt = Number(trend?.by_stage[s.bucket] || 0);
const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0;
return (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(amt)}</span></div>
<div className="usage-bar"><span style={{ width: `${w}%`, background: s.color }} /></div>
</div>
);
})}
<div className="total"><span></span><span className="v">{money(used)}</span></div>
</div>
</div>
@ -208,9 +256,12 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
<table className="billing-table">
<thead><tr><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className="zero">{money(0)}</td></tr>
))}
{projects.map((p) => {
const spend = projectSpend(p.id);
return (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className={spend > 0 ? "neg" : "zero"}>{money(spend)}</td></tr>
);
})}
</tbody>
</table>
</div>

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
@ -52,6 +53,24 @@ function statusText(status: string) {
return STATUS_LABEL[status] || status;
}
// 下载图片:优先 fetch→blob 触发真实下载;跨域失败则回退到新标签打开(用户仍拿到图)
async function downloadImage(url: string, filename: string) {
try {
const res = await fetch(url, { mode: "cors" });
const blob = await res.blob();
const href = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
} catch {
window.open(url, "_blank", "noopener");
}
}
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
const cards = [
{
@ -88,7 +107,40 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
return acc;
}, [aiTasks]);
const visible = aiTasks.slice(0, 8);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("list");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
];
const visible = aiTasks.filter((task) => {
const pill = statusPill(task.status);
if (filter === "gen" && pill !== "info") return false;
if (filter === "ok" && pill !== "ok") return false;
if (filter === "err" && pill !== "err") return false;
if (typeFilter && task.task_type !== typeFilter) return false;
if (timeFilter !== "all" && task.created_at) {
const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
});
return (
<div className="asset-factory">
@ -133,21 +185,63 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</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">{aiTasks.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="搜索任务名" />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{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>
</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 ? TASK_TYPE_LABEL[typeFilter] || 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>{TASK_TYPE_LABEL[t] || t}
</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="active">
<List size={13} />
</button>
<button type="button">
<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>
@ -160,6 +254,31 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
<div className="mono">// NO TASKS YET</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">
{visible.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
return (
<article className="task-card history-card" key={task.id}>
<div className="placeholder"><span className="ph-frame">{task.id.slice(0, 4)}</span></div>
<div className="history-body">
<div className="history-name">{typeLabel}</div>
<div className="history-type">// {task.task_type}</div>
<div className="history-foot">
<span className="mono">{(task.created_at || "").slice(0, 10)}</span>
<span className={`pill ${pill}`}><span className="dot" />{statusText(task.status)}</span>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="task-list-view">
<table className="t">
@ -168,7 +287,8 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
<th style={{ width: "42%" }}></th>
<th style={{ width: 160 }}></th>
<th></th>
<th style={{ width: 140 }}> ID</th>
<th style={{ width: 120 }}></th>
<th style={{ width: 48 }} />
</tr>
</thead>
<tbody>
@ -208,7 +328,8 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
{statusText(task.status)}
</span>
</td>
<td className="muted-2">{task.id.slice(0, 8)}</td>
<td className="muted-2 mono" style={{ fontSize: 11 }}>{(task.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
})}
@ -243,7 +364,7 @@ const MODE_META: Record<
title: "模特上身图",
tag: "[ MODEL · TRY-ON ]",
desc: "选择模特和商品,生成电商模特上身图。",
ratio: "3:4",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
@ -328,6 +449,19 @@ export function ImageWorkbenchPage({
const [pickedIds, setPickedIds] = useState<string[]>([]);
const [generating, setGenerating] = useState(false);
const [results, setResults] = useState<Asset[] | null>(null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
const [gridQuery, setGridQuery] = useState("");
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
const [tbOpen, setTbOpen] = useState<"" | "time" | "model">("");
const [searchOpen, setSearchOpen] = useState(false);
function pickReference(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setRefImage({ name: file.name, url: URL.createObjectURL(file) });
event.target.value = "";
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
@ -387,15 +521,19 @@ export function ImageWorkbenchPage({
</div>
)}
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="重跑">
<button className="gen-img-btn" type="button" title="重跑" disabled={generating} onClick={() => runGenerate()}>
<RefreshCw size={14} />
</button>
<button className="gen-img-btn" type="button" title="下载">
<Download size={14} />
</button>
<button className="gen-img-btn" type="button" title="更多">
<MoreHorizontal size={14} />
</button>
{url && (
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
)}
{url && (
<button className="gen-img-btn" type="button" title="查看原图" onClick={() => window.open(url, "_blank", "noopener")}>
<MoreHorizontal size={14} />
</button>
)}
</div>
</div>
))}
@ -418,7 +556,7 @@ export function ImageWorkbenchPage({
</button>
</div>
<button className="ic-new-conv" type="button">
<button className="ic-new-conv" type="button" onClick={() => { setResults(null); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
<Plus size={13} />
</button>
@ -464,15 +602,15 @@ export function ImageWorkbenchPage({
</div>
<div className="gen-card">{renderResultGrid()}</div>
<div className="gen-card-actions">
<button className="btn btn-sm" type="button">
<button className="btn btn-sm" type="button" onClick={() => setResults(null)}>
<RefreshCw size={13} />
</button>
<button className="btn btn-sm" type="button">
<button className="btn btn-sm" type="button" onClick={() => navigate?.("library")}>
<Check size={13} />
</button>
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<button className="btn btn-sm btn-ghost" type="button" title="下载全部" onClick={() => results?.forEach((asset, i) => { const u = asset.files?.[0]?.preview_url; if (u) downloadImage(u, `${meta.title}-${i + 1}.png`); })}>
<MoreHorizontal size={13} />
</button>
</div>
@ -501,9 +639,17 @@ export function ImageWorkbenchPage({
<div className="ic-input-wrap">
<div className="ic-input">
<div className="ic-input-top">
<button className="add-btn" type="button" title="上传参考图">
<button className="add-btn" type="button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
<Plus size={22} />
</button>
<input ref={refInputRef} type="file" accept="image/*" hidden onChange={pickReference} />
{refImage && (
<span className="meta-chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={refImage.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{refImage.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImage(null)}>×</button>
</span>
)}
</div>
<textarea
className="ic-input-text"
@ -568,6 +714,12 @@ export function ImageWorkbenchPage({
</div>
<div className="iw-list-h">
<span className="mono">// 商品空间</span>
{navigate && (
<button className="new-prod" type="button" title="新建商品" onClick={() => navigate("productCreateUpload")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<span></span>
</button>
)}
</div>
<div className="iw-ps-list">
{products.length === 0 ? (
@ -607,13 +759,33 @@ export function ImageWorkbenchPage({
</span>
</div>
<span className="spacer" />
<button className="search-btn" type="button" title="搜索">
<Search size={14} />
</button>
{mode === "model" && navigate && (
<button className="tb-chip" type="button" onClick={() => navigate("modelPhotoDemoA")}>
A
<div className="tb-search-wrap">
{searchOpen && (
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索平台"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
<button className={`tb-chip${gridSort === "name" ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "time" ? "" : "time"))}><span className="lbl">{gridSort === "name" ? "按名称" : "时间"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${gridSort === "recent" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("recent"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg></div>
<div className={`mi${gridSort === "name" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("name"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg></div>
</div>
</div>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "model" ? " open" : ""}`} data-filter="model">
<button className={`tb-chip${gridQuery ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "model" ? "" : "model"))}><span className="lbl">{gridQuery || "模特"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${!gridQuery ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridQuery(""); setTbOpen(""); }}><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>
{(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => (
<div className={`mi${gridQuery === nm ? " selected" : ""}`} key={nm} role="button" tabIndex={0} onClick={() => { setGridQuery(nm); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{nm}</div>
))}
</div>
</div>
</>
)}
</div>
@ -629,7 +801,11 @@ export function ImageWorkbenchPage({
</div>
<div className="model-grid">
{personAssets.length > 0
? personAssets.slice(0, 6).map((item) => {
? personAssets
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || "")))
.slice(0, 6)
.map((item) => {
const url = item.files?.[0]?.preview_url;
return (
<button
@ -655,7 +831,10 @@ export function ImageWorkbenchPage({
</button>
);
})
: FALLBACK_MODELS.map((item) => (
: FALLBACK_MODELS
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0))
.map((item) => (
<button
type="button"
key={item.id}
@ -681,7 +860,7 @@ export function ImageWorkbenchPage({
<span className="title"></span>
</div>
<div className="platform-grid">
{PLATFORM_OPTIONS.map((item) => (
{PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).map((item) => (
<button
type="button"
key={item.id}
@ -867,7 +1046,11 @@ const DEMO_SIDE_PRODUCTS = [
{ id: "d6", title: "小熊 4L 可视空气炸锅", category: "家居家电", batches: 0 }
];
export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A" | "B"; products: Product[]; onBack: () => void }) {
export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { variant: "A" | "B"; products: Product[]; onBack: () => void; navigate?: (page: Page) => void }) {
// 方案 A/B 是设计展示页(mock 数据,无生成后端);所有动作按钮导向真实工具 / 改本地选中态,杜绝死按钮
const [demoCount, setDemoCount] = useState("4 张");
const [demoRatio, setDemoRatio] = useState("3:4");
const toRealTool = () => onBack();
// 左栏商品空间:优先真 products(最近 6 条),空则回退基线占位
const sideProducts =
products.length > 0
@ -889,7 +1072,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-side-h">
<div className="ti-row">
<span className="ti"></span>
<button className="add" type="button" title="新建商品">
<button className="add" type="button" title="新建商品" onClick={() => navigate?.("productCreateUpload")}>
<Plus size={11} />
</button>
</div>
@ -916,7 +1099,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
))}
</div>
<button className="dm-all" type="button">
<button className="dm-all" type="button" onClick={() => navigate?.("products")}>
<LayoutGrid size={12} />
<span className="ct">{totalCount} </span>
@ -987,7 +1170,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-field-h"></div>
<div className="dm-chip-row">
{["1 张", "2 张", "4 张", "8 张"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === "4 张" ? "active" : ""}`}>
<button type="button" key={label} className={`dm-chip ${label === demoCount ? "active" : ""}`} onClick={() => setDemoCount(label)}>
{label}
</button>
))}
@ -998,7 +1181,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-field-h"></div>
<div className="dm-chip-row">
{["1:1", "3:4", "9:16", "16:9"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === "3:4" ? "active" : ""}`}>
<button type="button" key={label} className={`dm-chip ${label === demoRatio ? "active" : ""}`} onClick={() => setDemoRatio(label)}>
{label}
</button>
))}
@ -1016,7 +1199,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<span> <span className="v"> ¥1.20</span></span>
<span> ¥327.40</span>
</div>
<button className="dm-gen" type="button">
<button className="dm-gen" type="button" onClick={toRealTool}>
<Sparkles size={15} />
· {active.title} × Ava
</button>
@ -1042,9 +1225,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1069,9 +1252,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1096,7 +1279,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="取消"><X size={13} /></button>
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1140,10 +1323,10 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
<span className="spacer" />
<div className="dm-tb">
<button className="icbtn" type="button" title="搜索批次"><Search size={13} /></button>
<button className="chip" type="button"> <ChevronDown size={10} /></button>
<button className="chip" type="button"> <ChevronDown size={10} /></button>
<button className="chip" type="button"> <ChevronDown size={10} /></button>
<button className="icbtn" type="button" title="搜索批次" onClick={toRealTool}><Search size={13} /></button>
<button className="chip" type="button" onClick={toRealTool}> <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}> <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}> <ChevronDown size={10} /></button>
</div>
</div>
</div>
@ -1168,9 +1351,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1193,9 +1376,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1218,7 +1401,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="取消"><X size={13} /></button>
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1245,9 +1428,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1274,7 +1457,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="删除"><Trash2 size={13} /></button>
</div>
</div>
@ -1288,28 +1471,28 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
{/* 底部 fixed 参数面板 */}
<div className="dm-param-wrap">
<div className="dm-param">
<button className="pchip active" type="button">
<button className="pchip active" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span>Ava</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span>4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span>3:4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span className="muted">+ </span>
</button>
<span className="spacer" />
<span className="meta-right"> <span className="v">¥1.20</span> · <span className="v">¥327.40</span></span>
<button className="gen-btn" type="button">
<button className="gen-btn" type="button" onClick={toRealTool}>
<Sparkles size={14} />
· {active.title} × Ava
</button>

View File

@ -1,7 +1,11 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Asset } from "../types";
import { Drawer } from "../components/overlays";
import { ConfirmModal, Drawer } from "../components/overlays";
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
@ -34,16 +38,52 @@ function assetTab(asset: Asset): LibTab {
}
}
export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void }) {
export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people");
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false);
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
// 切 tab 时清空与该 tab 无关的筛选
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const counts = LIB_TABS.reduce((acc, t) => { acc[t.key] = assets.filter((a) => assetTab(a) === t.key).length; return acc; }, {} as Record<LibTab, number>);
const inTab = assets.filter((a) => assetTab(a) === tab);
const filtered = inTab.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()));
// 当前 tab 下真实存在的来源 / 类型(下拉只列真有的)
const srcOptions = Array.from(new Set(inTab.map((a) => a.source).filter(Boolean)));
const kindOptions = Array.from(new Set(inTab.map((a) => a.asset_type).filter(Boolean)));
// 元数据筛选项:从当前 tab 真实资产的 metadata 派生(无则下拉只有「全部」)
const metaOptions = (key: string) => Array.from(new Set(inTab.map((a) => String((a.metadata as Record<string, unknown> | undefined)?.[key] ?? "")).filter(Boolean)));
const filtered = inTab
.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()))
.filter((a) => !srcFilter || a.source === srcFilter)
.filter((a) => !kindFilter || a.asset_type === kindFilter)
.filter((a) => Object.entries(metaFilter).every(([k, v]) => !v || String((a.metadata as Record<string, unknown> | undefined)?.[k] ?? "") === v))
.sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return sortDesc ? cmp : -cmp;
});
async function submit(event: FormEvent) {
event.preventDefault();
@ -65,9 +105,9 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
</div>
<div className="actions">
<button className="btn" type="button" id="lib-manage-btn">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-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="lib-manage-label"></span>
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
</button>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
@ -87,14 +127,83 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => (
<div className="chip-wrap" data-key={chip.key} key={chip.key}>
<button className="chip" type="button"><span className="chip-label">{chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
</div>
))}
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)Asset 无对应字段,保持静态
if (chip.key === "source") {
return (
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
<button className={`chip${srcFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!srcFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSrcFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{srcOptions.length > 0 && <div className="mi-sep" />}
{srcOptions.map((src) => (
<div className={`mi${srcFilter === src ? " selected" : ""}`} key={src} role="button" tabIndex={0} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{SOURCE_LABELS[src] || src}
</div>
))}
</div>
</div>
);
}
if (chip.key === "kind") {
return (
<div className={`chip-wrap${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
<button className={`chip${kindFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
<span className="chip-label">{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!kindFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setKindFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{kindOptions.length > 0 && <div className="mi-sep" />}
{kindOptions.map((k) => (
<div className={`mi${kindFilter === k ? " selected" : ""}`} key={k} role="button" tabIndex={0} onClick={() => { setKindFilter(k); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{KIND_LABELS[k] || k}
</div>
))}
</div>
</div>
);
}
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
const opts = metaOptions(chip.key);
const cur = metaFilter[chip.key] || "";
return (
<div className={`chip-wrap${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
<button className={`chip${cur ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
<span className="chip-label">{cur || chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!cur ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{chip.label}
</div>
{opts.length > 0 && <div className="mi-sep" />}
{opts.map((v) => (
<div className={`mi${cur === v ? " selected" : ""}`} key={v} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{v}
</div>
))}
</div>
</div>
);
})}
<span className="spacer"></span>
<div className="chip-wrap" data-key="sort">
<button className="chip" type="button"><span className="chip-label">使</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "最近添加" : "最早添加"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
</div>
</div>
</div>
@ -106,6 +215,11 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmId(asset.id); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb">
{cover ? <img src={cover} alt={asset.name} loading="lazy" /> : <span className="ph-frame">{asset.asset_type}</span>}
</div>
@ -118,6 +232,15 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<ConfirmModal
open={Boolean(confirmId)}
title="删除资产"
detail="确定删除该资产?该操作不可撤销。"
confirmText="删除"
onCancel={() => setConfirmId(null)}
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>
</section>
);

View File

@ -1,7 +1,7 @@
import { Fragment, useState } from "react";
import type { CSSProperties } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import { Play } from "lucide-react";
import type { Asset, BillingSummary, Product, Project, Team, User } from "../types";
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@ -67,20 +67,27 @@ export function PipelinePage(props: {
logout: () => void;
onRefresh: () => void;
onGenerateScript: (prompt: string) => void;
onAdoptScript: (scriptId: string) => void;
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
onGenerateStoryboard: (prompt: string) => void;
onSkipStoryboard: () => void;
onSubmitVideo: (segmentId: string, prompt: string) => void;
onPollVideo: (segmentId: string) => void;
onSubmitAllVideos: (prompt: string) => void;
onPollVideosQuiet: () => void | Promise<void>;
onPollAllVideos: () => void;
onSubmitExport: () => void;
exportResult: ExportPoll | null;
onRefreshExport: () => void;
onUploadVideoSegment: (segmentId: string, file: File) => void;
onUploadBgm: (file: File, volume: number) => void;
onSaveTimeline: (payload: TimelineSavePayload) => void;
onSubmitExport: (payload?: TimelineSavePayload) => void;
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onSubmitExport
onGenerateScript, onAdoptScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
@ -91,11 +98,34 @@ export function PipelinePage(props: {
return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || "";
};
const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : "");
// 缩略图解析:优先用后端内嵌的 preview_url(不受团队 assets 分页 20 条影响),回退到 assets 列表解析
type GroupLike = { adopted_asset: string | null; adopted_asset_url?: string; candidate_assets?: string[]; candidate_asset_urls?: Record<string, string> };
const groupMainUrl = (g?: GroupLike | null): string =>
g?.adopted_asset_url || assetUrl(g?.adopted_asset) || (g?.candidate_assets?.[0] ? (g?.candidate_asset_urls?.[g.candidate_assets[0]] || assetUrl(g.candidate_assets[0])) : "");
const candUrl = (g: GroupLike | null | undefined, id: string): string => g?.candidate_asset_urls?.[id] || assetUrl(id);
const frameUrl = (f?: { asset: string; asset_url?: string } | null): string => f?.asset_url || assetUrl(f?.asset);
const segUrl = (s?: { adopted_asset?: string | null; adopted_asset_url?: string } | null): string => s?.adopted_asset_url || assetUrl(s?.adopted_asset);
// ── Stage 1:脚本(采用版优先,否则最近一版)+ 镜头列表 ──
const scripts = [...(project.script_versions ?? [])];
const currentScript =
scripts.find((s) => s.is_adopted) ||
[...scripts].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0] ||
null;
const scriptAdopted = Boolean(currentScript?.is_adopted);
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
const groups = project.base_asset_groups ?? [];
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
function jumpAssetSection(kind: "product" | "person" | "scene") {
setAssetTab(kind);
if (typeof document !== "undefined") {
document.getElementById(`asset-sec-${kind}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
// ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ──
const storyboards = project.storyboard_versions ?? [];
@ -128,9 +158,319 @@ export function PipelinePage(props: {
const activeDot = navigated ? viewStage : projectStage;
const completed = Math.max(projectStage - 1, activeDot - 1);
const [chatText, setChatText] = useState("");
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const chatFileRef = useRef<HTMLInputElement | null>(null);
function clearChat() {
setChatText("");
setChatAttachments([]);
setChatMode("ai");
}
function pickScriptMode() {
setChatMode("manual");
chatFileRef.current?.click();
}
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || "").trim();
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
setChatMode("manual");
chatTextareaRef.current?.focus();
};
reader.readAsText(file);
}
function focusThemeMode() {
setChatMode("theme");
chatTextareaRef.current?.focus();
}
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
const isVideoAsset = (id: string | null | undefined): boolean => {
const a = id ? byId.get(id) : null;
if (!a) return false;
if (a.asset_type === "video") return true;
const f = a.files?.find((x) => x.is_primary) || a.files?.[0];
return !!f && /video\//.test(f.content_type || "");
};
// ── Stage 5 · 编辑器状态(可改片段/字幕文本/转场/BGM音量,本地编辑,保存草稿/导出时落盘)──
type EdClipState = { key: string; asset: string; url: string; isVideo: boolean; durMs: number; trimStartMs: number; trimEndMs: number | null; subtitle: string };
type EditorState = { clips: EdClipState[]; subtitleEnabled: boolean; subtitleStyle: string; transition: string; bgmVolume: number };
const buildInitialEditor = useCallback((): EditorState => {
const tl = project.timeline;
const sub = (tl?.subtitle_tracks ?? [])[0];
const savedTexts: string[] = (sub?.content ?? []).map((c) => String(c?.text || ""));
const scriptScript = (project.script_versions ?? []).find((s) => s.is_adopted) || (project.script_versions ?? [])[0];
const scriptTexts = [...(scriptScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).map((s) => (s.narration || "").trim());
const subFor = (i: number) => savedTexts[i] || scriptTexts[i] || "";
const baseClips: EdClipState[] = tl?.clips?.length
? [...tl.clips].sort((a, b) => a.sort_order - b.sort_order).map((c, i) => ({
key: `c${i}-${c.id}`, asset: c.asset, url: c.asset_url || assetUrl(c.asset),
isVideo: c.asset_is_video ?? isVideoAsset(c.asset), durMs: c.duration_ms || 0,
trimStartMs: c.trim_start_ms || 0, trimEndMs: c.trim_end_ms ?? null, subtitle: subFor(i)
}))
: segments.filter((s) => s.adopted_asset).map((s, i) => ({
key: `s${i}-${s.id}`, asset: s.adopted_asset as string, url: segUrl(s), isVideo: true,
durMs: (s.target_duration_seconds || 0) * 1000, trimStartMs: 0, trimEndMs: null, subtitle: subFor(i)
}));
const bgm = (tl?.bgm_tracks ?? [])[0];
return {
clips: baseClips,
subtitleEnabled: sub ? sub.enabled : true,
subtitleStyle: (sub?.style?.key as string) || "plain",
transition: tl?.metadata?.transition?.type || "none",
bgmVolume: bgm?.volume ?? 60
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.timeline, project.script_versions, segments]);
const [edState, setEdState] = useState<EditorState>(buildInitialEditor);
const [edHistory, setEdHistory] = useState<EditorState[]>([]);
const [edFuture, setEdFuture] = useState<EditorState[]>([]);
const [selectedClip, setSelectedClip] = useState(0);
const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
const edHydratedRef = useRef(false);
const bgmFileRef = useRef<HTMLInputElement | null>(null);
const videoUploadRef = useRef<HTMLInputElement | null>(null);
const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
// 提交一个新编辑态(进 undo 栈)
const commitEdit = useCallback((next: EditorState) => {
setEdHistory((h) => [...h.slice(-49), edState]);
setEdFuture([]);
setEdState(next);
}, [edState]);
const edClips = edState.clips.map((c) => ({ id: c.key, assetId: c.asset, url: c.url, isVideo: c.isVideo, durMs: c.durMs }));
const edTotalMs = edClips.reduce((sum, c) => sum + c.durMs, 0) || tlRulerMs;
const edOffsetMs = (idx: number) => edClips.slice(0, idx).reduce((sum, c) => sum + c.durMs, 0);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [edIdx, setEdIdx] = useState(0);
const [edPlaying, setEdPlaying] = useState(false);
const [edClipMs, setEdClipMs] = useState(0);
const edCur = edClips[Math.min(edIdx, Math.max(0, edClips.length - 1))] || null;
const edGlobalMs = edOffsetMs(edIdx) + edClipMs;
const gotoClip = useCallback((idx: number, atEnd = false) => {
if (idx < 0 || idx >= edClips.length) return;
setEdIdx(idx);
setEdClipMs(atEnd ? Math.max(0, (edClips[idx]?.durMs || 0) - 200) : 0);
}, [edClips]);
// 在时间轴上跳转到全局毫秒位置(点击标尺 seek)
const seekToMs = useCallback((globalMs: number) => {
let acc = 0;
for (let i = 0; i < edClips.length; i += 1) {
const dur = edClips[i].durMs || 0;
if (globalMs <= acc + dur || i === edClips.length - 1) {
const within = Math.max(0, Math.min(dur, globalMs - acc));
setEdIdx(i);
setEdClipMs(within);
if (edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = within / 1000;
return;
}
acc += dur;
}
}, [edClips]);
const togglePlay = useCallback(() => {
setEdPlaying((p) => {
const next = !p;
const v = videoRef.current;
if (edCur?.isVideo && v) {
if (next) v.play().catch(() => undefined);
else v.pause();
}
return next;
});
}, [edCur]);
// 上一帧 / 下一帧:视频按 ±1/25s 逐帧 seek,越界切相邻段;静态图切相邻片段
const stepFrame = useCallback((dir: 1 | -1) => {
const v = videoRef.current;
if (edCur?.isVideo && v && v.duration) {
const t = v.currentTime + dir * (1 / 25);
if (t < 0) { gotoClip(edIdx - 1, true); return; }
if (t > v.duration) { gotoClip(edIdx + 1, false); return; }
v.currentTime = t;
setEdClipMs(t * 1000);
} else {
gotoClip(edIdx + dir, false);
}
}, [edCur, edIdx, gotoClip]);
// 静态图(无视频文件)播放:定时推进虚拟播放头,到段尾自动进下一段
useEffect(() => {
if (!edPlaying || edCur?.isVideo || edClips.length === 0) return;
const tick = window.setInterval(() => {
setEdClipMs((ms) => {
const dur = edCur?.durMs || 2000;
if (ms + 120 >= dur) {
if (edIdx + 1 < edClips.length) { setEdIdx(edIdx + 1); return 0; }
setEdPlaying(false);
return dur;
}
return ms + 120;
});
}, 120);
return () => window.clearInterval(tick);
}, [edPlaying, edCur, edIdx, edClips.length]);
// 切换片段时同步 video 进度,正在播放则续播
useEffect(() => {
const v = videoRef.current;
if (!v || !edCur?.isVideo) return;
v.currentTime = edClipMs / 1000;
if (edPlaying) v.play().catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edIdx]);
// 键盘:仅 stage5 生效(空格播放/暂停,←/→ 逐帧)
useEffect(() => {
if (viewStage !== 5) return;
function onKey(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.code === "Space") { e.preventDefault(); togglePlay(); }
else if (e.code === "ArrowLeft") { e.preventDefault(); stepFrame(-1); }
else if (e.code === "ArrowRight") { e.preventDefault(); stepFrame(1); }
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [viewStage, togglePlay, stepFrame]);
// Stage 4:有运行中的视频段时静默轮询推进(本机无 Celery worker,前端驱动 poll-video-segment),进度条/缩略图实时刷新
const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length;
useEffect(() => {
if (viewStage !== 4 || activeVideoCount === 0) return;
const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000);
return () => window.clearInterval(timer);
}, [viewStage, activeVideoCount, onPollVideosQuiet]);
// Stage 5:进入拼接页时回填已有导出成片(若此前导过)
useEffect(() => {
if (viewStage !== 5) return;
onRefreshExport();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewStage]);
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
async function confirmScript() {
if (currentScript && !scriptAdopted) {
await onAdoptScript(currentScript.id);
}
goStage(2);
}
// ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
const [edZoom, setEdZoom] = useState(100);
useEffect(() => {
if (viewStage !== 5) { edHydratedRef.current = false; return; }
if (edHydratedRef.current) return;
const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset);
if (!hasData) return;
edHydratedRef.current = true;
setEdState(buildInitialEditor());
setEdHistory([]); setEdFuture([]); setSelectedClip(0);
}, [viewStage, project, segments, buildInitialEditor]);
const edUndo = useCallback(() => {
setEdHistory((h) => {
if (!h.length) return h;
setEdFuture((f) => [edState, ...f].slice(0, 50));
setEdState(h[h.length - 1]);
return h.slice(0, -1);
});
}, [edState]);
const edRedo = useCallback(() => {
setEdFuture((f) => {
if (!f.length) return f;
setEdHistory((h) => [...h, edState].slice(-50));
setEdState(f[0]);
return f.slice(1);
});
}, [edState]);
function edDeleteClip(idx: number) {
if (idx < 0 || idx >= edState.clips.length || edState.clips.length <= 1) return;
commitEdit({ ...edState, clips: edState.clips.filter((_, i) => i !== idx) });
setSelectedClip((s) => Math.max(0, Math.min(s, edState.clips.length - 2)));
}
function edCopyClip(idx: number) {
const c = edState.clips[idx];
if (!c) return;
const dup = { ...c, key: `${c.key}-copy-${Date.now()}` };
commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx + 1), dup, ...edState.clips.slice(idx + 1)] });
}
function edSplitAtPlayhead() {
const idx = edIdx;
const c = edState.clips[idx];
if (!c || c.durMs <= 400) return;
const offset = Math.max(200, Math.min(c.durMs - 200, edClipMs));
const a: EdClipState = { ...c, key: `${c.key}-a-${Date.now()}`, durMs: offset, trimEndMs: c.trimStartMs + offset };
const b: EdClipState = { ...c, key: `${c.key}-b-${Date.now()}`, durMs: c.durMs - offset, trimStartMs: c.trimStartMs + offset, trimEndMs: c.trimEndMs };
commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx), a, b, ...edState.clips.slice(idx + 1)] });
}
const buildSavePayload = useCallback((): TimelineSavePayload => {
let acc = 0;
const content = edState.clips.map((c) => { const start = acc; acc += c.durMs; return { start_ms: Math.round(start), text: c.subtitle || "" }; });
return {
clips: edState.clips.map((c) => ({ asset: c.asset, duration_ms: Math.round(c.durMs), trim_start_ms: Math.round(c.trimStartMs), trim_end_ms: c.trimEndMs == null ? null : Math.round(c.trimEndMs) })),
subtitle: { enabled: edState.subtitleEnabled, style_key: edState.subtitleStyle, content },
bgm: { volume: edState.bgmVolume },
transition: { type: edState.transition }
};
}, [edState]);
// 字幕文本编辑(打字不进 undo 栈,避免逐字刷历史)
function setClipSubtitle(idx: number, text: string) {
setEdState((s) => ({ ...s, clips: s.clips.map((c, i) => (i === idx ? { ...c, subtitle: text } : c)) }));
}
// 片段拖拽重排
const [dragIdx, setDragIdx] = useState<number | null>(null);
function reorderClip(from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= edState.clips.length || to >= edState.clips.length) return;
const clips = [...edState.clips];
const [moved] = clips.splice(from, 1);
clips.splice(to, 0, moved);
commitEdit({ ...edState, clips });
setSelectedClip(to);
}
// 转场实时预览:切片段时若有转场,画面做一次淡场(导出才是真 xfade,这里给即时视觉提示)
const [fadeKey, setFadeKey] = useState(0);
const prevEdIdxRef = useRef(edIdx);
useEffect(() => {
if (prevEdIdxRef.current !== edIdx) {
prevEdIdxRef.current = edIdx;
if (edState.transition !== "none") setFadeKey((k) => k + 1);
}
}, [edIdx, edState.transition]);
// Stage 4 / 5 文件上传
function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file);
setUploadTargetSeg(null);
}
function triggerVideoUpload(segmentId: string) {
setUploadTargetSeg(segmentId);
videoUploadRef.current?.click();
}
function onPickBgmFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (file) onUploadBgm(file, edState.bgmVolume);
}
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
const productRecord = products.find((item) => item.id === project.product);
const productName = productRecord?.title || "透真补水面膜";
@ -198,7 +538,9 @@ export function PipelinePage(props: {
<div className="pane-h">
<div className="shot-headline">
<strong></strong>
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>· · </span>
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>
{shots.length ? `· ${shots.length} 镜 · ${scriptAdopted ? "已采用" : "待采用"}` : "· 空 · 待生成"}
</span>
</div>
<div className="script-brief-summary" aria-label="当前创作方向">
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source"></span></span>
@ -208,22 +550,32 @@ export function PipelinePage(props: {
<div className="script-tags" id="script-tags">
<div className="tag-group" data-kind="char">
<span className="tg-lbl">// 人物</span>
<button className="tag-add" type="button" aria-label="添加人物">+</button>
<button className="tag-add" type="button" aria-label="添加人物" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个人物角色:"); }}>+</button>
</div>
<div className="tag-group" data-kind="scene">
<span className="tg-lbl">// 场景</span>
<button className="tag-add" type="button" aria-label="添加场景">+</button>
<button className="tag-add" type="button" aria-label="添加场景" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个场景:"); }}>+</button>
</div>
</div>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn"> </button>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放")}> </button>
</div>
<div className="shots-body" id="shots-body">
<div className="shots-empty">
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
<div className="empty-title"></div>
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
</div>
{shots.length ? shots.map((shot) => (
<div className="shot-card" key={shot.id}>
<div className="shot-n">{shot.sort_order + 1}</div>
<div className="shot-main">
<div className="shot-meta">// 场 {shot.sort_order + 1} · {shot.duration_seconds || 15}s</div>
<div className="shot-narration">{shot.narration?.trim() || "(本镜暂无文案,可在右侧重写脚本)"}</div>
</div>
</div>
)) : (
<div className="shots-empty">
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
<div className="empty-title"></div>
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
</div>
)}
</div>
</div>
@ -235,29 +587,38 @@ export function PipelinePage(props: {
<strong></strong>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>· GPT-4o</span>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn"></button>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0} onClick={clearChat}></button>
</div>
<div className="chat-body" id="chat-body">
<div className="chat-empty">
<div className="ce-title"></div>
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
<div className="chat-modes">
<button className="chat-mode primary" type="button" data-mode="ai" disabled={loading} onClick={() => onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI </button>
<button className="chat-mode" type="button" data-mode="theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg></button>
<button className="chat-mode" type="button" data-mode="manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg></button>
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => { setChatMode("ai"); onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放"); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI </button>
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={focusThemeMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg></button>
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={pickScriptMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg></button>
</div>
</div>
</div>
<div className="chat-input">
<div className="chat-input-card">
<div className="chat-attach-row" id="chat-attach-row" hidden></div>
<textarea className="chat-input-area" id="chat-textarea" placeholder="直接说怎么改,如:更像小红书种草 / 换成熬夜党" rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
<div className="chat-attach-row" id="chat-attach-row" hidden={chatAttachments.length === 0}>
{chatAttachments.map((att, index) => (
<span className="chip" key={`${att.name}-${index}`} style={{ marginRight: 6 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
{att.name} · {att.chars}
<button type="button" aria-label="移除附件" style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => setChatAttachments((list) => list.filter((_, i) => i !== index))}>×</button>
</span>
))}
</div>
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
<div className="chat-input-foot">
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件">
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); setChatText(""); }}>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); clearChat(); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
@ -270,7 +631,7 @@ export function PipelinePage(props: {
<div className="info"><span className="mono">[ LLM ~2.4k tokens · ¥0.04 · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(2)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
@ -278,7 +639,7 @@ export function PipelinePage(props: {
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
{viewStage === 2 && (() => {
const productGroup = groupsByKind("product")[0] || null;
const productAssetUrl = assetUrl(productGroup?.adopted_asset) || assetUrl(productGroup?.candidate_assets?.[0]) || assetUrl(productCover);
const productAssetUrl = groupMainUrl(productGroup) || assetUrl(productCover);
const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
return (
<section className="stage active" data-stage-pane="2">
@ -288,7 +649,7 @@ export function PipelinePage(props: {
const list = groupsByKind(kind);
const adopted = list.filter((g) => g.adopted_asset).length;
return (
<div className={`ttab${kind === "product" ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`}>
<div className={`ttab${kind === assetTab ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => jumpAssetSection(kind)}>
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
</div>
);
@ -338,10 +699,10 @@ export function PipelinePage(props: {
</div>
<div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
<div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
<div className={`placeholder prod-preview-img${assetUrl(productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={assetUrl(productCandidates[0]) ? mediaStyle(assetUrl(productCandidates[0])) : undefined}><span className="ph-frame"> #1</span></div>
<div className={`placeholder prod-preview-img${candUrl(productGroup, productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={candUrl(productGroup, productCandidates[0]) ? mediaStyle(candUrl(productGroup, productCandidates[0])) : undefined}><span className="ph-frame"> #1</span></div>
<div className="prod-preview-foot" id="prod-preview-foot">
{productCandidates.slice(0, 4).map((id) => (
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
<div className={`placeholder${candUrl(productGroup, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(productGroup, id) ? mediaStyle(candUrl(productGroup, id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
))}
</div>
</div>
@ -350,13 +711,23 @@ export function PipelinePage(props: {
{(["person", "scene"] as const).map((kind) => {
const list = groupsByKind(kind);
const genPrompt = kind === "person"
? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏`
: `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`;
return (
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
<div className="sec-h"><h3>{KIND_LABEL[kind]} · {list.length} </h3><span className="spacer"></span></div>
<div className="sec-h">
<h3>{KIND_LABEL[kind]} · {list.length} </h3>
<span className="spacer"></span>
<button className="btn-aigen" type="button" data-stop disabled={loading} onClick={() => onGenerateBaseAsset(kind, genPrompt)}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
AI {KIND_LABEL[kind]}
</button>
</div>
{list.length ? (
<div className="asset-grid-2">
{list.map((group, gi) => {
const mainUrl = assetUrl(group.adopted_asset) || assetUrl(group.candidate_assets?.[0]);
const mainUrl = groupMainUrl(group);
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
@ -375,7 +746,7 @@ export function PipelinePage(props: {
{cands.length > 0 && (
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
{cands.map((id) => (
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
<div className={`placeholder${candUrl(group, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(group, id) ? mediaStyle(candUrl(group, id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
))}
</div>
)}
@ -385,7 +756,9 @@ export function PipelinePage(props: {
})}
</div>
) : (
<div className="placeholder" style={{ minHeight: "120px" }}><span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 待生成</span></div>
<div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「AI 生成{KIND_LABEL[kind]}」生成</span>
</div>
)}
</section>
);
@ -410,7 +783,7 @@ export function PipelinePage(props: {
<div className="sb-canvas">
<div className="sb-scenes-col" id="sb-scenes-row">
{sbFrames.length ? sbFrames.map((frame, idx) => {
const url = assetUrl(frame.asset);
const url = frameUrl(frame);
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame"> {idx + 1}</span></div>
@ -421,7 +794,7 @@ export function PipelinePage(props: {
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div>
{(() => {
const url = assetUrl(sbActiveFrame?.asset);
const url = frameUrl(sbActiveFrame);
return (
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame">{sbActiveFrame ? `${sbSelected + 1}` : "// 故事板未生成"}</span>
@ -451,7 +824,7 @@ export function PipelinePage(props: {
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
{adoptedStoryboard ? "整张重跑" : "生成故事板"}
</button>
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/</span>
@ -460,7 +833,7 @@ export function PipelinePage(props: {
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
<div className="sb-history-row" id="sb-history-row">
{storyboards.length ? storyboards.map((ver) => {
const cover = assetUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]?.asset);
const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]);
return (
<div className={`sb-history-thumb${ver.is_adopted ? " current" : ""}`} key={ver.id} data-vi={ver.id}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
@ -493,31 +866,43 @@ export function PipelinePage(props: {
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
{viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
const statusText = !segments.length
? "暂无片段"
: segDone === segments.length
? "已完成所有场次"
: activeVideoCount > 0
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
: "待生成";
return (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
<div>
<div style={{ fontSize: "14px", fontWeight: 600 }}> · {segDone} / {segments.length} </div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {segments.length ? (segDone === segments.length ? "已完成所有场次" : "生成中") : "暂无片段"}</div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {statusText}</div>
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => onSubmitAllVideos(videoPrompt)}> </button>
<button className="btn btn-sm" type="button">
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => onSubmitAllVideos(videoPrompt)}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
</button>
</div>
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
{segments.length ? (
<div className="video-grid" id="video-grid">
{segments.map((seg) => {
const url = assetUrl(seg.adopted_asset);
const url = segUrl(seg);
const tone = statusPill(seg.status);
const busy = ["running", "queued"].includes(seg.status);
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className={`placeholder video-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame"> {seg.sort_order + 1}</span>
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }}>
{url
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
: <span className="ph-frame"> {seg.sort_order + 1}</span>}
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
</div>
<div className="body">
@ -527,9 +912,12 @@ export function PipelinePage(props: {
</div>
<div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`)}></button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`)}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => triggerVideoUpload(seg.id)}></button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={!url}></button>
{url
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop></a>
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled></button>}
</div>
</div>
</div>
@ -552,78 +940,201 @@ export function PipelinePage(props: {
})()}
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
{viewStage === 5 && (() => {
const previewUrl = assetUrl(tlClips[0]?.asset) || assetUrl(segments.find((s) => s.adopted_asset)?.adopted_asset);
const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.adopted_asset));
const aspect = timeline?.aspect_ratio || "9:16";
const resolution = timeline?.resolution || "1080×1920";
const bgm = bgmTracks[0] || null;
const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : "");
const showVideo = !!(edCur?.isVideo && edCur.url);
// 拼接成片:导出成功后用整片预览/下载;导出中显示进度
const finalUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
const exporting = exportResult?.status === "queued" || exportResult?.status === "running";
const exportFailed = exportResult?.status === "failed";
// ── 编辑器派生(随本地编辑实时变化的时间轴)──
const STYLE_SWATCHES = [
{ key: "plain", demo: "", nm: "朴素白底" }, { key: "cinema", demo: "b", nm: "影视黑底" },
{ key: "handwrite", demo: "c", nm: "手写描边" }, { key: "variety", demo: "d", nm: "综艺暖黄" }
];
const TRANSITIONS = [
{ key: "none", nm: "无转场" }, { key: "fade", nm: "淡入淡出" }, { key: "dissolve", nm: "溶解" },
{ key: "slideleft", nm: "左滑" }, { key: "wiperight", nm: "擦除" }
];
const edRulerMs = edTotalMs;
const edRuler = buildRuler(edRulerMs / 1000);
const edOffsets = edClips.map((_, i) => edClips.slice(0, i).reduce((sum, c) => sum + c.durMs, 0));
const serverBgm = (project.timeline?.bgm_tracks ?? [])[0] || null;
const serverBgmUrl = serverBgm?.asset_url || "";
const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : "");
const subVisible = edState.subtitleEnabled;
return (
<section className="stage active" data-stage-pane="5">
<div className="editor">
<div className="editor-preview">
<div className={`canvas${previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={previewUrl ? mediaStyle(previewUrl) : undefined}><span id="ed-canvas-label">{aspect} · {resolution}</span></div>
<div className={`canvas${!showVideo && !finalUrl && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !finalUrl && previewUrl ? mediaStyle(previewUrl) : undefined}>
{finalUrl ? (
<>
<video
src={finalUrl}
controls
playsInline
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", background: "#000", borderRadius: "inherit" }}
/>
<span className="pill ok" style={{ position: "absolute", top: 10, left: 10, zIndex: 4 }}><span className="dot"></span></span>
</>
) : showVideo ? (
<video
ref={videoRef}
src={edCur!.url}
playsInline
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }}
onTimeUpdate={(e) => setEdClipMs(e.currentTarget.currentTime * 1000)}
onEnded={() => { if (edIdx + 1 < edClips.length) gotoClip(edIdx + 1, false); else setEdPlaying(false); }}
onPlay={() => setEdPlaying(true)}
onPause={() => setEdPlaying(false)}
/>
) : (
<span id="ed-canvas-label">{exporting ? `拼接中… ${exportResult?.progress ?? 0}%` : `${aspect} 预览 · ${resolution}${edClips.length ? ` · 片段 ${Math.min(edIdx + 1, edClips.length)}/${edClips.length}` : ""}`}</span>
)}
{showVideo && !finalUrl && edState.transition !== "none" && <div key={fadeKey} className="ed-xfade-flash" aria-hidden="true" />}
</div>
<div className="controls">
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">0:00</span> / <span id="ed-total-time">{fmtMs(tlRulerMs)}</span></span>
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)" onClick={() => stepFrame(-1)} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)" onClick={togglePlay} disabled={edClips.length === 0}>
{edPlaying
? <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor" /></svg>
: <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg>}
</button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)" onClick={() => stepFrame(1)} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">{fmtMs(edGlobalMs)}</span> / <span id="ed-total-time">{fmtMs(edTotalMs)}</span></span>
</div>
</div>
<div className="editor-props">
<div className="props-tabs"><div className="active"></div><div></div><div>BGM</div></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式</div>
<div className="style-swatch">
<div className="swatch-card selected"><div className="demo"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo b"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo c"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo d"></div><div className="nm"></div></div>
<div className="props-tabs">
<div className={propsTab === "subtitle" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("subtitle")}></div>
<div className={propsTab === "transition" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("transition")}></div>
<div className={propsTab === "bgm" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("bgm")}>BGM</div>
</div>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-start" defaultValue={fmtMs(tlRulerMs)} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-dur" defaultValue={`${tlClips.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue={`${subtitleCues.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
{bgm && (
{propsTab === "subtitle" && (
<>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>{bgmName} · {bgm.volume}</span><button className="btn btn-ghost btn-sm" type="button"></button></div>
<div className="props-row" style={{ marginBottom: 8 }}>
<span className="k"></span>
<button className={`btn btn-sm ${edState.subtitleEnabled ? "btn-primary" : "btn-ghost"}`} type="button" onClick={() => commitEdit({ ...edState, subtitleEnabled: !edState.subtitleEnabled })}>{edState.subtitleEnabled ? "已开启" : "已关闭"}</button>
</div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式(导出烧入)</div>
<div className="style-swatch">
{STYLE_SWATCHES.map((sw) => (
<div className={`swatch-card${edState.subtitleStyle === sw.key ? " selected" : ""}`} key={sw.key} role="button" tabIndex={0} style={{ cursor: "pointer", opacity: edState.subtitleEnabled ? 1 : 0.5 }} onClick={() => commitEdit({ ...edState, subtitleStyle: sw.key, subtitleEnabled: true })}>
<div className={`demo${sw.demo ? ` ${sw.demo}` : ""}`}></div><div className="nm">{sw.nm}</div>
</div>
))}
</div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, margin: "12px 0 6px", letterSpacing: ".04em" }}>// 字幕文本(默认取脚本旁白,可逐段改)</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", maxHeight: "186px", overflowY: "auto" }}>
{edState.clips.map((c, idx) => (
<div key={c.key} style={{ display: "flex", gap: "6px", alignItems: "flex-start" }}>
<span className="mono" style={{ fontSize: "10px", color: "var(--black-alpha-48)", marginTop: "7px", flex: "0 0 auto" }}>{idx + 1}</span>
<textarea value={c.subtitle} onChange={(e) => setClipSubtitle(idx, e.target.value)} rows={1} disabled={!edState.subtitleEnabled} placeholder={`${idx + 1} 段字幕`} style={{ flex: 1, minWidth: 0, resize: "vertical", fontSize: "12px", lineHeight: 1.4, padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)", fontFamily: "inherit" }} />
</div>
))}
</div>
</>
)}
{propsTab === "transition" && (
<>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 片段间转场(导出 xfade 烧入)</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{TRANSITIONS.map((tr) => (
<button className={`btn btn-sm ${edState.transition === tr.key ? "btn-primary" : "btn-ghost"}`} key={tr.key} type="button" style={{ justifyContent: "flex-start" }} onClick={() => commitEdit({ ...edState, transition: tr.key })}>{tr.nm}</button>
))}
</div>
</>
)}
{propsTab === "bgm" && (
<>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 背景音乐(导出混音)</div>
<div className="props-row"><span style={{ fontSize: "12px", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{serverBgm ? serverBgmName : "未设置 BGM"}</span></div>
{serverBgmUrl && <audio src={serverBgmUrl} controls style={{ width: "100%", height: 30, marginBottom: 8 }} />}
<div className="props-row"><span className="k"> {edState.bgmVolume}</span>
<input type="range" min={0} max={100} value={edState.bgmVolume} onChange={(e) => setEdState((s) => ({ ...s, bgmVolume: Number(e.target.value) }))} style={{ flex: 1 }} />
</div>
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => bgmFileRef.current?.click()} style={{ marginTop: 6 }}>{serverBgm ? "替换 BGM" : "上传 BGM"}</button>
<input ref={bgmFileRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={onPickBgmFile} />
</>
)}
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={fmtMs(edRulerMs)} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={`${edClips.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={subVisible ? `${edClips.length}` : "关"} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={(TRANSITIONS.find((t) => t.key === edState.transition) || TRANSITIONS[0]).nm} readOnly /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
</div>
<div className="timeline" id="ed-timeline">
<div className="timeline" id="ed-timeline" style={{ overflowX: edZoom > 100 ? "auto" : "hidden" }}>
<div className="tl-toolbar">
<button className="tl-action" type="button" title="撤销"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
<button className="tl-action" type="button" title="重做"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
<button className="tl-action" type="button" title="撤销" disabled={!edHistory.length} onClick={edUndo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
<button className="tl-action" type="button" title="重做" disabled={!edFuture.length} onClick={edRedo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
<span className="tl-sep"></span>
<button className="tl-action" type="button" title="在播放头处分割选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg></button>
<button className="tl-action" type="button" title="复制选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg></button>
<button className="tl-action danger" type="button" title="删除选中片段 (Delete)"><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 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg></button>
<button className="tl-action" type="button" title="在播放头处分割所在片段" disabled={!edClips.length} onClick={edSplitAtPlayhead}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg></button>
<button className="tl-action" type="button" title="复制选中片段" disabled={!edClips.length} onClick={() => edCopyClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg></button>
<button className="tl-action danger" type="button" title="删除选中片段" disabled={edClips.length <= 1} onClick={() => edDeleteClip(selectedClip)}><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 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg></button>
<span className="spacer"></span>
<div className="tl-zoom"><span className="lbl">// zoom</span><input type="range" min={50} max={200} defaultValue={100} /></div>
<div className="tl-zoom"><span className="lbl">// zoom {edZoom}%</span><input type="range" min={100} max={300} value={edZoom} onChange={(e) => setEdZoom(Number(e.target.value))} /></div>
</div>
<div style={{ width: `${edZoom}%`, minWidth: "100%" }}>
<div className="tl-ruler">
<div className="l">// time</div>
<div className="rule-track" id="ed-ruler">
{ruler.map((tick, i) => (
<div
className="rule-track"
id="ed-ruler"
style={{ cursor: edClips.length ? "pointer" : "default" }}
onClick={(event) => {
if (!edClips.length) return;
const rect = event.currentTarget.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
seekToMs(frac * edRulerMs);
}}
>
{edRuler.map((tick, i) => (
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
))}
{edClips.length > 0 && (
<span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 5, pointerEvents: "none" }} />
)}
</div>
</div>
<div className="tl-track video-track">
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span></div>
<div className="lane" id="ed-lane-video" data-track="video">
{tlClips.length ? tlClips.map((clip, idx) => {
const { leftPct, widthPct } = clipLayout(clip.start_ms, clip.duration_ms, tlRulerMs);
const lbl = assetName(clip.asset) || `片段 ${idx + 1}`;
const frameCount = Math.max(1, Math.round(clip.duration_ms / 1000));
{edClips.length > 0 && (
<span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 6, pointerEvents: "none" }} />
)}
{edClips.length ? edClips.map((c, idx) => {
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
const lbl = assetName(c.assetId) || `片段 ${idx + 1}`;
const frameCount = Math.max(1, Math.round(c.durMs / 1000));
return (
<div className="clip video" key={clip.id} data-track="video" data-label={lbl} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
<div
className="clip video"
key={c.id}
data-track="video"
data-label={lbl}
draggable
onDragStart={() => setDragIdx(idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); if (dragIdx != null) reorderClip(dragIdx, idx); setDragIdx(null); }}
onDragEnd={() => setDragIdx(null)}
title="拖拽可重排片段"
style={{ left: `${leftPct}%`, width: `${widthPct}%`, cursor: "grab", opacity: dragIdx === idx ? 0.4 : 1, outline: idx === selectedClip ? "2px solid var(--heat)" : undefined, outlineOffset: "-2px" }}
onClick={() => { setSelectedClip(idx); gotoClip(idx, false); }}
>
<span className="frames">{Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
<span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
</div>
@ -635,44 +1146,52 @@ export function PipelinePage(props: {
<div className="tl-track subtitle-track">
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span></div>
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
{subtitleCues.map((cue, i) => {
const next = subtitleCues[i + 1];
const endMs = next ? next.start_ms : tlRulerMs;
const { leftPct, widthPct } = clipLayout(cue.start_ms, Math.max(0, endMs - cue.start_ms), tlRulerMs);
{subVisible && edState.clips.map((c, idx) => {
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
const text = c.subtitle || `字幕 ${idx + 1}`;
return (
<div className="clip subtitle" key={i} data-track="subtitle" data-label={cue.text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{cue.text}</span></div>
<div className="clip subtitle" key={c.key} data-track="subtitle" data-label={text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{text}</span></div>
);
})}
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
<div className="playhead" id="ed-playhead" style={{ left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%` }}><span className="ph-grab"></span></div>
</div>
</div>
{bgmTracks.length > 0 && (
{serverBgm && (
<div className="tl-track bgm-track">
<div className="label bgm"><span className="ico"><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>BGM</div>
<div className="lane">
{bgmTracks.map((track) => {
const { leftPct, widthPct } = clipLayout(track.start_ms, Math.max(0, tlRulerMs - track.start_ms), tlRulerMs);
const name = assetName(track.asset) || "背景音乐";
return (
<div className="clip bgm" key={track.id} data-track="bgm" data-label={name} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl">{name} · {track.volume}</span>
</div>
);
})}
<div className="clip bgm" data-track="bgm" data-label={serverBgmName} style={{ left: "0%", width: "100%" }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl">{serverBgmName} · {edState.bgmVolume}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ {fmtMs(tlRulerMs)} · {tlClips.length} · / 0 token ]</span></div>
<div className="info">
<span className="mono">[ {fmtMs(edRulerMs)} · {edClips.length} · / 0 token ]</span>
{exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 拼接中 {exportResult?.progress ?? 0}%</span>}
{finalUrl && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 成片已就绪</span>}
{exportFailed && <span className="mono" style={{ marginLeft: 10, color: "var(--err, #d33)" }}>// 导出失败:{exportResult?.error_message || "请重试"}</span>}
{!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>// 待全部视频片段生成完成后可导出</span>}
</div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn" type="button">稿</button>
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport} onClick={onSubmitExport}> MP4 · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg></button>
<button className="btn" type="button" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>稿</button>
{finalUrl && (
<a className="btn" href={finalUrl} target="_blank" rel="noreferrer" download>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</a>
)}
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport || loading || exporting} onClick={() => onSubmitExport(buildSavePayload())}>
{exporting ? "拼接中…" : finalUrl ? "重新导出" : "导出 MP4"} · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</button>
</div>
</div>
</section>

View File

@ -1,11 +1,14 @@
import { useState } from "react";
import type { CSSProperties, FormEvent, KeyboardEvent } from "react";
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { ArrowLeft } from "lucide-react";
import { ConfirmModal } from "../components/overlays";
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import { Drawer } from "../components/overlays";
import "../product-create-page.css";
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
type ProductPayload = {
title?: string;
brand?: string;
@ -31,34 +34,117 @@ function productCover(name: string): string {
}
const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
export function ProductsPage({ products, navigate, openProduct, onCreate }: {
export function ProductsPage({ products, projects = [], navigate, openProduct, onCreate, onDelete, autoOpenCreate = false }: {
products: Product[];
projects?: Project[];
navigate: (page: Page) => void;
openProduct: (productId: string) => void;
onCreate: (payload: ProductPayload) => Promise<unknown> | void;
onDelete?: (id: string) => Promise<unknown> | void;
autoOpenCreate?: boolean;
}) {
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
// 管理(编辑)模式:开关切 body.edit-mode → 卡片可多选 + 批量删除;单卡删除走 card-del-btn
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
const doDelete = async () => {
const ids = confirmIds || [];
setConfirmIds(null);
for (const id of ids) await onDelete?.(id);
setSelected(new Set());
};
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
const [title, setTitle] = useState("");
const [brand, setBrand] = useState("");
const [point, setPoint] = useState("");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
const [bullets, setBullets] = useState<string[]>([]);
const [bulletDraft, setBulletDraft] = useState("");
// 新建抽屉:主图选择(选图即预览,创建商品后到详情页上传)+ 使用指南面板
const [imagePreview, setImagePreview] = useState<string>("");
const imgInputRef = useRef<HTMLInputElement | null>(null);
const [showGuide, setShowGuide] = useState(false);
function pickImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) setImagePreview(URL.createObjectURL(file));
event.target.value = "";
}
const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
const [catFilter, setCatFilter] = useState("");
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
const filtered = products.filter((product) => `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()));
// 筛选选项:商品分类来自真实 products,创建时间为固定区间
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
const DATE_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 dateLabel = DATE_OPTS.find((o) => o.value === dateFilter)?.label || "创建时间";
function submit(event: FormEvent) {
// /products/new 进入时自动打开新建商品 drawer
useEffect(() => {
if (autoOpenCreate) setDrawer(true);
}, [autoOpenCreate]);
// 点击 chip 外部关闭下拉
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const filtered = products.filter((product) => {
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase());
const matchCat = !catFilter || product.category === catFilter;
let matchDate = true;
if (dateFilter !== "all" && product.created_at) {
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000;
matchDate = days <= Number(dateFilter);
}
return matchQuery && matchCat && matchDate;
});
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return;
event.preventDefault();
const value = bulletDraft.trim();
if (!value) return;
setBullets((list) => [...list, value]);
setBulletDraft("");
}
function removeBullet(index: number) {
setBullets((list) => list.filter((_, position) => position !== index));
}
function submit() {
if (!title.trim()) return;
onCreate({
title,
brand,
category: "电商商品",
target_audience: "泛人群",
description: point,
selling_points: [{ title: point || "核心卖点", detail: point || "待补充", sort_order: 0 }]
title: title.trim(),
category: category || PC_CAT_OPTIONS[0],
target_audience: target,
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
});
setDrawer(false);
setTitle("");
setBrand("");
setPoint("");
setCategory("");
setTarget("");
setBullets([]);
setBulletDraft("");
}
return (
@ -69,9 +155,9 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<div className="sub"><span className="mono">// <span id="sku-count">{products.length}</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
</div>
<div className="actions">
<button className="btn btn-edit-toggle" type="button" id="edit-toggle-btn">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="edit-toggle-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="btn-edit-label"></span>
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
</button>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
@ -86,8 +172,34 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="chip-wrap" data-key="cat"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="date"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
<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>
{categories.length > 0 && <div className="mi-sep" />}
{categories.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={`chip-wrap${openChip === "date" ? " open" : ""}`} data-key="date">
<button className={`chip${dateFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "date" ? "" : "date"))}>
<span className="chip-label">{dateFilter === "all" ? "创建时间" : dateLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{DATE_OPTS.map((opt) => (
<div className={`mi${dateFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
</div>
<div className="result-meta" id="result-meta">
@ -96,30 +208,159 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{filtered.map((product) => <ProductCard key={product.id} product={product} onOpen={() => openProduct(product.id)} />)}
{filtered.map((product) => (
<ProductCard
key={product.id}
product={product}
videoCount={projects.filter((p) => p.product === product.id).length}
editMode={editMode}
selected={selected.has(product.id)}
onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))}
onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined}
/>
))}
</div>
</div>
</div>
<Drawer title="新建商品" open={drawer} close={() => setDrawer(false)}>
<form onSubmit={submit}>
<div className="field"><label className="field-label"><span className="req">*</span></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} required /></div>
<div className="field"><label className="field-label"></label><input className="input" value={brand} onChange={(event) => setBrand(event.target.value)} /></div>
<div className="field"><label className="field-label"><span className="req">*</span></label><textarea className="textarea" value={point} onChange={(event) => setPoint(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"></button></div>
</form>
</Drawer>
{/* 编辑模式浮动操作条 */}
<div className="bulk-bar" role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}></button>
<span className="sep" />
<button
className="danger"
type="button"
disabled={selected.size === 0}
onClick={() => selected.size && setConfirmIds(Array.from(selected))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
<button type="button" onClick={exitEdit}>退</button>
</div>
<ConfirmModal
open={Boolean(confirmIds && confirmIds.length)}
title="删除商品"
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?该操作不可撤销,商品的图册与关联记录也会一并移除。`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}
/>
{/* 新建商品 · 右侧 Drawer · 在商品库页面原地打开(转写自 products.html #pc-drawer) */}
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
<div className="drawer-h">
<h3></h3>
<button className="x" type="button" onClick={() => setDrawer(false)} aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="drawer-b">
<div className="form-card">
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="请输入商品名称(必填)" maxLength={100} />
</div>
<div className="field-row">
<div>
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
<div>
<label className="field-label"><span className="opt">()</span></label>
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
</div>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="pf-upload-row">
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
{imagePreview ? (
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
) : (
<>
<div className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</div>
<div className="uz-t"><strong></strong></div>
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
</>
)}
</div>
<div className="pf-example">
<div className="ex-h"></div>
<div className="ex-grid">
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z" /><path d="M9 4v3M15 4v3M9 11h6M9 14h6" /></svg></div>
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="5" width="12" height="15" rx="2" /><path d="M9 9h6M9 12h6M9 15h4" /></svg></div>
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z" /><circle cx="12" cy="13" r="2.5" /></svg></div>
</div>
<div className="ex-d"></div>
</div>
</div>
<div className="pf-grid" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<ul className="bullet-list">
{bullets.map((bullet, index) => (
<li className="bl-item" key={`${bullet}-${index}`}>
<span className="num">{index + 1}</span>
<span className="bl-text">{bullet}</span>
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</li>
))}
<li className="bl-add">
<span className="num">+</span>
<input className="bl-input" value={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={addBullet} placeholder="添加新卖点 · 回车确认" />
</li>
</ul>
</div>
</div>
</div>
{showGuide && (
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 12.5, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
<strong>// 建好商品的 3 步</strong><br />
+ (,/)<br />
(800×800 ),便 AI <br />
24 ,
</div>
)}
<div className="drawer-f">
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
使
</button>
<button className="btn" type="button" onClick={() => setDrawer(false)}></button>
<button className="btn btn-primary" type="button" onClick={submit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
</button>
</div>
</aside>
</section>
);
}
export function ProductCard({ product, onOpen }: { product: Product; onOpen: () => void }) {
export function ProductCard({ product, videoCount = 0, onOpen, editMode = false, selected = false, onDelete }: { product: Product; videoCount?: number; onOpen: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) {
const cover = productCover(product.title);
const assetCount = product.images?.length || 0;
return (
<div className="product-card" data-cat={product.category} data-name={product.title} role="button" tabIndex={0} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => event.stopPropagation()}><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>
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete?.(); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
<div className={`placeholder product-thumb${cover ? " has-mock-media" : ""}`} style={cover ? prodMock(cover) : undefined}><span className="ph-frame">{product.title} · 1200×800</span></div>
<div className="product-body">
<div className="product-name">{product.title}</div>
@ -134,16 +375,13 @@ export function ProductCard({ product, onOpen }: { product: Product; onOpen: ()
<span className="sep">·</span>
<span className="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg>
<b>0</b>
<b>{videoCount}</b>
</span>
</div>
</div>
);
}
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (payload: ProductPayload) => Promise<unknown> | void; onBack: () => void }) {
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
@ -328,16 +566,70 @@ function pdProjStatusLabel(project: Project) {
}
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
export function ProductDetailPage({ product, projects, assets, navigate, onUpdate }: {
export function ProductDetailPage({ product, projects, assets, navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages }: {
product: Product;
projects: Project[];
assets: Asset[];
navigate: (page: Page) => void;
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
onUploadImage?: (formData: FormData) => Promise<unknown> | void;
onDeleteImage?: (imageId: string) => Promise<unknown> | void;
onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
}) {
const [tab, setTab] = useState<"assets" | "videos">("assets");
const [editing, setEditing] = useState(false);
const [triOpen, setTriOpen] = useState(false);
const imgInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 三视图生成
const [triGenerating, setTriGenerating] = useState(false);
const [triUrl, setTriUrl] = useState<string>("");
// 素材 tab 筛选 / 排序 / 分页
const [openFilter, setOpenFilter] = useState<"" | "type" | "sort">("");
const [typeFilter, setTypeFilter] = useState("");
const [assetSortDesc, setAssetSortDesc] = useState(true);
const [assetLimit, setAssetLimit] = useState(12);
const [videoSortDesc, setVideoSortDesc] = useState(true);
useEffect(() => {
if (!openFilter) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenFilter("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openFilter]);
async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !onUploadImage) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("name", `${product.title || "商品"}-图${(product.images?.length || 0) + 1}`);
await onUploadImage(fd);
} finally {
setUploading(false);
}
}
async function generateTriView() {
if (!onGenerateImages || triGenerating) return;
setTriGenerating(true);
try {
const res = await onGenerateImages({
prompt: `${product.title || "商品"} 白底商品三视图(正面/侧面/背面),电商主图,干净白底,高清`,
mode: "image",
count: 1
});
const url = res?.assets?.[0]?.files?.[0]?.preview_url;
if (url) setTriUrl(url);
} finally {
setTriGenerating(false);
}
}
// 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重)
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
@ -350,13 +642,24 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
// AI 生成素材 · 团队资产中筛与该商品相关的类别(模特/场景/三视图/商品图/背景),取真图;无则回退到全部图片资产
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
const aiSource = assets.filter((asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type));
const imageAssets = (aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image"))
const allImageAssets = aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image");
// 类型筛选选项(当前素材里真实存在的 category)
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
const filteredAssets = allImageAssets
.filter((asset) => !typeFilter || asset.category === typeFilter)
.slice()
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
const assetCount = imageAssets.length;
.sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return assetSortDesc ? cmp : -cmp;
});
const assetCount = filteredAssets.length;
const imageAssets = filteredAssets.slice(0, assetLimit);
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段
const videoProjects = projects;
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
const videoProjects = [...projects].sort((a, b) => {
const cmp = (b.updated_at || "").localeCompare(a.updated_at || "");
return videoSortDesc ? cmp : -cmp;
});
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
const realName = product.title || "补水保湿精华液";
@ -405,12 +708,14 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">待生成</span></div>
<div className="placeholder prod-preview-img" id="ov-tri-img"><span className="ph-frame">// 尚未生成 · 点击下方按钮开始</span></div>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}</span></div>
<div className="placeholder prod-preview-img" id="ov-tri-img">
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
</div>
<div className="prod-preview-foot" id="ov-tri-foot">
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }}>
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }} onClick={generateTriView} disabled={triGenerating || !onGenerateImages}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
{triGenerating ? "生成中…" : "生成"}
</button>
<span style={{ flex: 1 }}></span>
<span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-56)" }}>~¥0.30 / </span>
@ -492,9 +797,14 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
</div>
))}
<div className="img-upload" id="ov-img-add" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
{uploading ? (
<span className="ph-frame" style={{ fontSize: 10 }}></span>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
)}
</div>
<input ref={imgInputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={onPickProductImage} />
</div>
</div>
@ -545,27 +855,38 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<div className="pd-toolbar">
<div className="total"> AI <span className="ct">({assetCount})</span></div>
<button className="filter" type="button" data-key="type">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<button className="filter" type="button" data-key="status">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="right">
<div className="view-tog">
<button type="button" className="active" title="网格视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
<button type="button" title="列表视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
</button>
</div>
<button className="filter" type="button" data-key="sort">
<div className={`chip-wrap${openFilter === "type" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="type">
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "type" ? "" : "type"))}>
{typeFilter ? (pdAssetTypeLabel({ category: typeFilter, asset_type: "" } as Asset) || typeFilter) : "全部类型"}
<svg 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(""); setAssetLimit(12); setOpenFilter(""); }}>
<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((cat) => (
<div className={`mi${typeFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setTypeFilter(cat); setAssetLimit(12); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{pdAssetTypeLabel({ category: cat, asset_type: "" } as Asset) || cat}
</div>
))}
</div>
</div>
<div className="right">
<div className={`chip-wrap${openFilter === "sort" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="sort">
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "sort" ? "" : "sort"))}>
{assetSortDesc ? "最新生成" : "最早生成"}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${assetSortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setAssetSortDesc(true); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className={`mi${!assetSortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setAssetSortDesc(false); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
</div>
</div>
</div>
</div>
@ -586,7 +907,9 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
})}
</div>
<div className="pd-more"><button type="button"></button></div>
{imageAssets.length < filteredAssets.length && (
<div className="pd-more"><button type="button" onClick={() => setAssetLimit((n) => n + 12)}>({filteredAssets.length - imageAssets.length})</button></div>
)}
</div>
{/* ===== 视频项目 ===== */}
@ -594,8 +917,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<div className="pd-toolbar">
<div className="total"> <span className="ct">({videoProjects.length})</span></div>
<div className="right">
<button className="filter" type="button" data-key="sort">
<button className="filter" type="button" data-key="sort" onClick={() => setVideoSortDesc((v) => !v)}>
{videoSortDesc ? "最新更新" : "最早更新"}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
</div>
@ -609,8 +932,6 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
</div>
))}
</div>
<div className="pd-more"><button type="button"></button></div>
</div>
</section>

View File

@ -32,7 +32,7 @@ const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品
export function ProjectWizardPage({ products, onBack, onCreate }: {
products: Product[];
onBack: () => void;
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | 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];
@ -122,7 +122,20 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product) return;
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 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
}
}
});
}
const productCover = (p: Product): CSSProperties | undefined => {
@ -383,8 +396,43 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
const [view, setView] = useState<"list" | "grid">("list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
const [query, setQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "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 || "创建时间";
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const counts = {
all: projects.length,
@ -394,7 +442,18 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
};
const filtered = projects.filter((project) => {
if (tab !== "all" && projBucket(project) !== tab) return false;
return `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase());
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_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;
}
return true;
});
async function confirmDelete() {
@ -411,9 +470,9 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
</div>
<div className="actions">
<button className="btn" type="button" id="proj-manage-btn">
<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"></span>
<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>
@ -433,9 +492,46 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="chip-wrap" data-key="product"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="source"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="time"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<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={`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>
<span className="spacer"></span>
<div className="view-toggle">
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
@ -490,6 +586,9 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<td className="muted-2">{projDate(project)}</td>
<td>
<div className="row-action">
{editMode && (
<a href="#" className="row-del" onClick={(event) => { event.preventDefault(); event.stopPropagation(); setDeleteTarget(project); }} title="删除项目"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" width="14" height="14"><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></a>
)}
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><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>

View File

@ -11,7 +11,7 @@ import {
Upload,
User as UserIcon,
} from "lucide-react";
import type { Team, User } from "../types";
import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@ -52,11 +52,13 @@ const SUBTITLE_CHOICES = [
const DURATIONS = ["30", "45", "60"];
const DEVICES: Array<{ name: string; meta: string; current?: boolean; phone?: boolean }> = [
{ name: "MacBook Pro · Chrome", meta: "// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42", current: true },
{ name: "iPhone 15 · Safari", meta: "// 上海 · 2026-05-20 21:43", phone: true },
{ name: "Windows · Edge", meta: "// 杭州 · 2026-05-18 09:12" },
];
// 从 User-Agent 提取「系统 · 浏览器」可读名
function deviceName(ua: string): string {
if (!ua) return "未知设备";
const os = /Windows/i.test(ua) ? "Windows" : /Mac OS X|Macintosh/i.test(ua) ? "macOS" : /iPhone|iPad/i.test(ua) ? "iOS" : /Android/i.test(ua) ? "Android" : /Linux/i.test(ua) ? "Linux" : "设备";
const browser = /Edg/i.test(ua) ? "Edge" : /Chrome/i.test(ua) ? "Chrome" : /Safari/i.test(ua) ? "Safari" : /Firefox/i.test(ua) ? "Firefox" : ua.slice(0, 24);
return `${os} · ${browser}`;
}
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
@ -65,46 +67,20 @@ const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: s
{ key: "n-login", title: "异地登录告警", channels: "短信" },
];
// ─── 偏好持久化 · 后端无字段,纯本地 localStorage ───
const PREFS_KEY = "airshelf_settings_prefs";
type Prefs = {
template: string;
duration: string;
subtitle: string;
twoFactor: boolean;
notify: Record<string, boolean>;
appearance: string;
language: string;
density: string;
};
const DEFAULT_PREFS: Prefs = {
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
const DEFAULT_PREFS = {
template: "pain",
duration: "60",
subtitle: "big-variety",
bgm: "kapian",
transition: "fade",
twoFactor: false,
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true },
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true } as Record<string, boolean>,
appearance: "system",
language: "zh",
density: "standard",
};
function loadPrefs(): Prefs {
try {
const raw = localStorage.getItem(PREFS_KEY);
if (!raw) return DEFAULT_PREFS;
const parsed = JSON.parse(raw) as Partial<Prefs>;
return {
...DEFAULT_PREFS,
...parsed,
notify: { ...DEFAULT_PREFS.notify, ...(parsed.notify ?? {}) },
};
} catch {
return DEFAULT_PREFS;
}
}
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
return (
<label className="switch">
@ -118,16 +94,30 @@ export function SettingsPage({
user,
team,
initialSection = "profile",
preferences,
sessions = [],
onSavePreferences,
onRevokeSession,
onRevokeOthers,
onSaveProfile,
onChangePassword,
onUploadAvatar,
onResetAvatar,
onNotify,
}: {
user: User;
team: Team;
initialSection?: string;
preferences?: UserPreference | null;
sessions?: LoginSession[];
onSavePreferences?: (payload: Partial<UserPreference>) => void | Promise<unknown>;
onRevokeSession?: (id: string) => void | Promise<unknown>;
onRevokeOthers?: () => void | Promise<unknown>;
onSaveProfile: (payload: { name?: string; phone?: string; email?: string }) => void | Promise<unknown>;
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
onResetAvatar?: () => void | Promise<unknown>;
onNotify?: (text: string) => void;
}) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
@ -141,26 +131,42 @@ export function SettingsPage({
const [phone, setPhone] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
// 偏好 · localStorage 持久化(读 localStorage 初始化)
const initialPrefs = useMemo(() => loadPrefs(), []);
const [template, setTemplate] = useState(initialPrefs.template);
const [duration, setDuration] = useState(initialPrefs.duration);
const [subtitle, setSubtitle] = useState(initialPrefs.subtitle);
const [twoFactor, setTwoFactor] = useState(initialPrefs.twoFactor);
const [notify, setNotify] = useState<Record<string, boolean>>(initialPrefs.notify);
const [appearance, setAppearance] = useState(initialPrefs.appearance);
const [language, setLanguage] = useState(initialPrefs.language);
const [density, setDensity] = useState(initialPrefs.density);
// 偏好 · 服务端持久化(从后端 preferences 注入初值,改动即 PUT 回后端)
const [template, setTemplate] = useState(DEFAULT_PREFS.template);
const [duration, setDuration] = useState(DEFAULT_PREFS.duration);
const [subtitle, setSubtitle] = useState(DEFAULT_PREFS.subtitle);
const [bgm, setBgm] = useState(DEFAULT_PREFS.bgm);
const [transition, setTransition] = useState(DEFAULT_PREFS.transition);
const [twoFactor, setTwoFactor] = useState(DEFAULT_PREFS.twoFactor);
const [notify, setNotify] = useState<Record<string, boolean>>(DEFAULT_PREFS.notify);
const [appearance, setAppearance] = useState(DEFAULT_PREFS.appearance);
const [language, setLanguage] = useState(DEFAULT_PREFS.language);
const [density, setDensity] = useState(DEFAULT_PREFS.density);
// 偏好改动即写回 localStorage(不调后端)
// 后端 preferences 到达时注入(覆盖默认值,缺字段回退默认)
useEffect(() => {
const prefs: Prefs = { template, duration, subtitle, twoFactor, notify, appearance, language, density };
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch {
/* localStorage 不可用时静默降级 */
}
}, [template, duration, subtitle, twoFactor, notify, appearance, language, density]);
if (!preferences) return;
const cd = preferences.creation_defaults || {};
setTemplate(cd.template ?? DEFAULT_PREFS.template);
setDuration(cd.duration ?? DEFAULT_PREFS.duration);
setSubtitle(cd.subtitle ?? DEFAULT_PREFS.subtitle);
setBgm(cd.bgm ?? DEFAULT_PREFS.bgm);
setTransition(cd.transition ?? DEFAULT_PREFS.transition);
setTwoFactor(!!preferences.two_factor_enabled);
setNotify({ ...DEFAULT_PREFS.notify, ...(preferences.notify || {}) });
const dp = preferences.display || {};
setAppearance(dp.appearance ?? DEFAULT_PREFS.appearance);
setLanguage(dp.language ?? DEFAULT_PREFS.language);
setDensity(dp.density ?? DEFAULT_PREFS.density);
}, [preferences]);
// 当前 creation_defaults / display 快照(配合 [key]:value 即时持久化单字段)
function saveCreation(patch: Partial<UserPreference["creation_defaults"]>) {
onSavePreferences?.({ creation_defaults: { template, duration, subtitle, bgm, transition, ...patch } });
}
function saveDisplay(patch: Partial<UserPreference["display"]>) {
onSavePreferences?.({ display: { appearance, language, density, ...patch } });
}
// 改密 · 受控输入
const [oldPassword, setOldPassword] = useState("");
@ -313,7 +319,7 @@ export function SettingsPage({
<div className="av-big">{avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={openAvatarModal}></button>
<button className="btn btn-ghost btn-sm" type="button"></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onResetAvatar?.()}></button>
</div>
</div>
</div>
@ -326,14 +332,14 @@ export function SettingsPage({
<div className="lbl"></div>
<div className="val">
<input className="input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
<button className="btn btn-ghost btn-sm" type="button"></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onNotify?.(email ? `已向 ${email} 发送验证邮件` : "请先填写邮箱")}></button>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="138****8000" />
<button className="btn btn-ghost btn-sm" type="button"></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => { if (phone.trim()) { onSaveProfile({ phone: phone.trim() }); onNotify?.("手机号已更新"); } else { onNotify?.("请先填写新手机号"); } }}></button>
</div>
</div>
<div className="form-row">
@ -367,36 +373,43 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 推荐开启</div></div>
<div className="val">
<Switch checked={twoFactor} onChange={setTwoFactor} />
<Switch checked={twoFactor} onChange={(v) => { setTwoFactor(v); onSavePreferences?.({ two_factor_enabled: v }); }} />
<span className="switch-note"> + Authenticator</span>
</div>
</div>
<h3 className="sub-head"></h3>
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</div>
<div className="device-list">
{DEVICES.map((device) => (
<div className="device-row" key={device.name}>
<div className="ic">
{device.phone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{device.name}{device.current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">{device.meta}</div>
</div>
<div className="spacer" />
{device.current
? <span className="row-note"></span>
: <button className="btn btn-ghost btn-sm" type="button">线</button>}
</div>
))}
{sessions.length === 0 ? (
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}>// 暂无其他登录会话记录</div></div>
) : (
sessions.map((s) => {
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
return (
<div className="device-row" key={s.id}>
<div className="ic">
{isPhone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">// {s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
</div>
<div className="spacer" />
{s.is_current
? <span className="row-note"></span>
: <button className="btn btn-ghost btn-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>线</button>}
</div>
);
})
)}
</div>
<div style={{ marginTop: 14 }}>
<button className="btn" type="button">线</button>
<button className="btn" type="button" onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>线</button>
</div>
</section>
)}
@ -409,7 +422,7 @@ export function SettingsPage({
<div className="form-row" key={row.key}>
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
<div className="val">
<Switch checked={!!notify[row.key]} onChange={(next) => setNotify((prev) => ({ ...prev, [row.key]: next }))} />
<Switch checked={!!notify[row.key]} onChange={(next) => { const merged = { ...notify, [row.key]: next }; setNotify(merged); onSavePreferences?.({ notify: merged }); }} />
<span className="switch-note">{row.channels}</span>
</div>
</div>
@ -432,7 +445,7 @@ export function SettingsPage({
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setTemplate(choice.v)}
onClick={() => { setTemplate(choice.v); saveCreation({ template: choice.v }); }}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@ -451,7 +464,7 @@ export function SettingsPage({
className={`dur-chip ${duration === d ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setDuration(d)}
onClick={() => { setDuration(d); saveCreation({ duration: d }); }}
>
{d}s
</span>
@ -470,7 +483,7 @@ export function SettingsPage({
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setSubtitle(choice.v)}
onClick={() => { setSubtitle(choice.v); saveCreation({ subtitle: choice.v }); }}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@ -482,7 +495,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"> BGM </div>
<div className="val">
<select className="select" defaultValue="kapian">
<select className="select" value={bgm} onChange={(event) => { setBgm(event.target.value); saveCreation({ bgm: event.target.value }); }}>
<option value="kapian"> Top10 </option>
<option value="emotion"> · /</option>
<option value="urban"> · </option>
@ -493,7 +506,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="fade">
<select className="select" value={transition} onChange={(event) => { setTransition(event.target.value); saveCreation({ transition: event.target.value }); }}>
<option value="none"></option>
<option value="fade"> · 0.3s</option>
<option value="slide"> · 0.3s</option>
@ -520,7 +533,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={appearance} onChange={(event) => setAppearance(event.target.value)}>
<select className="select" value={appearance} onChange={(event) => { setAppearance(event.target.value); saveDisplay({ appearance: event.target.value }); }}>
<option value="system"></option>
<option value="light"></option>
<option value="dark" disabled>(V2)</option>
@ -530,7 +543,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={language} onChange={(event) => setLanguage(event.target.value)}>
<select className="select" value={language} onChange={(event) => { setLanguage(event.target.value); saveDisplay({ language: event.target.value }); }}>
<option value="zh"></option>
<option value="en" disabled>English(V2)</option>
</select>
@ -539,7 +552,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={density} onChange={(event) => setDensity(event.target.value)}>
<select className="select" value={density} onChange={(event) => { setDensity(event.target.value); saveDisplay({ density: event.target.value }); }}>
<option value="compact"></option>
<option value="standard"></option>
<option value="loose"></option>

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
import type { BillingSummary, Team, TeamMember, User } from "../types";
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";
@ -24,11 +24,12 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
];
export function TeamPage({ team, user, members, billing, navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
export function TeamPage({ team, user, members, billing, notifications = [], navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
team: Team;
user: User;
members: TeamMember[];
billing: BillingSummary | null;
notifications?: Notification[];
navigate: (page: Page) => void;
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
@ -72,6 +73,11 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 团队动态:取最近 6 条真实通知
const feedItems = [...notifications]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 6);
const needle = search.trim().toLowerCase();
const list = rows.filter((member) => {
const name = member.user.username || "";
@ -190,21 +196,37 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
</div>
</div>
{/* 团队动态(banner 右栏)· 对齐 api-bridge renderLiveTeamActivity 的真实状态占位 */}
{/* 团队动态(banner 右栏)· 接真实 ops/notifications 事件流 */}
<div className="team-feed">
<div className="h">
<h3></h3>
<span className="ct">// 真实动态接口待接入</span>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notifications.length}</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}> </a>
</div>
<div className="feed-list">
<div className="feed-item">
<div className="av">Q</div>
<div>
<div className="txt"><span className="who"></span><span className="act"></span><span className="obj">{rows.length} </span></div>
<div className="ts">local cache</div>
{feedItems.length === 0 ? (
<div className="feed-item">
<div className="av">{(team.name || "T").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt"><span className="who">{team.name}</span><span className="act"></span><span className="obj">{rows.length} </span></div>
<div className="ts">// 暂无团队动态</div>
</div>
</div>
</div>
) : (
feedItems.map((n) => (
<div className="feed-item" key={n.id}>
<div className="av">{(n.owner_label || n.source || team.name || "·").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt">
<span className="who">{n.owner_label || n.source || "系统"}</span>
<span className="act">{n.title}</span>
{n.project_name && <span className="obj">{n.project_name}</span>}
</div>
<div className="ts">{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}</div>
</div>
</div>
))
)}
</div>
</div>

View File

@ -12,7 +12,7 @@
local('AlibabaPuHuiTi-3-55-Regular'),
local('Alibaba PuHuiTi 2.0'),
local('AlibabaPuHuiTi-2-55-Regular'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-55-Regular/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -22,7 +22,7 @@
src: local('Alibaba PuHuiTi 3.0 Medium'),
local('AlibabaPuHuiTi-3-65-Medium'),
local('AlibabaPuHuiTi-2-65-Medium'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-65-Medium/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -31,7 +31,7 @@
font-display: swap;
src: local('AlibabaPuHuiTi-3-75-SemiBold'),
local('AlibabaPuHuiTi-2-75-SemiBold'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-75-SemiBold/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -41,7 +41,7 @@
src: local('Alibaba PuHuiTi 3.0 Bold'),
local('AlibabaPuHuiTi-3-85-Bold'),
local('AlibabaPuHuiTi-2-85-Bold'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-85-Bold/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -55,6 +55,7 @@ export type Asset = {
source: string;
category: string;
description: string;
metadata?: Record<string, unknown>;
files?: Array<{
id: string;
object_key: string;
@ -78,8 +79,11 @@ export type ScriptVersion = {
id: string;
title: string;
content: string;
source?: string;
is_adopted: boolean;
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string }>;
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string; visual_prompt?: string }>;
created_at?: string;
updated_at?: string;
};
export type VideoSegment = {
@ -90,31 +94,41 @@ export type VideoSegment = {
error_message: string;
adopted_version: string | null;
adopted_asset?: string | null;
adopted_asset_url?: string;
};
export type StoryboardVersion = {
id: string;
prompt: string;
is_adopted: boolean;
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string }>;
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string; asset_url?: string }>;
created_at: string;
updated_at: string;
};
export type ExportPoll = {
status: string;
progress: number;
output_asset?: string | null;
output_url: string;
error_message?: string;
};
export type Timeline = {
id: string;
name: string;
aspect_ratio: string;
resolution: string;
duration_seconds: number;
clips: Array<{ id: string; asset: string; sort_order: number; start_ms: number; duration_ms: number }>;
metadata?: { transition?: { type: string }; draft?: Record<string, unknown> };
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 }>;
style?: Record<string, unknown>;
enabled: boolean;
}>;
bgm_tracks?: Array<{ id: string; asset: string; volume: number; start_ms: number }>;
bgm_tracks?: Array<{ id: string; asset: string; asset_url?: string; asset_name?: string; volume: number; start_ms: number }>;
export_jobs?: Array<{
id: string;
status: string;
@ -126,6 +140,14 @@ 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 }> };
bgm?: { volume?: number; clear?: boolean };
transition?: { type: string };
draft?: Record<string, unknown>;
};
export type Project = {
id: string;
name: string;
@ -141,7 +163,9 @@ export type Project = {
kind: string;
prompt: string;
adopted_asset: string | null;
adopted_asset_url?: string;
candidate_assets: string[];
candidate_asset_urls?: Record<string, string>;
}>;
storyboard_versions: StoryboardVersion[];
timeline: Timeline | null;
@ -168,6 +192,32 @@ export type Ledger = {
created_at: string;
};
export type UserPreference = {
notify: Record<string, boolean>;
two_factor_enabled: boolean;
creation_defaults: { template: string; duration: string; subtitle: string; bgm: string; transition: string };
display: { appearance: string; language: string; density: string };
updated_at?: string;
};
export type LoginSession = {
id: string;
user_agent: string;
ip_address: string | null;
last_seen_at: string;
created_at: string;
is_current: boolean;
};
export type BillingTrend = {
daily: Array<{ date: string; label: string; amount: string }>;
total_14d: string;
avg: string;
peak: string;
by_stage: { script: string; base: string; storyboard: string; video: string };
by_project: Record<string, string>;
};
export type ModelConfig = {
id: string;
name: string;