diff --git a/core/frontend/public/exact/assets/restraint.css b/core/frontend/public/exact/assets/restraint.css index 617716b..7ccfc67 100644 --- a/core/frontend/public/exact/assets/restraint.css +++ b/core/frontend/public/exact/assets/restraint.css @@ -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; } diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.ttf b/core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.ttf new file mode 100644 index 0000000..a6eaf36 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.ttf differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 b/core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 new file mode 100644 index 0000000..e2b8207 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.ttf b/core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.ttf new file mode 100644 index 0000000..38ee60f Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.ttf differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 b/core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 new file mode 100644 index 0000000..cb68fd1 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.ttf b/core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.ttf new file mode 100644 index 0000000..92532c4 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.ttf differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 b/core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 new file mode 100644 index 0000000..502a648 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.ttf b/core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.ttf new file mode 100644 index 0000000..7edd4e5 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.ttf differ diff --git a/core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.woff2 b/core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.woff2 new file mode 100644 index 0000000..c1cd922 Binary files /dev/null and b/core/frontend/public/fonts/AlibabaPuHuiTi-3-85-Bold.woff2 differ diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a149ee8..c4a0e34 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -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> = { dashboard: "工作台", products: "商品库", productDetail: "商品详情", - productCreateUpload: "新建商品", + productCreateUpload: "商品库", projects: "视频项目", projectWizard: "新建视频项目", pipeline: "生产管线", @@ -76,9 +80,13 @@ export function App() { const [aiTasks, setAiTasks] = useState([]); const [billing, setBilling] = useState(null); const [ledgers, setLedgers] = useState([]); + const [billingTrend, setBillingTrend] = useState(null); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [projectDetail, setProjectDetail] = useState(null); + const [exportResult, setExportResult] = useState(null); + const [preferences, setPreferences] = useState(null); + const [sessions, setSessions] = useState([]); 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) { + 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(work: () => Promise, successText: string): Promise { + 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 ( navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} + onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} /> ); case "productCreateUpload": + // 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开 return ( - 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 navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />; + if (!activeProduct) return navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />; return ( 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() { ); case "library": - return action(() => api.uploadAsset(formData), "资产已上传")} />; + return action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; case "account": return ( 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 navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; case "modelPhotoDemoA": - return navigate("modelPhoto")} />; + return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": - return navigate("modelPhoto")} />; + return navigate("modelPhoto")} navigate={navigate} />; case "settings": - return ; + return setNotice({ type: "success", text })} />; case "settingsNotify": - return ; + return setNotice({ type: "success", text })} />; default: return ; } @@ -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; + }, "成片已导出") + } /> ); } diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 7f6fc5a..95c87e7 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -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); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 48c2b89..5df5104 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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("/api/auth/me/avatar/", { method: "POST", body: formData }); }, + resetAvatar() { + return request("/api/auth/me/avatar/", { method: "DELETE" }); + }, + deleteAsset(id: string) { + return request(`/api/assets/${id}/`, { method: "DELETE" }); + }, + preferences() { + return request("/api/auth/me/preferences/"); + }, + updatePreferences(payload: Partial) { + return request("/api/auth/me/preferences/", { method: "PUT", body: JSON.stringify(payload) }); + }, + loginSessions() { + return request("/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("/api/auth/logout/", { method: "POST" }); }, @@ -123,6 +147,12 @@ export const api = { updateProduct(id: string, payload: Partial) { return request(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); }, + uploadProductImage(productId: string, formData: FormData) { + return request(`/api/products/${productId}/images/`, { method: "POST", body: formData }); + }, + deleteProductImage(productId: string, imageId: string) { + return request(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" }); + }, deleteProduct(id: string) { return request(`/api/products/${id}/`, { method: "DELETE" }); }, @@ -132,7 +162,7 @@ export const api = { project(id: string) { return request(`/api/projects/${id}/`); }, - createProject(payload: { name: string; product: string }) { + createProject(payload: { name: string; product: string; metadata?: Record }) { return request("/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(`/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(`/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(`/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(`/api/projects/${projectId}/upload-bgm/`, { method: "POST", body: form }); + }, + saveTimeline(projectId: string, payload: import("./types").TimelineSavePayload) { + return request(`/api/projects/${projectId}/save-timeline/`, { method: "POST", body: JSON.stringify(payload) }); + }, assets() { return request>("/api/assets/"); }, @@ -186,6 +240,9 @@ export const api = { ledgers() { return request("/api/billing/ledgers/"); }, + billingTrend(range?: "day" | "week" | "month") { + return request(`/api/billing/trend/${range ? `?range=${range}` : ""}`); + }, modelConfigs() { return request>("/api/ai/models/"); }, diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 536154d..7c68b01 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -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( +
{ if (event.target === event.currentTarget) onClose(); }} + > +
+
+ + 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]); } + }} + /> + {items.length} 项 + +
+
+ {items.length === 0 && ( +
+ + 没有匹配的入口 + // 换个关键词试试 +
+ )} + {items.map((cmd, i) => { + const section = cmd.group !== lastGroup ?
{cmd.group}
: null; + lastGroup = cmd.group; + return ( +
+ {section} + +
+ ); + })} +
+
+
, + 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> = { 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 ( + <> + setPaletteOpen(false)} navigate={navigate} /> + ); } diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index fccb2e5..426a396 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -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; } diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index cdf2225..144cdd4 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -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; } diff --git a/core/frontend/src/main.tsx b/core/frontend/src/main.tsx index d89a9f7..02bc2d3 100644 --- a/core/frontend/src/main.tsx +++ b/core/frontend/src/main.tsx @@ -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(); diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 1b0ce45..ace3275 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -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); } diff --git a/core/frontend/src/product-create-page.css b/core/frontend/src/product-create-page.css index 3b01145..2604649 100644 --- a/core/frontend/src/product-create-page.css +++ b/core/frontend/src/product-create-page.css @@ -265,3 +265,172 @@ @media (max-width: 1100px) { .product-create-page .form-grid { grid-template-columns: 1fr; } } + +/* ============================================================ + 新建商品 · 右侧 Drawer(在商品库页面原地打开) + 像素基线: public/exact/products.html #pc-drawer + 其内联