大量修改UI
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AlertCircle, Check, Info, LogOut } from "lucide-react";
|
||||
import { AlertCircle, Boxes, Check, Info, LogOut, Settings, UsersRound } from "lucide-react";
|
||||
import { IconKitSvg } from "./IconKitSvg";
|
||||
import { useBodyScrollLock } from "./overlays";
|
||||
import { api, setToken } from "../api";
|
||||
import type { Product, Project, Team, User } from "../types";
|
||||
import type { Notice, Page } from "../routes/route-config";
|
||||
|
||||
@@ -15,10 +13,10 @@ type Command = { id: string; group: string; label: string; sub: string; page: Pa
|
||||
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: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
|
||||
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
|
||||
{ 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" },
|
||||
@@ -73,7 +71,7 @@ function CommandPalette({ open, onClose, navigate, canManageBilling = true }: {
|
||||
<div className="shell-command-empty">
|
||||
<IconKitSvg name="search" />
|
||||
<span>没有匹配的入口</span>
|
||||
<span className="shell-command-section">// 换个关键词试试</span>
|
||||
<span className="shell-command-section">换个关键词试试</span>
|
||||
</div>
|
||||
)}
|
||||
{items.map((cmd, i) => {
|
||||
@@ -113,7 +111,7 @@ const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
|
||||
{ act: "account", icon: "creditCard", label: "消费与余额" }
|
||||
];
|
||||
|
||||
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
|
||||
export function AccountMenu({ anchorRect, onClose, navigate, logout, user, team, canManageBilling = true }: {
|
||||
anchorRect: DOMRect;
|
||||
onClose: () => void;
|
||||
navigate: Navigate;
|
||||
@@ -196,10 +194,10 @@ const NAV: NavDef[] = [
|
||||
{ id: "dashboard", page: "dashboard", label: "工作台", icon: "dashboard" },
|
||||
{ id: "products", page: "products", label: "商品库", icon: "package" },
|
||||
{ id: "models", page: "models", label: "模特库", icon: "model" },
|
||||
{ id: "projects", page: "projects", label: "视频项目", icon: "clapperboard" },
|
||||
{ id: "projects", page: "projects", label: "视频创作", icon: "clapperboard" },
|
||||
{ id: "asset-factory", page: "assetFactory", label: "图片生成", icon: "sparkles" },
|
||||
{ id: "free-create", page: "freeCreate", label: "自由创作", icon: "film" },
|
||||
{ id: "library", page: "library", label: "资产库", icon: "library" },
|
||||
{ id: "library", page: "library", label: "成品库", icon: "library" },
|
||||
{ id: "team", page: "team", label: "团队", icon: "users" },
|
||||
{ id: "account", page: "account", label: "消费", icon: "creditCard" },
|
||||
{ id: "trash", page: "trash", label: "垃圾桶", icon: "trash" },
|
||||
@@ -208,7 +206,7 @@ const NAV: NavDef[] = [
|
||||
const RESOURCE_NAV: Array<{ id: string; page: Page; label: string }> = [
|
||||
{ id: "products", page: "products", label: "商品库" },
|
||||
{ id: "models", page: "models", label: "模特库" },
|
||||
{ id: "library", page: "library", label: "资产库" },
|
||||
{ id: "library", page: "library", label: "成品库" },
|
||||
// 成品库先隐藏,后续再加回资源中心
|
||||
{ id: "account", page: "account", label: "消费" },
|
||||
{ id: "trash", page: "trash", label: "垃圾桶" },
|
||||
@@ -258,11 +256,10 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
||||
settingsNotify: "settings"
|
||||
};
|
||||
|
||||
export function Sidebar({ page, navigate, user, team, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, logout, onOpenAdmin }: {
|
||||
export function Sidebar({ page, navigate, user, canManageBilling = true, products, projects, productTotal, projectTotal, aiUnread, onOpenAdmin }: {
|
||||
page: Page;
|
||||
navigate: Navigate;
|
||||
user: User;
|
||||
team: Team | null;
|
||||
// 主账号(owner=超管)才看得到「团队」「消费」入口;子账号隐藏(PMC#3)。默认 true 兼容旧调用。
|
||||
canManageBilling?: boolean;
|
||||
products: Product[];
|
||||
@@ -271,9 +268,6 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
projectTotal?: number;
|
||||
// YYX#row22:未读生成任务总数 → 「图片生成」右侧数字胶囊
|
||||
aiUnread?: number;
|
||||
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
|
||||
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
|
||||
logout?: () => void;
|
||||
// 平台超管入口:仅 user.is_platform_admin 时显示,点了进 /admin 后台
|
||||
onOpenAdmin?: () => void;
|
||||
}) {
|
||||
@@ -292,7 +286,6 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
const badges: Partial<Record<string, number>> = { products: productTotal ?? products.length, projects: projectTotal ?? projects.length };
|
||||
// YYX#row22:有未读生成任务才显示胶囊(0 不显示)
|
||||
if (aiUnread && aiUnread > 0) badges["asset-factory"] = aiUnread;
|
||||
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
||||
|
||||
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
|
||||
// 类(CSS 在 design-restraint.css),并持久化到 localStorage。
|
||||
@@ -319,20 +312,6 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
else if (topModule) setResourceOpen(false);
|
||||
}, [activeMajor, topModule]);
|
||||
|
||||
// 账户下拉菜单:点侧栏 .user 用其 DOMRect 当锚点展开,再次点击收起(toggle)。
|
||||
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
|
||||
const toggleAccountMenu = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setAccountAnchor((prev) => (prev ? null : rect));
|
||||
};
|
||||
// 退出兜底:父级未传 logout 时,自行 best-effort 调登出接口 + 清 token + 跳登录页。
|
||||
const handleLogout = () => {
|
||||
if (logout) { logout(); return; }
|
||||
void api.logout().catch(() => undefined);
|
||||
setToken(null);
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
// 命令面板:Ctrl/Cmd K,以及顶栏搜索按钮
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -360,8 +339,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
|
||||
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
|
||||
<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 className="brand" href="/dashboard" aria-label="影擎工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
|
||||
<span className="brand-clip"><img className="brand-logo" src="/assets/yz/logo-horizontal.png" alt="影擎" /></span>
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
@@ -391,7 +370,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
}}
|
||||
>
|
||||
<span className="major-dot" aria-hidden="true" />
|
||||
<IconKitSvg name="library" />
|
||||
<Boxes size={16} strokeWidth={2} />
|
||||
<span>资源中心</span>
|
||||
<span className="chev"><IconKitSvg name="chevronRight" /></span>
|
||||
</button>
|
||||
@@ -417,7 +396,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate("team"); }}
|
||||
>
|
||||
<span className="major-dot" aria-hidden="true" />
|
||||
<IconKitSvg name="users" />
|
||||
<UsersRound size={18} strokeWidth={2} />
|
||||
<span>团队中心</span>
|
||||
</a>
|
||||
<a
|
||||
@@ -426,7 +405,7 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate("settings"); }}
|
||||
>
|
||||
<span className="major-dot" aria-hidden="true" />
|
||||
<IconKitSvg name="settings" />
|
||||
<Settings size={18} strokeWidth={2} />
|
||||
<span>设置</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -446,34 +425,8 @@ export function Sidebar({ page, navigate, user, team, canManageBilling = true, p
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
<div className="aside-foot">
|
||||
<div
|
||||
className="user"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountAnchor !== null}
|
||||
title="账户"
|
||||
onClick={toggleAccountMenu}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleAccountMenu(event as unknown as ReactMouseEvent<HTMLElement>); } }}
|
||||
>
|
||||
<div className="av">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatar}</div>
|
||||
<div className="em">{team?.name || user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} canManageBilling={canManageBilling} />
|
||||
{accountAnchor && (
|
||||
<AccountMenu
|
||||
anchorRect={accountAnchor}
|
||||
onClose={() => setAccountAnchor(null)}
|
||||
navigate={navigate}
|
||||
logout={handleLogout}
|
||||
user={user}
|
||||
team={team}
|
||||
canManageBilling={canManageBilling}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<ChevronLeft size={14} /> {activeGroup.name}
|
||||
</button>
|
||||
) : "人物素材库"}
|
||||
<span>// 火山素材登记 · @ 引用生成</span>
|
||||
<span>火山素材登记 · @ 引用生成</span>
|
||||
</div>
|
||||
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
|
||||
</div>
|
||||
@@ -318,7 +318,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<Upload size={13} /> {quickUploading ? "上传中…" : "上传图片"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> 新建素材组</button>
|
||||
<span className="mono fc-lib-hint">// 自动进入默认素材组审核</span>
|
||||
<span className="mono fc-lib-hint">自动进入默认素材组审核</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -326,7 +326,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
|
||||
<h3>还没有素材组</h3>
|
||||
<p>// 一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截</p>
|
||||
<p>一个角色一组:登记后可在提示词里 @ 引用,规避真人脸拦截</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-lib-groups">
|
||||
@@ -375,7 +375,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
) : (
|
||||
<div className="fc-lib-name" title={group.name}>{group.name}</div>
|
||||
)}
|
||||
<div className="fc-lib-count mono">// {group.asset_count} 个素材</div>
|
||||
<div className="fc-lib-count mono">{group.asset_count} 个素材</div>
|
||||
<div className="fc-lib-ops">
|
||||
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameGroup(group); }}><Pencil size={12} /></button>
|
||||
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); setEditingGroupId(null); setConfirmDeleteGroupId(group.id); }}><Trash2 size={12} /></button>
|
||||
@@ -383,7 +383,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
{isDeleting ? (
|
||||
<div className="fc-lib-delete-pop" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="fc-lib-delete-title">删除素材组?</div>
|
||||
<div className="fc-lib-delete-note mono">// 组内素材也会删除</div>
|
||||
<div className="fc-lib-delete-note mono">组内素材也会删除</div>
|
||||
<div className="fc-lib-delete-actions">
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setConfirmDeleteGroupId(null)}>取消</button>
|
||||
<button type="button" className="btn btn-sm fc-lib-delete-danger" disabled={deletingGroupId === group.id} onClick={() => void removeGroup(group)}>
|
||||
@@ -415,13 +415,13 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<button type="button" className="btn btn-sm" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={13} /> {uploading ? "上传中…" : "上传素材"}
|
||||
</button>
|
||||
<span className="mono fc-lib-hint">// 审核通过(可用)后才能被生成引用</span>
|
||||
<span className="mono fc-lib-hint">审核通过(可用)后才能被生成引用</span>
|
||||
</div>
|
||||
{assets.length === 0 && !loading ? (
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><Upload size={22} strokeWidth={1.5} /></span>
|
||||
<h3>组内还没有素材</h3>
|
||||
<p>// 上传该角色的图片/视频/音频</p>
|
||||
<p>上传该角色的图片/视频/音频</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
@@ -473,7 +473,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
<div className="fc-lib-name" title={item.name}>{item.name}</div>
|
||||
)}
|
||||
<span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
||||
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>// {item.error_message}</div>}
|
||||
{item.status === "failed" && item.error_message && <div className="fc-lib-err mono" title={item.error_message}>{item.error_message}</div>}
|
||||
<div className="fc-lib-ops">
|
||||
<button type="button" title="重命名" onClick={(event) => { event.stopPropagation(); startRenameAsset(item); }}><Pencil size={12} /></button>
|
||||
<button type="button" title="删除" onClick={(event) => { event.stopPropagation(); setEditingAssetId(null); setConfirmDeleteAssetId(item.id); }}><Trash2 size={12} /></button>
|
||||
|
||||
@@ -10,6 +10,19 @@ function ratioStyle(ratio: string) {
|
||||
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
|
||||
}
|
||||
|
||||
function ratioCopy(ratio: string) {
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
if (!w || !h) return ratio;
|
||||
if (w === h) return `方形 ${ratio}`;
|
||||
return `${w > h ? "横屏" : "竖屏"} ${ratio}`;
|
||||
}
|
||||
|
||||
function statusTag(status: string) {
|
||||
if (status === "succeeded") return { cls: "green", label: "已完成" };
|
||||
if (status === "failed" || status === "cancelled") return { cls: "danger", label: status === "cancelled" ? "已取消" : "失败" };
|
||||
return { cls: "blue", label: STATUS_LABELS[status] || "生成中" };
|
||||
}
|
||||
|
||||
export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavorite, onDelete, onDownload }: {
|
||||
task: FreeVideoTask;
|
||||
progress: number;
|
||||
@@ -23,12 +36,13 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
|
||||
const inFlight = isInFlight(task.status);
|
||||
const failed = task.status === "failed" || task.status === "cancelled";
|
||||
const done = task.status === "succeeded" && !!task.video_url;
|
||||
const tag = statusTag(task.status);
|
||||
const errorText = isPublicGenerationError(task.error)
|
||||
? (() => { const presentation = presentGenerationError(task.error); return `${presentation.title}:${presentation.description}`; })()
|
||||
: task.error_message || "视频生成失败,请重试";
|
||||
|
||||
return (
|
||||
<div className={`fc-card${done ? " done" : ""}`}>
|
||||
<article className={`fc-card${done ? " done" : ""}`}>
|
||||
<div
|
||||
className="fc-card-media"
|
||||
style={ratioStyle(task.aspect_ratio)}
|
||||
@@ -43,18 +57,18 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
|
||||
<div className="fc-card-generating">
|
||||
<span className="spinner" />
|
||||
<div className="fc-progress"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
|
||||
<span className="mono">{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
|
||||
<span>{STATUS_LABELS[task.status] || "生成中"} · {Math.round(Math.min(progress, 95))}%</span>
|
||||
</div>
|
||||
)}
|
||||
{failed && (
|
||||
<div className="fc-card-failed">
|
||||
<span className="pill pill-l2 pill-err"><span className="dot" />失败</span>
|
||||
<span className="fc-tag danger">失败</span>
|
||||
<p>{errorText}</p>
|
||||
<div className="hstack">
|
||||
<button type="button" className="btn btn-sm" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
|
||||
<div className="fc-failed-actions">
|
||||
<button type="button" className="fc-mini" onClick={(event) => { event.stopPropagation(); onRetry(); }}>
|
||||
<RotateCcw size={13} /> 重试
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-ghost" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
|
||||
<button type="button" className="fc-mini" title="删除" onClick={(event) => { event.stopPropagation(); onDelete(); }}>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
@@ -71,6 +85,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<span className="fc-dur">{task.duration}s</span>
|
||||
<div className="fc-card-hover">
|
||||
<button type="button" className="fc-hover-btn" title="下载" onClick={(event) => { event.stopPropagation(); onDownload(); }}>
|
||||
<Download size={14} />
|
||||
@@ -91,12 +106,16 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
|
||||
)}
|
||||
</div>
|
||||
<div className="fc-card-meta">
|
||||
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
|
||||
<div className="fc-card-sub mono">
|
||||
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
|
||||
{task.status === "succeeded" && ` · ${Number(task.actual_cost || 0)} 积分`}
|
||||
<h3 title={task.prompt}>{task.prompt || "未命名视频"}</h3>
|
||||
<p>{MODE_LABELS[task.mode] || task.mode} · {task.duration} 秒 · {ratioCopy(task.aspect_ratio)}</p>
|
||||
<div className="fc-card-line">
|
||||
<span className={`fc-tag ${tag.cls}`}>{tag.label}</span>
|
||||
<span className="fc-tag">{modelLabel(task.model)}</span>
|
||||
</div>
|
||||
{inFlight && (
|
||||
<div className="fc-progress-mini"><span style={{ width: `${Math.min(progress, 95)}%` }} /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。
|
||||
// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { IconKitSvg } from "../IconKitSvg";
|
||||
import { ImagePlus, Images, UserRoundPlus } from "lucide-react";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
|
||||
import { PromptInput, type PromptInputHandle } from "./prompt-input";
|
||||
@@ -16,11 +16,11 @@ function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void })
|
||||
{item.type === "image" ? (
|
||||
<img src={item.thumb_url || item.url} alt={item.label || "参考图"} />
|
||||
) : item.type === "video" ? (
|
||||
item.thumb_url ? <img src={item.thumb_url} alt={item.label || "参考视频"} /> : <span className="fc-ref-kind mono">MP4</span>
|
||||
item.thumb_url ? <img src={item.thumb_url} alt={item.label || "参考视频"} /> : <span className="fc-ref-kind">MP4</span>
|
||||
) : (
|
||||
<span className="fc-ref-kind mono">♪</span>
|
||||
<span className="fc-ref-kind">♪</span>
|
||||
)}
|
||||
{item.type !== "image" && item.duration ? <span className="fc-ref-dur mono">{item.duration}s</span> : null}
|
||||
{item.type !== "image" && item.duration ? <span className="fc-ref-dur">{item.duration}s</span> : null}
|
||||
{item.uploading && <span className="spinner" />}
|
||||
{item.label ? <span className="fc-ref-label">@{item.label}</span> : null}
|
||||
<button type="button" className="fc-ref-x" aria-label="删除素材" onClick={onRemove}>×</button>
|
||||
@@ -39,7 +39,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
|
||||
return (
|
||||
<div className="fc-kf-slot-wrap">
|
||||
<button type="button" className="fc-kf-slot" onClick={onPickLibrary}>
|
||||
<IconKitSvg name="images" size={20} />
|
||||
<Images size={18} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
|
||||
<button type="button" className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`} title={`从人物素材库替换${label}`} onClick={onPickLibrary}>
|
||||
<img src={item.thumb_url || item.url} alt={label} />
|
||||
{item.uploading && <span className="spinner" />}
|
||||
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
|
||||
<span className="fc-kf-tag">{role === "first_frame" ? "首" : "尾"}</span>
|
||||
</button>
|
||||
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
|
||||
<button type="button" className="fc-kf-lib" title={`从人物素材库替换${label}`} aria-label={`从人物素材库替换${label}`} onClick={onPickLibrary}>
|
||||
@@ -104,7 +104,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fc-inputbar${dragOver ? " drag" : ""}`}
|
||||
className={`fc-composer${dragOver ? " drag" : ""}`}
|
||||
onDragOver={(event) => { event.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
@@ -125,18 +125,17 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
pendingRoleRef.current = undefined;
|
||||
}}
|
||||
/>
|
||||
<div className="fc-input-top">
|
||||
<div className="fc-prompt-main">
|
||||
{mode === "universal" ? (
|
||||
<div className="fc-refs">
|
||||
<button type="button" className="fc-add" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
|
||||
<IconKitSvg name="images" size={18} />
|
||||
<span>+</span>
|
||||
<button type="button" className="fc-ref-btn" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
|
||||
<ImagePlus />
|
||||
</button>
|
||||
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
|
||||
<IconKitSvg name="users" size={18} />
|
||||
<button type="button" className="fc-ref-btn" title="人物素材库" onClick={() => onOpenLibrary()}>
|
||||
<UserRoundPlus />
|
||||
</button>
|
||||
<button type="button" className="fc-add fc-lib" title="引用平台素材(资产库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
|
||||
<IconKitSvg name="library" size={18} />
|
||||
<button type="button" className="fc-ref-btn" title="引用平台素材(成品库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
|
||||
<Images />
|
||||
</button>
|
||||
{refs.map((item) => (
|
||||
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
|
||||
@@ -145,7 +144,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
) : (
|
||||
<div className="fc-keyframes">
|
||||
<KeyframeSlot role="first_frame" item={firstFrame} onPickLibrary={() => onOpenLibrary("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
|
||||
<span className="fc-kf-arrow mono">→</span>
|
||||
<span className="fc-kf-arrow">→</span>
|
||||
<KeyframeSlot role="last_frame" item={lastFrame} onPickLibrary={() => onOpenLibrary("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
|
||||
</div>
|
||||
)}
|
||||
@@ -155,7 +154,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
onSubmit={onSend}
|
||||
onOpenLibrary={mode === "universal" ? () => onOpenLibrary() : undefined}
|
||||
onTextChange={setHasPrompt}
|
||||
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
|
||||
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材,例如:产品在晨光下缓慢旋转,镜头推近展示包装细节……"}
|
||||
/>
|
||||
</div>
|
||||
<FreeToolbar
|
||||
@@ -179,7 +178,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
onClear={onClear}
|
||||
onSend={onSend}
|
||||
/>
|
||||
{dragOver && <div className="fc-drop-hint mono">松开上传到「{MODE_LABELS[mode]}」</div>}
|
||||
{dragOver && <div className="fc-drop-hint">松开上传到「{MODE_LABELS[mode]}」</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useBodyScrollLock } from "../overlays";
|
||||
|
||||
type LibTab = "assets" | "models" | "products";
|
||||
|
||||
const TAB_LABEL: Record<LibTab, string> = { assets: "资产库", models: "模特库", products: "商品库" };
|
||||
const TAB_LABEL: Record<LibTab, string> = { assets: "成品库", models: "模特库", products: "商品库" };
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
/** 引用前的审核判定。与后端 apps/assets/review.py 的 reference_review_state 同一套规则:
|
||||
@@ -213,7 +213,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
const emptyText = useMemo(() => {
|
||||
if (tab === "models") return "还没有模特";
|
||||
if (tab === "products") return openProduct ? "这个商品还没有素材" : "还没有商品";
|
||||
return "资产库里还没有可引用的素材";
|
||||
return "成品库里还没有可引用的素材";
|
||||
}, [tab, openProduct]);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -239,7 +239,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
<div className="fc-lib-name" title={asset.display_name || asset.name}>{asset.display_name || asset.name}</div>
|
||||
{pill
|
||||
? <span className={`pill pill-l3 fc-lib-status ${pill.cls}`}><span className="dot" />{pill.label}</span>
|
||||
: <span className="fc-lib-count mono">// {asset.source === "upload" ? "已过审" : "平台生成"}</span>}
|
||||
: <span className="fc-lib-count mono">{asset.source === "upload" ? "已过审" : "平台生成"}</span>}
|
||||
{state === "unsubmitted" && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -266,7 +266,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
<ChevronLeft size={14} /> {openProduct.title}
|
||||
</button>
|
||||
) : "引用平台素材"}
|
||||
<span>// 资产库 · 模特库 · 商品库</span>
|
||||
<span>成品库 · 模特库 · 商品库</span>
|
||||
</div>
|
||||
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
|
||||
</div>
|
||||
@@ -296,7 +296,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
</label>
|
||||
)}
|
||||
<span className="mono fc-lib-hint">
|
||||
{imageOnly ? "// 首尾帧只列图片素材" : "// 平台生成的直接可用,上传的要审核通过"}
|
||||
{imageOnly ? "首尾帧只列图片素材" : "平台生成的直接可用,上传的要审核通过"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -305,7 +305,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><User size={22} strokeWidth={1.5} /></span>
|
||||
<h3>{emptyText}</h3>
|
||||
<p>// 去模特库添加模特后可在这里引用形象图与三视图</p>
|
||||
<p>去模特库添加模特后可在这里引用形象图与三视图</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
@@ -319,7 +319,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
<div className="fc-lib-name" title={model.name}>{model.name}</div>
|
||||
<div className="fc-lib-model-picks">
|
||||
{items.length === 0
|
||||
? <span className="fc-lib-count mono">// 还没有可引用的图</span>
|
||||
? <span className="fc-lib-count mono">还没有可引用的图</span>
|
||||
: items.map((item) => (
|
||||
<button key={item.id} type="button" className="btn btn-sm" onClick={() => pickModelAsset(model, item)}>
|
||||
{item.tag}
|
||||
@@ -336,7 +336,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><Package size={22} strokeWidth={1.5} /></span>
|
||||
<h3>{emptyText}</h3>
|
||||
<p>// 去商品库建商品后可在这里引用它的素材</p>
|
||||
<p>去商品库建商品后可在这里引用它的素材</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fc-lib-assets">
|
||||
@@ -348,7 +348,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
{product.cover_preview_url ? <img src={product.cover_preview_url} alt={product.title} /> : <Package size={20} />}
|
||||
</div>
|
||||
<div className="fc-lib-name" title={product.title}>{product.title}</div>
|
||||
<div className="fc-lib-count mono">// {product.category || "未分类"}</div>
|
||||
<div className="fc-lib-count mono">{product.category || "未分类"}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -357,7 +357,7 @@ export function PlatformLibraryModal({ open, imageOnly, onClose, onPick, notify
|
||||
<div className="empty-state show">
|
||||
<span className="ic-empty"><Images size={22} strokeWidth={1.5} /></span>
|
||||
<h3>{emptyText}</h3>
|
||||
<p>// 平台生成的素材可直接引用,上传的要先审核通过</p>
|
||||
<p>平台生成的素材可直接引用,上传的要先审核通过</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -321,7 +321,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
|
||||
style={{ left: Math.min(menuPos.left, window.innerWidth - 292), top: Math.max(12, menuPos.top - 8) }}
|
||||
>
|
||||
<div className="fc-mention-menu-inner">
|
||||
<div className="fc-mention-menu-head mono">// 引用素材</div>
|
||||
<div className="fc-mention-menu-head mono">引用素材</div>
|
||||
{menuItems.map((item, i) => (
|
||||
<button
|
||||
key={item.key}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
||||
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, ChevronDown } from "lucide-react";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
@@ -39,10 +40,9 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
}, [open]);
|
||||
return (
|
||||
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
|
||||
<button type="button" className="fc-dd-btn" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
|
||||
<span className="fc-dd-lbl mono">{label}</span>
|
||||
<span className="fc-dd-val">{display}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
|
||||
<button type="button" className="fc-chip" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
|
||||
{label} <strong>{display}</strong>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="fc-dd-menu">
|
||||
@@ -56,7 +56,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
onClick={() => { if (!item.disabled) { onSelect(item.value); setOpen(false); } }}
|
||||
>
|
||||
<span className="ti">{item.label}</span>
|
||||
{item.desc && <span className="de mono">{item.desc}</span>}
|
||||
{item.desc && <span className="de">{item.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -103,14 +103,13 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
|
||||
return (
|
||||
<div className="fc-toolbar">
|
||||
<div className="fc-toolbar-l">
|
||||
<div className="fc-chips">
|
||||
<FcDropdown
|
||||
label="模型"
|
||||
display={FC_MODELS.find((m) => m.name === model)?.label || model}
|
||||
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))}
|
||||
onSelect={(value) => {
|
||||
onModelChange(value);
|
||||
// 非标准档不支持 1080P/4K:自动回落 720P(与 jimeng 行为一致)
|
||||
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p");
|
||||
}}
|
||||
/>
|
||||
@@ -142,9 +141,8 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
onSelect={(value) => onDurationChange(Number(value))}
|
||||
/>
|
||||
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
|
||||
<button type="button" className="fc-dd-btn" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
|
||||
<span className="fc-dd-lbl mono">种子</span>
|
||||
<span className="fc-dd-val">{seed === -1 ? "随机" : seed}</span>
|
||||
<button type="button" className="fc-chip" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
|
||||
种子 <strong>{seed === -1 ? "随机" : seed}</strong>
|
||||
</button>
|
||||
{seedOpen && (
|
||||
<div className="fc-dd-menu fc-seed-menu">
|
||||
@@ -161,18 +159,19 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
/>
|
||||
<button type="button" className="btn btn-sm" onClick={() => { onSeedChange(-1); setSeedOpen(false); }}>随机</button>
|
||||
</div>
|
||||
<div className="fc-seed-hint mono">// -1 = 随机;相同种子可复现相似结果</div>
|
||||
<div className="fc-seed-hint">-1 = 随机;相同种子可复现相似结果</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="fc-toolbar-r">
|
||||
<span className="fc-estimate mono" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
||||
<span className="fc-estimate" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
||||
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}>清空</button>
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
||||
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成 →"}
|
||||
<button type="button" className="fc-clear" onClick={onClear}>清空</button>
|
||||
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
||||
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClo
|
||||
{task.seed_used != null && ` · seed ${task.seed_used}`}
|
||||
{` · ${Number(task.actual_cost || 0)} 积分`}
|
||||
</div>
|
||||
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
|
||||
{task.fallback_note && <div className="fc-player-warn mono">{task.fallback_note}</div>}
|
||||
<div className="fc-player-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={onDownload}><Download size={13} /> 下载</button>
|
||||
<button type="button" className="btn btn-sm" onClick={onReuse}><RotateCcw size={13} /> 再次生成</button>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function SystemLoading({
|
||||
) : null}
|
||||
|
||||
<div className="system-loading-foot mono">
|
||||
<span>{reference ? `// REF · ${reference}` : "// DATA SYNC"}</span>
|
||||
<span>{reference ? `REF · ${reference}` : "DATA SYNC"}</span>
|
||||
<span>{state === "error" ? "RETRY AVAILABLE" : "DATA HYDRATION"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +64,7 @@ export function SystemLoading({
|
||||
export function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="skeleton-loading-tag">// 加载中…</div>
|
||||
<div className="skeleton-loading-tag">加载中…</div>
|
||||
<div className="skeleton-grid">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div className="skeleton-block skeleton-card" key={i} />
|
||||
@@ -77,7 +77,7 @@ export function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
export function SkeletonRows({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="skeleton-loading-tag">// 加载中…</div>
|
||||
<div className="skeleton-loading-tag">加载中…</div>
|
||||
<div className="skeleton-rows">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div className="skeleton-block skeleton-line" key={i} />
|
||||
|
||||
@@ -123,7 +123,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
<div className="actorlib" role="dialog" aria-modal="true" aria-label="模特库">
|
||||
<div className="actorlib-h">
|
||||
<h2>{mode === "replace" ? "模特库 · 选择模特" : "模特库"}</h2>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>{mode === "replace" ? "点模特卡即选用应用" : "团队模特 · 官方模板"}</span>
|
||||
<button className="x" type="button" aria-label="关闭" onClick={onClose}>
|
||||
<svg width="16" height="16" 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>
|
||||
@@ -147,7 +147,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
<div className="actorlib-studio-left">
|
||||
{studioMode === "ai" ? (
|
||||
<div className="actorlib-studio-ai">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 描述模特形象(年龄 / 风格 / 妆造 / 背景)→ 生成候选</div>
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 描述模特形象(年龄 / 风格 / 妆造 / 背景)→ 生成候选</div>
|
||||
<textarea className="asset-prompt-edit" rows={3} placeholder="如:25 岁都市白领女性,通勤淡妆,简约棚拍背景,9:16 竖屏正面半身" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 12 }} onClick={() => void genCandidates()}>
|
||||
{busy ? "生成中…" : "生成立绘"}
|
||||
@@ -155,18 +155,18 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
</div>
|
||||
) : (
|
||||
<div className="actorlib-studio-upload">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>// 1 上传本地模特形象图 → 右侧命名并保存</div>
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 上传本地模特形象图 → 右侧命名并保存</div>
|
||||
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><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>
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>// JPG / PNG / WEBP</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>JPG / PNG / WEBP</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||
</div>
|
||||
)}
|
||||
{candidates.length > 0 && (
|
||||
<>
|
||||
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>// 2 选一张形象图 → 右侧命名并保存模特</div>
|
||||
<div className="muted mono" style={{ fontSize: 12, margin: "14px 0 6px", letterSpacing: ".04em" }}>2 选一张形象图 → 右侧命名并保存模特</div>
|
||||
<div className="actorlib-grid">
|
||||
{candidates.map((a) => {
|
||||
const url = previewOf(a);
|
||||
@@ -192,7 +192,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
<div className={`placeholder actor-thumb${previewOf(picked) ? " has-mock-media" : ""}`} style={previewOf(picked) ? mediaStyle(previewOf(picked)) : undefined}>
|
||||
{!previewOf(picked) && <span className="ph-frame">{picked.name}</span>}
|
||||
</div>
|
||||
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>// 3 给模特命名</label>
|
||||
<label className="muted mono" style={{ fontSize: 12, margin: "12px 0 4px", display: "block", letterSpacing: ".04em" }}>3 给模特命名</label>
|
||||
<input className="asset-prompt-edit" style={{ minHeight: 0, height: 36 }} placeholder="如:都市白领 · 小林" value={modelName} onChange={(e) => setModelName(e.target.value)} />
|
||||
<button className="btn btn-primary" type="button" disabled={busy} style={{ marginTop: 14, width: "100%" }} onClick={() => void saveModel()}>
|
||||
{busy ? "保存中…" : "保存模特"}
|
||||
@@ -200,7 +200,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
</div>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 220, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// {studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
|
||||
<span className="ph-frame">{studioMode === "ai" ? "生成候选后,选一张进入这里" : "上传图片后,在这里命名并保存模特"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -209,7 +209,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
) : (
|
||||
<div className="actorlib-body">
|
||||
<div className="actorlib-toolbar">
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 共 {list.length} 位模特</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>共 {list.length} 位模特</span>
|
||||
<span className="spacer" style={{ flex: 1 }}></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" onClick={() => { setStudio(true); setStudioMode("ai"); }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 5v14M5 12h14" /></svg>
|
||||
@@ -245,7 +245,7 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
</>
|
||||
) : (
|
||||
<div className="placeholder" style={{ minHeight: 160, flexDirection: "column", gap: 10 }}>
|
||||
<span className="ph-frame">// 模特库暂无模特 · 点右上“添加模特”创建</span>
|
||||
<span className="ph-frame">模特库暂无模特 · 点右上“添加模特”创建</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "// CONFIRM", icon, onCancel, onConfirm, dismissable = true }: {
|
||||
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
@@ -148,7 +148,7 @@ export function ConfirmModal({ open, title, detail, confirmText, subtitle = "//
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={dismissable ? onCancel : undefined}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}<span>{subtitle}</span></div></div>
|
||||
<div className="modal-h"><div className="ic-m">{icon ?? <Shield size={16} />}</div><div className="ti">{title}{subtitle ? <span>{subtitle}</span> : null}</div></div>
|
||||
<div className="modal-b">{detail}</div>
|
||||
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export function SuccessModal({ open, title, detail, actions, close }: {
|
||||
<div className={`modal-bg${show ? " show" : ""}`} onClick={close}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}<span>// SUCCESS</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="modal-h"><div className="ic-m"><CheckCircle2 size={16} /></div><div className="ti">{title}</div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
|
||||
<div className="modal-b">{detail}</div>
|
||||
<div className="modal-f">{actions}</div>
|
||||
</div>
|
||||
@@ -199,5 +199,5 @@ export function Drawer({ title, open, close, children }: { title: string; open:
|
||||
}
|
||||
|
||||
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
|
||||
return <div className="empty-state show"><span className="ic-empty"><Inbox size={22} strokeWidth={1.5} aria-hidden="true" /></span><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
|
||||
return <div className="empty-state show"><span className="ic-empty"><Inbox size={22} strokeWidth={1.5} aria-hidden="true" /></span><h3>{title}</h3><p>先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageS
|
||||
|
||||
return (
|
||||
<div className={`list-pager${sticky ? " sticky" : ""}`}>
|
||||
<span className="total">// 共 {total} 条 · 第 {cur} / {totalPages} 页</span>
|
||||
<span className="total">共 {total} 条 · 第 {cur} / {totalPages} 页</span>
|
||||
<div className="pages">
|
||||
<button type="button" disabled={cur === 1} onClick={() => onChange(cur - 1)} aria-label="上一页">‹</button>
|
||||
{pageWindow(cur, totalPages).map((p, i) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent, DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowLeft, Check, ChevronDown, Image as ImageIcon, Plus, ShieldCheck, Trash2, Upload, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { useBodyScrollLock, ConfirmModal } from "./overlays";
|
||||
import type { Product } from "../types";
|
||||
@@ -21,22 +22,22 @@ export type ProductCreatePayload = {
|
||||
category: string;
|
||||
target_audience?: string;
|
||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||
// upload-first:图先进桶,创建时带 asset id 一块进来(cover_asset=主图,images=全部)
|
||||
cover_asset?: string;
|
||||
images?: Array<{ asset: string; sort_order: number; is_primary?: boolean }>;
|
||||
};
|
||||
|
||||
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
|
||||
const PF_MAX = 5;
|
||||
const PF_MAX = 9;
|
||||
const PF_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// 抽屉内主图条目:拖入即传 /assets/upload/ 进桶,assetId 是返回的素材 id;url 仅作本地缩略预览
|
||||
type PfImage = { id: string; file: File; url: string; assetId?: string; status: "uploading" | "done" | "error" };
|
||||
|
||||
const pfUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||
|
||||
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。
|
||||
// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为
|
||||
// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。
|
||||
function padIndex(index: number) {
|
||||
return String(index + 1).padStart(2, "0");
|
||||
}
|
||||
|
||||
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现。
|
||||
export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptions = PC_CAT_OPTIONS }: {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
@@ -49,34 +50,32 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
useBodyScrollLock(open);
|
||||
const [title, setTitle] = useState("");
|
||||
const [businessType, setBusinessType] = useState<BusinessType>("ecommerce");
|
||||
const [category, setCategory] = useState(PC_CAT_OPTIONS[0]);
|
||||
const [category, setCategory] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
// 卖点 = 一组可直接编辑的输入行(每行一条);点「添加卖点」按钮新增一条空行,
|
||||
// 不再靠「在一个输入框里回车隐晦追加」(YYX#17)。
|
||||
const [bullets, setBullets] = useState<string[]>([]);
|
||||
const [bullets, setBullets] = useState<string[]>([""]);
|
||||
const [images, setImages] = useState<PfImage[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [titleError, setTitleError] = useState(false);
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const [categoryError, setCategoryError] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// 抽屉内就地 toast(组件无全局 Shell.toast,这里自给一条,镜像 V1 反馈文案)
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const [openSelect, setOpenSelect] = useState<"" | "type" | "cat">("");
|
||||
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
|
||||
// YYX#24:填了内容中途退出 → 二次确认
|
||||
const [confirmExit, setConfirmExit] = useState(false);
|
||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement | null>(null);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 始终指向最新 images:addImages 里同步算余量 + 发上传,不靠会滞后的 effect/闭包
|
||||
const imagesRef = useRef<PfImage[]>([]);
|
||||
imagesRef.current = images;
|
||||
const isLocal = businessType === "local_life";
|
||||
const activeCatOptions = isLocal ? catOptionsFor(businessType) : (catOptions.length ? catOptions : PC_CAT_OPTIONS);
|
||||
|
||||
function flash(title: string, sub: string) {
|
||||
function flash(nextTitle: string, sub: string) {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
setToast({ title, sub });
|
||||
setToast({ title: nextTitle, sub });
|
||||
toastTimer.current = setTimeout(() => setToast(null), 2600);
|
||||
}
|
||||
// 卸载时清掉 toast 定时器 + 释放残留的预览 objectURL
|
||||
|
||||
useEffect(() => () => {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
}, []);
|
||||
@@ -84,33 +83,51 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
setBusinessType("ecommerce");
|
||||
setCategory(PC_CAT_OPTIONS[0]);
|
||||
setCategory("");
|
||||
setTarget("");
|
||||
setBullets([]);
|
||||
setBullets([""]);
|
||||
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
||||
setDragOver(false);
|
||||
setTitleError(false);
|
||||
setCategoryError(false);
|
||||
setGalleryOpen(false);
|
||||
setOpenSelect("");
|
||||
}
|
||||
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
|
||||
useEffect(() => { if (open) resetForm(); }, [open]);
|
||||
|
||||
// YYX#24:表单是否已填内容(有任一内容即视为「编辑中」)
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setGalleryOpen(false);
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
const timer = window.setTimeout(() => nameRef.current?.focus(), 220);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openSelect) return;
|
||||
const closeMenu = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".pcd-select")) setOpenSelect("");
|
||||
};
|
||||
document.addEventListener("click", closeMenu);
|
||||
return () => document.removeEventListener("click", closeMenu);
|
||||
}, [openSelect]);
|
||||
|
||||
function isDirty() {
|
||||
return Boolean(title.trim() || businessType !== "ecommerce" || target.trim() || bullets.some((b) => b.trim()) || images.length);
|
||||
return Boolean(title.trim() || businessType !== "ecommerce" || category || target.trim() || bullets.some((b) => b.trim()) || images.length);
|
||||
}
|
||||
|
||||
function changeBusinessType(next: BusinessType) {
|
||||
setBusinessType(next);
|
||||
const nextOpts = catOptionsFor(next);
|
||||
if (!nextOpts.includes(category)) setCategory(nextOpts[0]);
|
||||
if (category && !nextOpts.includes(category)) setCategory("");
|
||||
}
|
||||
// 关闭入口统一走这里:有内容则先二次确认,否则直接关
|
||||
|
||||
function guardedClose() {
|
||||
if (isDirty()) setConfirmExit(true);
|
||||
else close();
|
||||
}
|
||||
|
||||
// upload-first:任一图处于「uploading」且未在途 → 立刻传进桶(/assets/upload/),拿到 assetId 标 done
|
||||
async function uploadOne(entry: PfImage) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
@@ -125,18 +142,20 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
}
|
||||
}
|
||||
|
||||
// 多图收编:超限/计数 toast 对齐 V1 pfAdd()。★ 必须同步读出 File(Array.from 立刻取),
|
||||
// 否则 pickImage 紧跟着的 input.value="" 会清空 input.files,延迟读就成空 → 第一次点没反应。
|
||||
function addImages(fileList: FileList | File[] | null) {
|
||||
if (!fileList) return;
|
||||
const picked = Array.from(fileList).filter((file) => file.type.startsWith("image/"));
|
||||
if (picked.length === 0) return;
|
||||
const oversized = picked.filter((file) => file.size > PF_MAX_BYTES);
|
||||
const sized = picked.filter((file) => file.size <= PF_MAX_BYTES);
|
||||
const room = PF_MAX - imagesRef.current.length;
|
||||
if (room <= 0) { flash("已达上限", `${PF_MAX} / ${PF_MAX} 张`); return; }
|
||||
const incoming = picked.slice(0, room).map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file), status: "uploading" as const }));
|
||||
if (oversized.length) flash("已忽略超大图片", "单张不超过 10MB");
|
||||
const incoming = sized.slice(0, room).map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file), status: "uploading" as const }));
|
||||
if (!incoming.length) return;
|
||||
setImages((list) => [...list, ...incoming]);
|
||||
flash("上传中", `+ ${incoming.length} 张 · 共 ${imagesRef.current.length + incoming.length} / ${PF_MAX}`);
|
||||
incoming.forEach((entry) => void uploadOne(entry)); // 立即发起上传,不靠 effect 时序
|
||||
incoming.forEach((entry) => void uploadOne(entry));
|
||||
}
|
||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
addImages(event.target.files);
|
||||
@@ -144,12 +163,20 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
}
|
||||
function removeImage(id: string) {
|
||||
setImages((list) => {
|
||||
const hit = list.find((item) => item.id === id);
|
||||
if (hit) URL.revokeObjectURL(hit.url);
|
||||
return list.filter((item) => item.id !== id);
|
||||
const next = list.filter((item) => {
|
||||
if (item.id !== id) return true;
|
||||
URL.revokeObjectURL(item.url);
|
||||
return false;
|
||||
});
|
||||
if (!next.length) setGalleryOpen(false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// YYX#22:上传失败后「重新上传」—— 把该图置回 uploading 再发一次
|
||||
function clearImages() {
|
||||
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
||||
setGalleryOpen(false);
|
||||
flash("已删除上传图片", "可重新选择商品图");
|
||||
}
|
||||
function retryUpload(id: string) {
|
||||
const hit = imagesRef.current.find((item) => item.id === id);
|
||||
if (!hit) return;
|
||||
@@ -161,7 +188,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
imgInputRef.current?.click();
|
||||
}
|
||||
|
||||
// 拖拽上传:dragover 高亮边框,drop 收图(对齐 V1 pfZone 事件)
|
||||
function onZoneDragOver(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(true); }
|
||||
function onZoneDragLeave(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(false); }
|
||||
function onZoneDrop(event: DragEvent<HTMLDivElement>) {
|
||||
@@ -170,7 +196,6 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
// 点「添加卖点」→ 追加一条空的可编辑卖点行(显式新增,不靠回车隐晦添加)
|
||||
function addBullet() {
|
||||
setBullets((list) => [...list, ""]);
|
||||
}
|
||||
@@ -178,24 +203,26 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
setBullets((list) => list.map((item, position) => (position === index ? value : item)));
|
||||
}
|
||||
function removeBullet(index: number) {
|
||||
setBullets((list) => list.filter((_, position) => position !== index));
|
||||
setBullets((list) => (list.length <= 1 ? list : list.filter((_, position) => position !== index)));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const name = title.trim();
|
||||
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
|
||||
// 电商必须有主图;本地生活团购是虚拟商品,允许不传图。
|
||||
if (!isLocal && images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
|
||||
// upload-first:图必须先全部传完进桶(拿到 assetId)才能带进创建
|
||||
const missing: string[] = [];
|
||||
if (!name) { setTitleError(true); missing.push("商品名称"); }
|
||||
if (!category) { setCategoryError(true); missing.push("商品品类"); }
|
||||
const finalBullets = bullets.map((item) => item.trim()).filter(Boolean);
|
||||
if (finalBullets.length === 0) missing.push("核心卖点");
|
||||
if (!isLocal && images.length === 0) missing.push("商品图片");
|
||||
if (images.some((im) => im.status === "uploading")) { flash("图片上传中", "请等图片传完再创建"); return; }
|
||||
if (images.some((im) => im.status === "error" || !im.assetId)) { flash("有图片未传成功", "请移除失败的图后重试"); return; }
|
||||
// 卖点行可能有空行(用户点了「添加卖点」还没填):提交时过滤掉空行再去重判空
|
||||
const finalBullets = bullets.map((item) => item.trim()).filter(Boolean);
|
||||
if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条"); return; }
|
||||
if (missing.length) {
|
||||
flash("请完善必填信息", missing.join("、"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 图已在桶里,创建时直接带上 cover_asset(主图)+ images(全部),一步落库、不再二次传图
|
||||
const created = (await onCreate({
|
||||
title: name,
|
||||
business_type: businessType,
|
||||
@@ -209,7 +236,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
}
|
||||
: {}),
|
||||
})) || null;
|
||||
if (!created) { flash("商品创建失败", "请稍后重试"); return; } // 不静默:建库失败也给提示
|
||||
if (!created) { flash("商品创建失败", "请稍后重试"); return; }
|
||||
close();
|
||||
onCreated?.(created);
|
||||
} finally {
|
||||
@@ -217,166 +244,255 @@ export function ProductCreateDrawer({ open, close, onCreate, onCreated, catOptio
|
||||
}
|
||||
}
|
||||
|
||||
const previewName = images.length === 0
|
||||
? "尚未上传"
|
||||
: images.length === 1
|
||||
? images[0].file.name
|
||||
: `已上传 ${images.length} 张 · 点击查看九宫格`;
|
||||
const uploading = images.some((im) => im.status === "uploading");
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className={`drawer-bg${open ? " show" : ""}`} onClick={guardedClose} />
|
||||
<aside className={`drawer pc-drawer${open ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!open}>
|
||||
<div className="drawer-h">
|
||||
<h3>新建商品</h3>
|
||||
<button className="x" type="button" onClick={guardedClose} 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${titleError ? " is-error" : ""}`} value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} />
|
||||
{titleError && <div className="pc-err-note">请先填写商品名称</div>}
|
||||
<div className={`pcd-layer${open ? " open" : ""}`} aria-hidden={!open}>
|
||||
<button type="button" className="pcd-scrim" aria-label="关闭新建商品面板" onClick={guardedClose} />
|
||||
<aside className="pcd-drawer" role="dialog" aria-modal="true" aria-labelledby="productDrawerTitle">
|
||||
<header className="pcd-head">
|
||||
<div>
|
||||
<h2 id="productDrawerTitle">创建新商品</h2>
|
||||
<p>创建后可直接用于当前项目</p>
|
||||
</div>
|
||||
<button type="button" className="pcd-close" aria-label="关闭" onClick={guardedClose}><X /></button>
|
||||
</header>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">类目<span className="req">*</span></label>
|
||||
<select className="select" value={businessType} onChange={(event) => changeBusinessType(event.target.value as BusinessType)}>
|
||||
{BUSINESS_TYPE_KEYS.map((key) => <option key={key} value={key}>{BUSINESS_TYPES[key]}</option>)}
|
||||
</select>
|
||||
{isLocal && <div className="pc-type-hint mono">// 团购按虚拟商品走主流程,无需实物三视图</div>}
|
||||
</div>
|
||||
<div className="pcd-body">
|
||||
<div className="pcd-form">
|
||||
<label className="pcd-field">
|
||||
<span className="pcd-label"><span>商品名称 <span className="pcd-req">*</span></span><small>必填</small></span>
|
||||
<input
|
||||
ref={nameRef}
|
||||
value={title}
|
||||
maxLength={100}
|
||||
aria-invalid={titleError}
|
||||
placeholder="例如:净颜精华、轻醒咖啡"
|
||||
onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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)}>
|
||||
{activeCatOptions.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={isLocal ? "例: 附近上班族、带娃家庭、周末聚餐" : "例: 22-32 岁女性、敏感肌、办公室通勤"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">{isLocal ? "门店图 / 套餐海报" : "商品主图"}{isLocal ? <span className="opt">(选填)</span> : <span className="req">*</span>}</label>
|
||||
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
|
||||
<div className="pf-upload-row">
|
||||
<div
|
||||
className={`pf-upload-zone${dragOver ? " is-dragover" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
|
||||
onDragOver={onZoneDragOver}
|
||||
onDragLeave={onZoneDragLeave}
|
||||
onDrop={onZoneDrop}
|
||||
>
|
||||
<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">{isLocal ? "// 店招、套餐海报均可 · 团购无实物可不传" : "// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB"}</div>
|
||||
</div>
|
||||
<div className="pf-example">
|
||||
<div className="ex-h">示例图</div>
|
||||
<div className="ex-grid">
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-earbuds.png" alt="示例:蓝牙耳机" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-mask.png" alt="示例:面膜" loading="lazy" /></div>
|
||||
<div className="ex-thumb ex-thumb--img"><img src="/exact/assets/mock/product-air-fryer.png" alt="示例:空气炸锅" loading="lazy" /></div>
|
||||
</div>
|
||||
<div className="ex-d">{isLocal ? "有门店或套餐图,后续脚本和出图更准" : "优质的商品图有助于生成更好的素材效果"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pf-grid">
|
||||
{images.map((item, idx) => (
|
||||
<div className={`pf-thumb pf-${item.status}`} key={item.id}>
|
||||
<img src={item.url} alt="商品主图预览" />
|
||||
{idx === 0 && <span className="pf-cover">主图</span>}
|
||||
{item.status === "uploading" && <span className="pf-state"><span className="spinner" aria-hidden="true" /></span>}
|
||||
{/* YYX#22:上传失败 —— 黑色遮罩 + 感叹号 + 提示 + 重新上传胶囊 */}
|
||||
{item.status === "error" && (
|
||||
<div className="pf-fail-mask">
|
||||
<span className="pf-fail-ic" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>
|
||||
</span>
|
||||
<span className="pf-fail-t">上传失败</span>
|
||||
<button className="pf-retry" type="button" onClick={() => retryUpload(item.id)}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36" /><path d="M21 3v6h-6" /></svg>
|
||||
重新上传
|
||||
<div className="pcd-row">
|
||||
<div className="pcd-field">
|
||||
<span className="pcd-label"><span>类目 <span className="pcd-req">*</span></span><small>必选</small></span>
|
||||
<div className={`pcd-select${openSelect === "type" ? " open" : ""}`}>
|
||||
<button type="button" className="pcd-select-trigger" aria-haspopup="listbox" aria-expanded={openSelect === "type"} onClick={() => setOpenSelect((c) => (c === "type" ? "" : "type"))}>
|
||||
<span>{BUSINESS_TYPES[businessType]}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="pcd-select-menu" role="listbox">
|
||||
{BUSINESS_TYPE_KEYS.map((key) => (
|
||||
<button type="button" className={`pcd-select-option${businessType === key ? " selected" : ""}`} key={key} onClick={() => { changeBusinessType(key); setOpenSelect(""); }}>
|
||||
{BUSINESS_TYPES[key]}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="pf-x" type="button" title="删除" aria-label="删除该图" onClick={() => removeImage(item.id)}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isLocal && <span className="pcd-hint">团购按虚拟商品走主流程,无需实物三视图</span>}
|
||||
</div>
|
||||
|
||||
<div className="pcd-field">
|
||||
<span className="pcd-label"><span>选择品类 <span className="pcd-req">*</span></span><small>必选</small></span>
|
||||
<div className={`pcd-select${openSelect === "cat" ? " open" : ""}`}>
|
||||
<button type="button" className={`pcd-select-trigger${categoryError ? " is-error" : ""}`} aria-haspopup="listbox" aria-expanded={openSelect === "cat"} onClick={() => setOpenSelect((c) => (c === "cat" ? "" : "cat"))}>
|
||||
<span className={category ? "" : "is-placeholder"}>{category || "请选择商品品类"}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="pcd-select-menu" role="listbox">
|
||||
{activeCatOptions.map((option) => (
|
||||
<button type="button" className={`pcd-select-option${category === option ? " selected" : ""}`} key={option} onClick={() => { setCategory(option); setCategoryError(false); setOpenSelect(""); }}>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="pcd-field">
|
||||
<span className="pcd-label"><span>目标人群</span><small>选填</small></span>
|
||||
<input
|
||||
value={target}
|
||||
onChange={(event) => setTarget(event.target.value)}
|
||||
placeholder={isLocal ? "例如:附近上班族、带娃家庭、周末聚餐" : "例如:22–32 岁女性、敏感肌、通勤人群"}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<section className="pcd-block" aria-labelledby="productImageTitle">
|
||||
<div className="pcd-block-head">
|
||||
<div>
|
||||
<strong id="productImageTitle">{isLocal ? "门店图 / 套餐海报" : "商品图片"}{isLocal ? "" : <span className="pcd-req"> *</span>}</strong>
|
||||
<p>最多上传九张商品图,两张及以上将自动切换为缩略图预览</p>
|
||||
</div>
|
||||
<span>JPG / PNG · 单张不超过 10MB</span>
|
||||
</div>
|
||||
<input ref={imgInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple hidden onChange={pickImage} />
|
||||
<div className="pcd-upload-grid">
|
||||
<div
|
||||
className={`pcd-upload${dragOver ? " is-dragover" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
|
||||
onDragOver={onZoneDragOver}
|
||||
onDragLeave={onZoneDragLeave}
|
||||
onDrop={onZoneDrop}
|
||||
>
|
||||
<span className="pcd-upload-hit">
|
||||
<span className="pcd-upload-icon"><Upload /></span>
|
||||
<strong>点击上传商品图片</strong>
|
||||
<small>{isLocal ? "店招、套餐海报均可 · 团购无实物可不传" : "最多九张,建议使用清晰商品实拍图"}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pcd-preview">
|
||||
<div
|
||||
className={`pcd-preview-frame${images.length ? " has-image" : ""}${images.length > 1 ? " multi-image" : ""}`}
|
||||
role="button"
|
||||
tabIndex={images.length ? 0 : -1}
|
||||
aria-label="打开商品图片九宫格预览"
|
||||
onClick={() => { if (images.length) setGalleryOpen(true); }}
|
||||
onKeyDown={(event) => { if ((event.key === "Enter" || event.key === " ") && images.length) { event.preventDefault(); setGalleryOpen(true); } }}
|
||||
>
|
||||
{images.length === 1 && <img src={images[0].url} alt={images[0].file.name} />}
|
||||
{images.length > 1 && (
|
||||
<div className="pcd-thumb-grid">
|
||||
{images.slice(0, 4).map((item) => (
|
||||
<span className="pcd-thumb" key={item.id}>
|
||||
<img src={item.url} alt={item.file.name} />
|
||||
<button type="button" className="pcd-thumb-x" aria-label={`删除图片 ${item.file.name}`} onClick={(event) => { event.stopPropagation(); removeImage(item.id); }}>
|
||||
<X />
|
||||
</button>
|
||||
{item.status === "uploading" && <span className="pcd-thumb-state"><span className="spinner" aria-hidden="true" /></span>}
|
||||
{item.status === "error" && (
|
||||
<button type="button" className="pcd-thumb-retry" onClick={(event) => { event.stopPropagation(); retryUpload(item.id); }}>重试</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{images.length > 1 && <span className="pcd-count">{images.length} 张</span>}
|
||||
{images.length === 1 && (
|
||||
<button type="button" className="pcd-clear" aria-label="删除已上传图片" onClick={(event) => { event.stopPropagation(); clearImages(); }}>
|
||||
<Trash2 />
|
||||
</button>
|
||||
)}
|
||||
{images.length === 1 && images[0].status === "uploading" && <span className="pcd-thumb-state"><span className="spinner" aria-hidden="true" /></span>}
|
||||
{images.length === 1 && images[0].status === "error" && (
|
||||
<button type="button" className="pcd-retry-cover" onClick={(event) => { event.stopPropagation(); retryUpload(images[0].id); }}>重新上传</button>
|
||||
)}
|
||||
{!images.length && (
|
||||
<span className="pcd-preview-ph">
|
||||
<ImageIcon />
|
||||
<span>缩略图预览</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{previewName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pcd-block" aria-labelledby="sellingPointTitle">
|
||||
<div className="pcd-block-head pcd-sp-head">
|
||||
<div>
|
||||
<strong id="sellingPointTitle">核心卖点 <span className="pcd-req">*</span></strong>
|
||||
<p>每条只写一个卖点,按 Enter 可继续添加</p>
|
||||
</div>
|
||||
<button type="button" className="pcd-add-point" onClick={addBullet}>
|
||||
<Plus />
|
||||
<span>添加卖点</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="pcd-points">
|
||||
{bullets.map((bullet, index) => (
|
||||
<div className="pcd-point" key={index}>
|
||||
<span className="pcd-point-idx">{padIndex(index)}</span>
|
||||
<input
|
||||
value={bullet}
|
||||
placeholder={index === 0
|
||||
? (isLocal ? "例如:双人下午茶套餐,含蛋糕+饮品" : "例如:7 天改善干燥,敏感肌可用")
|
||||
: "继续填写一个核心卖点"}
|
||||
onChange={(event) => updateBullet(index, event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
|
||||
/>
|
||||
<button type="button" className="pcd-point-x" aria-label="删除卖点" disabled={bullets.length <= 1} onClick={() => removeBullet(index)}>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="pcd-foot">
|
||||
<span><ShieldCheck />商品创建后仍可在商品库继续完善</span>
|
||||
<div className="pcd-actions">
|
||||
<button type="button" className="pcd-ghost" onClick={guardedClose}>取消</button>
|
||||
<button type="button" className="pcd-primary" disabled={saving || uploading} onClick={submit}>
|
||||
<Check />
|
||||
<span>{saving ? "创建中…" : uploading ? "图片上传中…" : "创建商品"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{toast && (
|
||||
<div className="pcd-toast" role="status" aria-live="polite">
|
||||
<span className="pcd-toast-ic"><Check /></span>
|
||||
<div>{toast.title}<small>{toast.sub}</small></div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{galleryOpen && (
|
||||
<div className="pcd-gallery" role="dialog" aria-label="商品图片预览">
|
||||
<header className="pcd-gallery-head">
|
||||
<div>
|
||||
<h2>商品图片预览</h2>
|
||||
<p>九宫格查看已上传商品图,空白位置可继续补充</p>
|
||||
</div>
|
||||
<button type="button" className="pcd-gallery-back" onClick={() => setGalleryOpen(false)}>
|
||||
<ArrowLeft />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="pcd-gallery-body">
|
||||
<div className="pcd-gallery-grid">
|
||||
{Array.from({ length: PF_MAX }, (_, index) => {
|
||||
const item = images[index];
|
||||
if (!item) {
|
||||
return (
|
||||
<button type="button" className="pcd-gallery-slot empty" key={`empty-${index}`} onClick={openPicker}>
|
||||
待上传
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="pcd-gallery-slot" key={item.id}>
|
||||
<img src={item.url} alt={item.file.name} />
|
||||
<span>{padIndex(index)}</span>
|
||||
<button type="button" className="pcd-gallery-x" aria-label={`删除图片 ${item.file.name}`} onClick={() => removeImage(item.id)}>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field pc-field-last">
|
||||
{/* YYX#17/R104:显式「添加卖点」按钮放在字段标题行右侧(独立小按钮,对齐「+ 新增角色」摆法),
|
||||
不嵌在卖点输入框内/整行虚线框里;每条卖点 = 一行可直接编辑的输入,行内回车也追加新行。 */}
|
||||
<div className="field-label-row">
|
||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||
<button type="button" className="bl-add-btn" onClick={addBullet}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
添加卖点
|
||||
</button>
|
||||
</div>
|
||||
<ul className="bullet-list">
|
||||
{bullets.map((bullet, index) => (
|
||||
<li className="bl-item bl-item-edit" key={index}>
|
||||
<span className="num">{index + 1}</span>
|
||||
<input
|
||||
className="bl-input"
|
||||
value={bullet}
|
||||
onChange={(event) => updateBullet(index, event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); addBullet(); } }}
|
||||
placeholder={isLocal ? "例: 双人下午茶套餐,含蛋糕+饮品,周末通用" : "例: 玻尿酸双效保湿,4 小时持久水润"}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGuide && (
|
||||
<div className="pc-guide-note">
|
||||
<strong>// 建好商品的 3 步</strong><br />
|
||||
① 填写商品名称 + 类目(电商/本地生活) + 品类<br />
|
||||
② 电商须上传清晰主图;本地生活/团购无实物可不传<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={guardedClose}>取消</button>
|
||||
<button className="btn btn-primary" type="button" disabled={saving || images.some((im) => im.status === "uploading")} 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>
|
||||
{saving ? "创建中…" : images.some((im) => im.status === "uploading") ? "图片上传中…" : "创建商品"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="pc-toast" role="status" aria-live="polite">
|
||||
<span className="ic-t"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 6" /></svg></span>
|
||||
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
{/* YYX#24:填了内容中途退出 → 二次确认(复用通用 ConfirmModal) */}
|
||||
<ConfirmModal
|
||||
open={confirmExit}
|
||||
title="商品未保存"
|
||||
subtitle="// UNSAVED"
|
||||
|
||||
detail="当前商品信息尚未保存,确定要退出编辑吗?"
|
||||
confirmText="退出编辑"
|
||||
onCancel={() => setConfirmExit(false)}
|
||||
|
||||
Reference in New Issue
Block a user