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
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:
parent
92826dec14
commit
0873e724bf
@ -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; }
|
||||
|
||||
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.ttf
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.ttf
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.woff2
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.woff2
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.ttf
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.ttf
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.woff2
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.woff2
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.ttf
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.ttf
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.ttf
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.ttf
Normal file
Binary file not shown.
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.woff2
Normal file
BIN
core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.woff2
Normal file
Binary file not shown.
@ -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;
|
||||
}, "成片已导出")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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/");
|
||||
},
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 />);
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
③ 写 2–4 条核心卖点,越具体生成效果越好
|
||||
</div>
|
||||
)}
|
||||
<div className="drawer-f">
|
||||
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
|
||||
使用指南
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user