feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying
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:
@@ -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; }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+145
-16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user