diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index c3d855c..c3ca83f 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -236,6 +236,28 @@ export function App() { await reloadNotifications(); } + async function saveProfile(payload: { name?: string; phone?: string; email?: string }) { + const res = await action(() => api.updateProfile(payload), "资料已保存"); + if (res) { + setUser(res.user); + setTeam(res.team); + } + } + + async function changeOwnPassword(payload: { old_password: string; new_password: string }) { + const res = await action(() => api.changePassword(payload), "密码已修改"); + if (res?.token) setToken(res.token); + } + + async function uploadOwnAvatar(formData: FormData) { + const res = await action(() => api.uploadAvatar(formData), "头像已更新"); + if (res) setUser(res); + } + + function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) { + return action(() => api.generateImage(payload), "图片已生成"); + } + function onAuthed(payload: { token: string; user: User; team: Team }) { setToken(payload.token); setUser(payload.user); @@ -321,6 +343,7 @@ export function App() { project.product === activeProduct.id)} + assets={assets} navigate={navigate} onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")} /> @@ -404,19 +427,19 @@ export function App() { case "assetFactory": return ; case "imageOptimize": - return navigate("assetFactory")} navigate={navigate} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; case "modelPhoto": - return navigate("assetFactory")} navigate={navigate} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; case "platformCover": - return navigate("assetFactory")} navigate={navigate} />; + return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} />; case "modelPhotoDemoB": return navigate("modelPhoto")} />; case "settings": - return ; + return ; case "settingsNotify": - return ; + return ; default: return ; } @@ -437,6 +460,7 @@ export function App() { team={currentTeam} products={products} projects={projects} + assets={assets} billing={billing} notice={notice} avatarChar={avatarChar} diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 8ece845..1f20967 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -486,6 +486,14 @@ cursor: pointer; } .image-workbench .gen-image .placeholder { position: absolute; inset: 0; } +/* 生成结果真图 · 填满 .gen-image(比例由容器 aspect-ratio 控制) */ +.image-workbench .gen-image-img { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; + display: block; + background: var(--black-alpha-4); +} /* 右上浮层按钮组(§4.18 .gen-image-actions) */ .image-workbench .gen-image-actions { position: absolute; top: 8px; right: 8px; diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 1e18805..cdf2225 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -6,6 +6,8 @@ .asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); } .asset-thumb { aspect-ratio: 1; } .asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; } + /* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */ + .asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; } .asset-body { padding: 12px 14px; } .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; } diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index 086079f..65faea9 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -698,6 +698,8 @@ position: relative; overflow: hidden; } + /* 有 preview_url 显真图:铺满缩图、cover 裁切、继承圆角 */ + .asset-card .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; } .asset-card .thumb .type-pill { position: absolute; top: 8px; left: 8px; padding: 3px 8px; diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 0038a20..d84668b 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -271,7 +271,8 @@ export function ImageWorkbenchPage({ assets, modelConfigs, onBack, - navigate + navigate, + onGenerate }: { mode: WorkMode; products: Product[]; @@ -279,6 +280,7 @@ export function ImageWorkbenchPage({ modelConfigs: ModelConfig[]; onBack: () => void; navigate?: (page: Page) => void; + onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>; }) { const meta = MODE_META[mode]; const [productId, setProductId] = useState(products[0]?.id || ""); @@ -287,6 +289,8 @@ export function ImageWorkbenchPage({ const [ratio, setRatio] = useState(meta.ratio); const [count, setCount] = useState("4"); const [pickedIds, setPickedIds] = useState([]); + const [generating, setGenerating] = useState(false); + const [results, setResults] = useState(null); const imageModels = modelConfigs.filter((model) => model.capability.includes("image")); const modelOptions = useMemo(() => modelConfigs.slice(0, 6), [modelConfigs]); @@ -304,6 +308,19 @@ export function ImageWorkbenchPage({ const ratioVar = ratio.replace(":", " / "); const candidateCount = Math.max(1, Number(count) || 4); + const canGenerate = prompt.trim().length > 0 && !generating; + + async function runGenerate() { + if (!canGenerate) return; + setGenerating(true); + try { + const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount }); + // 成功:渲染真图;失败(null):保留原占位,不改 results + if (result?.assets) setResults(result.assets); + } finally { + setGenerating(false); + } + } return (
@@ -326,9 +343,9 @@ export function ImageWorkbenchPage({ 方案 A )} -
@@ -485,9 +502,9 @@ export function ImageWorkbenchPage({
-
// {imageModels[0]?.display_name || "Volcano Image"} · 预估 {meta.title} @@ -513,14 +530,24 @@ export function ImageWorkbenchPage({ | {product?.title || meta.title}
-
= 4 ? 4 : 2, "--ratio": ratioVar } as React.CSSProperties}> - {Array.from({ length: candidateCount }).map((_, index) => ( -
-
- - {ratio} · #{index + 1} - -
+
= 4 ? 4 : 2, "--ratio": ratioVar } as React.CSSProperties} + > + {(results && results.length > 0 + ? results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url })) + : Array.from({ length: candidateCount }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined })) + ).map(({ key, index, url }) => ( +
+ {url ? ( + {`${meta.title} + ) : ( +
+ + {ratio} · #{index + 1} + +
+ )}
- {/* Stage 2-5 · 暂沿用既有功能性结构(默认隐藏),后续逐阶段做像素还原 */} - {viewStage === 2 && ( + {/* ============= 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 productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset); + return (
-
商品3 张
-
人物2/2
-
场景3/3
+ {KIND_ORDER.map((kind) => { + const list = groupsByKind(kind); + const adopted = list.filter((g) => g.adopted_asset).length; + return ( +
+ {KIND_LABEL[kind]}{list.length ? `${adopted}/${list.length}` : "0"} +
+ ); + })}
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。

@@ -256,29 +302,31 @@ export function PipelinePage(props: {
-
+

商品 · {productName}

-
-
- - - 缺三视图 - - - - MISSING TRI-VIEW +
+
+ {!productGroup?.adopted_asset && ( + + + 缺三视图 + + + + MISSING TRI-VIEW + + 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。 + 建议:点右下 AI 生成三视图 先补齐三视图,再发起后续生成。 - 该商品还未生成 正 / 侧 / 背 三视图。直接生成图片或视频,模型缺少多角度参考,角色一致性、姿态稳定性可能下降。 - 建议:点右下 AI 生成三视图 先补齐三视图,再发起后续生成。 - + )} {productName} · 主图
{productName}
-
美妆个护
-
2026-05-15 创建
+
{products.find((item) => item.id === project.product)?.category || "未分类"}
+
{(project.created_at || "").slice(0, 10)} 创建
-
-
// 三视图预览 · 生成中
-
-
-
-
-
- -
-

人物 · 2 个

-
-
-
林夕 · 都市白领
-
-
主角 · 林夕
-
25-30 岁都市白领,长发,穿宽松米色家居服,温柔但带点疲倦感。
-
-
-
-
-
-
-
- 生成中 · 约 8s -
-
-
-
朋友/同事 · 阿楠
-
25-30 岁同龄女性,短发,穿白色衬衫,妆容精致皮肤好,作为对比。
-
+
+
// 候选三视图 · {productCandidates.length} 张
+
候选 #1
+
+ {productCandidates.slice(0, 4).map((id) => ( +
+ ))}
-
-

场景 · 3 个

-
-
-
深夜办公桌
-
-
深夜办公桌
-
深夜居家办公环境,木质书桌,台灯暖光,电脑屏幕亮着。
-
-
-
-
-
床头特写
-
-
卧室床头
-
米白色床品,木质床头柜,闹钟显示晚间时间。
-
-
-
-
-
-
-
!
- 生成失败 + {(["person", "scene"] as const).map((kind) => { + const list = groupsByKind(kind); + return ( +
+

{KIND_LABEL[kind]} · {list.length} 个

+ {list.length ? ( +
+ {list.map((group, gi) => { + const mainUrl = assetUrl(group.adopted_asset) || assetUrl(group.candidate_assets?.[0]); + const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4); + return ( +
+
+ {assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`} +
+
+
+ {assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`} + + {group.adopted_asset + ? 已采用 + : 待采用} +
+
{group.prompt || "(暂无提示词)"}
+ {cands.length > 0 && ( +
+ {cands.map((id) => ( +
+ ))} +
+ )} +
+
+ ); + })}
-
-
-
通勤地铁失败
-
早高峰地铁车厢,光线偏冷,年轻通勤族,氛围紧张。
-
-
-
-
-
+ ) : ( +
// 暂无{KIND_LABEL[kind]}资产 · 待生成
+ )} +
+ ); + })}
-
[ 已确认 ¥0.85 · 待生成 ¥0.20 · 失败 ¥0(不扣) ]
+
[ 基础资产同时展示 · 商品图复用商品库 · 失败不扣 ]
- )} + ); + })()} + {/* ============= STAGE 3 · 故事板(采用版的 frames,真图 + 镜头提示词)============= */} {viewStage === 3 && (
- {SB_SCENES.map((scene) => ( -
-
{scene.frame}
-
{scene.nm}
-
{scene.sub}
-
- ))} + {sbFrames.length ? sbFrames.map((frame, idx) => { + const url = assetUrl(frame.asset); + return ( +
setSbSelected(idx)}> +
场 {idx + 1}
+
场 {idx + 1}
+
#{frame.sort_order + 1}
+
+ ); + }) :
// 暂无
}
-
场 1 · 深夜办公桌 · v1
+ {(() => { + const url = assetUrl(sbActiveFrame?.asset); + return ( +
+ {sbActiveFrame ? `场 ${sbSelected + 1}` : "// 故事板未生成"} +
+ ); + })()}
- 故事板 · 场 1 + 故事板 · {sbActiveFrame ? `场 ${sbSelected + 1}` : "—"} - 已生成 + {adoptedStoryboard + ? 已生成 + : 未生成}
整张故事板由 image-2 一次性输出,包含画面 + 镜头说明。
@@ -399,7 +446,7 @@ export function PipelinePage(props: {
仅支持整张重跑 · 不能局部改某一镜。如需调单镜,先在 { event.preventDefault(); goStage(1); }}>Stage 1 脚本 改镜头描述,再回此处整张重跑。
// 本场提示词
-
{SB_PROMPT}
+
{sbActiveFrame?.prompt || adoptedStoryboard?.prompt || "(暂无提示词)"}
-
// 历史版本(1)
+
// 历史版本({storyboards.length})
-
-
v1
-
14:02
-
+ {storyboards.length ? storyboards.map((ver) => { + const cover = assetUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]?.asset); + return ( +
+
{ver.is_adopted ? "采用" : "历史"}
+
{(ver.created_at || "").slice(11, 16) || "--:--"}
+
+ ); + }) : // 暂无历史}
// 绑定的资产
- 林夕(人物) - 深夜办公桌(场景) + {groups.filter((g) => g.adopted_asset).length ? groups.filter((g) => g.adopted_asset).map((g) => ( + {assetName(g.adopted_asset) || KIND_LABEL[g.kind] || g.kind}({KIND_LABEL[g.kind] || g.kind}) + )) : // 暂无绑定资产}
-
[ image-2 单场 ¥0.45 · 累计 ¥1.35 · 整张重跑,失败不扣 ]
+
[ image-2 整张输出 · {sbFrames.length} 场 · 整张重跑,失败不扣 ]
@@ -436,61 +489,83 @@ export function PipelinePage(props: {
)} - {viewStage === 4 && ( + {/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */} + {viewStage === 4 && (() => { + const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0; + return (
-
视频生成 · 3 / 3 完成
-
// 每场 Seedance 约 15 秒 · 已完成所有场次
+
视频生成 · {segDone} / {segments.length} 完成
+
// 每场 Seedance 生成 · {segments.length ? (segDone === segments.length ? "已完成所有场次" : "生成中") : "暂无片段"}
-
- 100% - +
+ {pct}% +
-
- {VIDEO_CARDS.map((card) => ( -
-
- {card.frame} -
-
-
-
{card.title}完成
-
{card.meta}
-
- - - + {segments.length ? ( +
+ {segments.map((seg) => { + const url = assetUrl(seg.adopted_asset); + const tone = statusPill(seg.status); + return ( +
+
+ 场 {seg.sort_order + 1} + {url &&
} +
+
+
+ 场 {seg.sort_order + 1} + {statusLabel(seg.status)} +
+
{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}
+
+ + + +
+
-
-
- ))} -
+ ); + })} +
+ ) : ( +
// 暂无视频片段 · 先在故事板确认后生成
+ )}
-
[ 已完成 3 场 · 累计 ¥1.35 · 总时长 40s · 失败不扣 · 通过后扣 ]
+
[ 已完成 {segDone} 场 · 总时长 {segTotalSec}s · 失败不扣 · 通过后扣 ]
- )} - {viewStage === 5 && ( + ); + })()} + {/* ============= 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 aspect = timeline?.aspect_ratio || "9:16"; + const resolution = timeline?.resolution || "1080×1920"; + const bgm = bgmTracks[0] || null; + const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : ""); + return (
-
9:16 预览 · 1080×1920
+
{aspect} 预览 · {resolution}
- 00:00.00 / 00:15.00 + 0:00 / {fmtMs(tlRulerMs)}
@@ -504,15 +579,18 @@ export function PipelinePage(props: {
真实分享
综艺暖黄
-
// 当前选中(未选)
-
起始
-
时长
-
音量
-
速度
-
入场交叉淡化
-
-
// BGM
-
温柔治愈钢琴 · 0:42
+
// 时间轴({timeline?.name || "未命名"})
+
总时长
+
片段
+
字幕
+
分辨率{resolution}
+ {bgm && ( + <> +
+
// BGM
+
{bgmName} · 音量 {bgm.volume}
+ + )}
@@ -530,8 +608,8 @@ export function PipelinePage(props: {
// time
- {ED_RULER.map((tick, i) => ( - {tick.t && {tick.t}} + {ruler.map((tick, i) => ( + {tick.t && {tick.t}} ))}
@@ -539,47 +617,66 @@ export function PipelinePage(props: {
视频
- {edLayout(ED_VIDEO_CLIPS).map((clip) => ( -
- {Array.from({ length: clip.dur + 1 }).map((_, i) => )} - {clip.n}{clip.lbl} -
- ))} + {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)); + return ( +
+ {Array.from({ length: frameCount + 1 }).map((_, i) => )} + {idx + 1}{lbl} +
+ ); + }) : // 暂无片段}
字幕
- {edLayout(ED_SUB_CLIPS).map((clip, i) => ( -
{clip.lbl}
- ))} + {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); + return ( +
{cue.text}
+ ); + })}
-
-
BGM
-
-
- {ED_WAVE.map(([y, h], i) => )} - 温柔治愈钢琴 · 0:42(循环 1 次,淡入淡出) + {bgmTracks.length > 0 && ( +
+
BGM
+
+ {bgmTracks.map((track) => { + const { leftPct, widthPct } = clipLayout(track.start_ms, Math.max(0, tlRulerMs - track.start_ms), tlRulerMs); + const name = assetName(track.asset) || "背景音乐"; + return ( +
+ {ED_WAVE.map(([y, h], i) => )} + {name} · 音量 {track.volume} +
+ ); + })}
-
+ )}
-
[ 合成预估 ~30s · 拼接 / 导出全程 0 token · 已结算 ¥1.39 ]
+
[ 时间轴 {fmtMs(tlRulerMs)} · {tlClips.length} 段 · 拼接 / 导出全程 0 token ]
- +
- )} + ); + })()}
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 0c5e5ed..47a4ada 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import type { CSSProperties, FormEvent, KeyboardEvent } from "react"; import { ArrowLeft } from "lucide-react"; -import type { Product, Project } from "../types"; +import type { Asset, Product, Project } from "../types"; import type { Page } from "./route-config"; import { Drawer } from "../components/overlays"; import "../product-create-page.css"; @@ -303,46 +303,60 @@ export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (paylo } // 商品详情页 · 从 public/exact/product-detail.html 忠实转写。 -// 真实数据仅注入 api-bridge renderProductDetail 实际 hydrate 的 4 个字段 -// (名称 / 品类 / 目标人群 / 卖点),其余 图片/素材/项目 网格沿用设计稿镜像的 -// 静态占位(api-bridge 在 ?product_id 加载时同样保留 mock,故像素对齐)。 -const PD_ASSETS: Array<{ type: string; status: "pass" | "fail" | "archive" }> = [ - { type: "模特上身图", status: "pass" }, - { type: "模特上身图", status: "pass" }, - { type: "模特上身图", status: "fail" }, - { type: "模特上身图", status: "pass" }, - { type: "模特上身图", status: "archive" }, - { type: "平台套图", status: "pass" }, - { type: "平台套图", status: "pass" }, - { type: "平台套图", status: "fail" }, - { type: "平台套图", status: "archive" }, - { type: "平台套图", status: "pass" }, - { type: "三视图", status: "pass" }, - { type: "三视图", status: "archive" } -]; +// 名称 / 品类 / 目标人群 / 卖点 来自 product;商品图网格 / AI 素材卡 / 视频项目卡 +// 已接入真实数据(product.images + 团队 assets + 该商品 projects),保持设计稿像素布局。 const PD_ASSET_STATUS_LABEL: Record<"pass" | "fail" | "archive", string> = { pass: "通过", fail: "不通过", archive: "归档" }; -const PD_VIDEOS: Array<{ proj: string; pill: string; ver: string; label: string; date: string }> = [ - { proj: "done", pill: "ok", ver: "补水面膜 · v3", label: "已完成", date: "2026-05-20 12:08" }, - { proj: "wip", pill: "info", ver: "补水面膜 · v2", label: "视频生成 4/6", date: "2026-05-19 10:24" }, - { proj: "archived", pill: "neutral", ver: "熬夜急救 · v1", label: "已归档", date: "2026-05-18 21:42" }, - { proj: "fail", pill: "err", ver: "补水面膜 · v1", label: "故事板失败", date: "2026-05-17 16:00" } -]; - const PD_CAT_OPTIONS = ["美妆个护 / 精华液", "美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"]; -export function ProductDetailPage({ product, navigate, onUpdate }: { +// 取一个 Asset 的预览图(优先主文件,其次首文件) +function pdAssetPreview(asset?: Asset): string { + if (!asset) return ""; + return asset.files?.find((file) => file.is_primary)?.preview_url || asset.files?.[0]?.preview_url || ""; +} +// AI 素材分类 → 中文类型标签(用于缩图左上角 type-pill) +const PD_ASSET_TYPE_LABEL: Record = { + product_image: "商品图", person: "模特上身图", scene: "平台套图", tri_view: "三视图", background: "背景图" +}; +function pdAssetTypeLabel(asset: Asset): string { + return PD_ASSET_TYPE_LABEL[asset.category] || PD_ASSET_TYPE_LABEL[asset.asset_type] || asset.category || "素材"; +} +// 项目状态 → 分桶 / 友好标签 / pill 类(对齐 projects.tsx 语义,组件内自洽) +function pdProjBucket(project: Project) { return project.status === "completed" ? "done" : project.status === "failed" ? "fail" : "wip"; } +function pdProjStatusLabel(project: Project) { + return ({ draft: "脚本待生成", scripting: "脚本生成中", asseting: "基础资产生成中", storyboarding: "故事板生成中", videoing: "视频片段生成中", exporting: "导出中", completed: "已完成", failed: "失败" } as Record)[project.status] || "进行中"; +} +function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; } + +export function ProductDetailPage({ product, projects, assets, navigate, onUpdate }: { product: Product; projects: Project[]; + assets: Asset[]; navigate: (page: Page) => void; onUpdate: (payload: Partial) => Promise | void; }) { const [tab, setTab] = useState<"assets" | "videos">("assets"); const [editing, setEditing] = useState(false); const [triOpen, setTriOpen] = useState(false); - // 素材状态筛选 · 镜像默认即「通过」(api-bridge ALWAYS_APPLY status),只显示通过卡 - const [assetStatus] = useState<"pass" | "fail" | "archive">("pass"); - const assetCount = PD_ASSETS.filter((asset) => asset.status === assetStatus).length; + + // 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重) + const assetById = new Map(assets.map((asset) => [asset.id, asset])); + const imageRefs = [...(product.images || [])].sort((a, b) => a.sort_order - b.sort_order); + const imageIds = imageRefs.map((ref) => ref.asset); + if (product.cover_asset && !imageIds.includes(product.cover_asset)) imageIds.unshift(product.cover_asset); + const productImages = imageIds + .map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) })); + + // 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")) + .slice() + .sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")); + const assetCount = imageAssets.length; + + // 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段 + const videoProjects = projects; // 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为) const realName = product.title || "补水保湿精华液"; @@ -470,15 +484,14 @@ export function ProductDetailPage({ product, navigate, onUpdate }: {
商品图片 - (6) + ({productImages.length})
-
1:1
-
1:1
-
1:1
-
1:1
-
1:1
-
1:1
+ {productImages.map((image) => ( +
+ {image.url ? {realName} : 1:1} +
+ ))}
@@ -557,13 +570,17 @@ export function ProductDetailPage({ product, navigate, onUpdate }: {
- {PD_ASSETS.map((asset, index) => { - // 镜像 mock-media:平台套图 占位匹配 scene → scene-tabletop.png - const hasMock = asset.type === "平台套图"; + {imageAssets.map((asset) => { + const url = pdAssetPreview(asset); + const status: "pass" | "fail" | "archive" = "pass"; return ( -
-
{asset.type}3:4
-
{PD_ASSET_STATUS_LABEL[asset.status]}2026-05-19 15:30
+
+
+ {url ? {asset.name} : null} + {pdAssetTypeLabel(asset)} + {url ? null : 3:4} +
+
{PD_ASSET_STATUS_LABEL[status]}{(asset.created_at || "").slice(0, 10)}
); })} @@ -575,7 +592,7 @@ export function ProductDetailPage({ product, navigate, onUpdate }: { {/* ===== 视频项目 ===== */}
-
该商品视频项目 (4)
+
该商品视频项目 ({videoProjects.length})
- {PD_VIDEOS.map((video, index) => ( -
-
视频 · 9:16{video.ver}
-
{video.label}{video.date}
+ {videoProjects.map((project) => ( +
+
视频 · 9:16{project.name}
+
{pdProjStatusLabel(project)}{(project.updated_at || "").slice(0, 10)}
))}
diff --git a/core/frontend/src/routes/settings.tsx b/core/frontend/src/routes/settings.tsx index 54052ed..28da16f 100644 --- a/core/frontend/src/routes/settings.tsx +++ b/core/frontend/src/routes/settings.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ChangeEvent, ReactNode } from "react"; import { Bell, + KeyRound, LogOut, Monitor, ShieldCheck, @@ -64,6 +65,46 @@ 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; + appearance: string; + language: string; + density: string; +}; + +const DEFAULT_PREFS: Prefs = { + template: "pain", + duration: "60", + subtitle: "big-variety", + twoFactor: false, + notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true }, + 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; + 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 (
- - + @@ -150,7 +312,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
{avatarChar}
- +
@@ -158,19 +320,19 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
显示名称*
-
+
setName(event.target.value)} />
登录邮箱
- + setEmail(event.target.value)} />
手机号
- + setPhone(event.target.value)} placeholder="138****8000" />
@@ -199,7 +361,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
●●●●●●●●●● 上次修改 2026-04-12 - +
@@ -358,7 +520,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
外观
- setAppearance(event.target.value)}> @@ -368,7 +530,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
语言
- setLanguage(event.target.value)}> @@ -377,7 +539,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
表格密度
- setDensity(event.target.value)}> @@ -391,7 +553,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
- {/* 上传头像 modal · 仅视觉还原,无后端接入 */} + {/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */} } close={() => setModal("")} footer={ - } >
-
{avatarChar}
+
+ {avatarPreview ? 头像预览 : avatarChar} +
-
当前头像 · 默认
-
// 系统生成 · 取姓氏首字
+
{avatarFile ? avatarFile.name : "当前头像 · 默认"}
+
{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}
-
+ +
fileInputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + fileInputRef.current?.click(); + } + }} + > -
点击选择 · 或拖入图片
+
点击选择 · 图片文件
JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256
@@ -427,6 +610,50 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
+ {/* 修改密码 modal · 原密码 + 新密码(≥8)→ onChangePassword */} + } + close={() => setModal("")} + footer={ + + } + > +
+ + setOldPassword(event.target.value)} + placeholder="输入当前密码" + /> + {pwSubmitted && !oldPassword ? 请输入原密码 : null} +
+
+ + setNewPassword(event.target.value)} + placeholder="至少 8 位" + /> + {pwTooShort || (pwSubmitted && newPassword.length < 8) + ? 新密码至少 8 位 + : // 建议混合字母、数字与符号} +
+
+ {/* 退出登录确认 modal · 仅视觉还原,无后端接入 */} ; + subtitle_tracks?: Array<{ + id: string; + content: Array<{ start_ms: number; text: string }>; + style?: Record; + enabled: boolean; + }>; + bgm_tracks?: Array<{ id: string; asset: string; volume: number; start_ms: number }>; export_jobs?: Array<{ id: string; status: string; diff --git a/core/qa/visual-parity/shot-pipeline.mjs b/core/qa/visual-parity/shot-pipeline.mjs new file mode 100644 index 0000000..4485673 --- /dev/null +++ b/core/qa/visual-parity/shot-pipeline.mjs @@ -0,0 +1,39 @@ +// 抓图:pipeline 五阶段真数据 + 商品详情 + ai-tools(用 demo 项目/商品) +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; +const BASE = "http://127.0.0.1:5180"; +const API = "http://127.0.0.1:8010"; +const OUT = process.argv[2] || "shots-pipeline"; +mkdirSync(OUT, { recursive: true }); +const tok = (await (await fetch(`${API}/api/auth/login/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "airshelf", password: "Restraint2026" }) })).json()).token; +const projs = (await (await fetch(`${API}/api/projects/`, { headers: { Authorization: `Token ${tok}` } })).json()).results; +const demo = projs.find((p) => p.name.startsWith("演示")) || projs[0]; +const prods = (await (await fetch(`${API}/api/products/`, { headers: { Authorization: `Token ${tok}` } })).json()).results; +const prod = prods.find((p) => p.title.includes("补水")) || prods[0]; +console.log("demo project", demo?.id, "| product", prod?.id); + +const shots = [ + ["pipeline-stage2", `/pipeline/${demo.id}?st=2#stage-2`], + ["pipeline-stage3", `/pipeline/${demo.id}?st=3#stage-3`], + ["pipeline-stage4", `/pipeline/${demo.id}?st=4#stage-4`], + ["pipeline-stage5", `/pipeline/${demo.id}?st=5#stage-5`], + ["product-detail", `/products/${prod.id}`], + ["image-optimize", `/image-optimize`] +]; +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); +await ctx.addInitScript((t) => localStorage.setItem("airshelf_token", t), tok); +const page = await ctx.newPage(); +page.on("pageerror", (e) => console.error(" pageerror:", e.message)); +for (const [name, route] of shots) { + try { + await page.goto(BASE + route, { waitUntil: "networkidle", timeout: 30000 }); + await page.waitForTimeout(1600); + await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true }); + console.log("shot", name); + } catch (e) { + console.error("FAIL", name, e.message); + } +} +await browser.close(); +console.log("DONE ->", OUT);