大量修改UI

This commit is contained in:
Azmat@qq.com
2026-08-19 17:44:56 +08:00
parent 18b3927817
commit a343f47f0c
76 changed files with 11655 additions and 6104 deletions
+14 -61
View File
@@ -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>
+3 -3
View File
@@ -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>
+4 -4
View File
@@ -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>;
}
+1 -1
View File
@@ -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 />
24 ,
</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)}