Files
omniai-web/src/features/more/MorePage.tsx
T
ludan 4530058648 feat: 工具盒视觉重设计 + 个人中心详情弹窗
本次提交对全部工具入口页(MorePage)进行了全面的信息架构和视觉升级,并为个人中心新增卡片点击详情弹窗。

## 工具盒(MorePage)重设计
- 工具卡片增加 useCase 使用场景说明和 tags 标签行,帮助用户快速理解每个工具的适用场景
- 核心工具(Featured)卡片新增 kicker 标题、steps 操作步骤、outcome 产出说明,强化工作流引导
- 新增 ToolComparePanel 组件,为每个工具展示 Before/After 对比示意舞台
- 分类筛选按钮新增计数徽章,展示每个分类下的工具数量
- 页面头部新增 eyebrow(AI Tool Hub)+ 工具概览统计信息
- 最近使用区域增加分类标签副标题
- 空分类场景新增引导式空状态面板
- 全面补充 aria-label 和无障碍键盘支持

## 个人中心详情弹窗
- 新增 ProfileDetailSelection 类型和 openDetailSelection/closeDetailSelection 流程
- 使用 createPortal 渲染详情弹窗,支持代表作和资产两种详情视图
- 弹窗内支持媒体预览(图片/视频)、元数据展示、下载和删除操作
- 列表卡片(代表作/项目/资产)改为 interactive-card,支持键盘 Enter/Space 激活
- 删除项目按钮增加 event.stopPropagation 防止冒泡触发卡片点击
- 弹窗打开时锁定 body 滚动,Esc 键关闭

## App.tsx 适配
- 传递 setTasks 给 ProfilePage,支持代表作移除操作
- 传递 onOpenProject 回调,支持从个人中心打开项目

## CSS 样式升级
- more.css: 全面重设页头布局(grid 三栏)、筛选胶囊、核心卡片 Before/After 舞台、步骤条、响应式适配
- profile.css: 新增详情弹窗 overlay/panel/preview 布局、交互卡片 hover/focus 状态
- dark-green.css: 工具盒与详情弹窗的深绿主题样式约 780 行
2026-06-07 11:42:00 +08:00

387 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CameraOutlined,
ClockCircleOutlined,
ColumnWidthOutlined,
CustomerServiceOutlined,
DashboardOutlined,
DeleteOutlined,
EditOutlined,
HighlightOutlined,
MessageOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
interface MorePageProps {
onSelectView?: (view: WebViewKey) => void;
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
}
type ToolCategory = "image" | "video";
type FilterKey = "all" | ToolCategory | "upcoming";
interface MoreTool {
id: string;
title: string;
text: string;
useCase: string;
tags: string[];
icon: ReactNode;
category: ToolCategory;
target?: WebViewKey;
imageTool?: WebImageWorkbenchTool;
ready: boolean;
badge?: string;
featured?: boolean;
}
type CompareScene =
| "workbench"
| "inpaint"
| "camera"
| "upscale"
| "watermark"
| "dialog"
| "subtitle"
| "digital-human"
| "character"
| "avatar";
const toolCompareScenes: Record<string, CompareScene> = {
workbench: "workbench",
inpaint: "inpaint",
camera: "camera",
upscale: "upscale",
watermarkRemoval: "watermark",
dialogGenerator: "dialog",
subtitleRemoval: "subtitle",
digitalHuman: "digital-human",
characterMix: "character",
avatarConsole: "avatar",
};
function ToolComparePanel({ scene }: { scene: CompareScene }) {
return (
<span className={`more-card__compare more-card__compare--${scene}`} aria-hidden="true">
<span className="more-card__compare-labels">
<span>Before</span>
<span>After</span>
</span>
<span className="more-card__compare-stage">
<span className="more-card__compare-side more-card__compare-side--before">
<span className="more-card__scene-subject" />
<span className="more-card__scene-detail" />
<span className="more-card__scene-overlay" />
</span>
<span className="more-card__compare-divider">
<span />
</span>
<span className="more-card__compare-side more-card__compare-side--after">
<span className="more-card__scene-subject" />
<span className="more-card__scene-detail" />
<span className="more-card__scene-overlay" />
</span>
</span>
</span>
);
}
const tools: MoreTool[] = [
{ id: "workbench", title: "图片工作台", text: "融合、修复、局部增强", useCase: "适合商品图精修、创意合成和局部画面重做", tags: ["热门", "一站式", "商品图"], icon: <EditOutlined />, category: "image", imageTool: "workbench", ready: true, featured: true },
{ id: "inpaint", title: "局部重绘", text: "修掉瑕疵、替换物体、重做局部画面", useCase: "适合快速处理商品瑕疵、人物细节和背景杂物", tags: ["新手推荐", "精修"], icon: <HighlightOutlined />, category: "image", imageTool: "inpaint", ready: true },
{ id: "camera", title: "镜头实验室", text: "快速生成俯拍、特写、广角等商业镜头", useCase: "适合做产品主图、种草图和不同机位方案", tags: ["电商常用", "镜头"], icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "把低清图片或视频提升到可交付质感", useCase: "适合修复旧素材、放大商品图和增强短视频清晰度", tags: ["高清", "交付前"], icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "智能去除图片水印、文字和遮挡元素", useCase: "适合整理素材、清理参考图和恢复画面干净度", tags: ["素材清理", "高频"], icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,快速制作可拖拽编辑的对话框", useCase: "适合剧情海报、社媒截图和角色对白设计", tags: ["内容创作", "可编辑"], icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "擦除视频字幕,让画面重新变干净", useCase: "适合二创前素材整理、短视频重剪和画面修复", tags: ["视频增强", "素材清理"], icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "用一张人像和音频生成口播视频", useCase: "适合品牌讲解、课程口播和带货短视频", tags: ["热门", "口播", "视频"], icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "把人物图迁移到参考视频的动作里", useCase: "适合角色短片、动作复刻和虚拟人内容生产", tags: ["角色视频", "动作"], icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
{ id: "avatarConsole", title: "数字人控制台", text: "管理形象、播报、互动与接入配置", useCase: "适合持续运营数字人、配置品牌形象和复用口播模板", tags: ["运营台", "企业"], icon: <DashboardOutlined />, category: "video", target: "avatarConsole", ready: true },
];
interface FeaturedTool {
id: string;
title: string;
desc: string;
kicker: string;
steps: string[];
outcome: string;
icon: ReactNode;
imageTool?: WebImageWorkbenchTool;
target?: WebViewKey;
category: ToolCategory;
gradient: string;
}
const featuredTools: FeaturedTool[] = [
{
id: "workbench",
title: "图片工作台",
desc: "从一张素材开始,完成精修、合成和二次创作。",
kicker: "图片精修工作流",
steps: ["上传素材", "局部修复", "高清导出"],
outcome: "适合商品图、海报图和创意视觉",
icon: <EditOutlined />,
imageTool: "workbench",
category: "image",
gradient: "linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.06))",
},
{
id: "digitalHuman",
title: "数字人",
desc: "用参考人像和音频,快速生成可交付口播视频。",
kicker: "口播视频工作流",
steps: ["选择人像", "上传音频", "生成视频"],
outcome: "适合品牌讲解、课程和带货短视频",
icon: <CustomerServiceOutlined />,
target: "digitalHuman",
category: "video",
gradient: "linear-gradient(135deg, rgba(13, 148, 136, 0.12), rgba(6, 182, 212, 0.06))",
},
];
const categoryLabels: Record<ToolCategory, string> = {
image: "图像创作",
video: "视频生成",
};
const categoryIcons: Record<ToolCategory, ReactNode> = {
image: <EditOutlined />,
video: <VideoCameraOutlined />,
};
const filters: { key: FilterKey; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "image", label: "图像" },
{ key: "video", label: "视频" },
{ key: "upcoming", label: "即将上线" },
];
const RECENT_STORAGE_KEY = "omniai:more-recent-tools";
const MAX_RECENT = 4;
function getRecentToolIds(): string[] {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
}
function pushRecentToolId(id: string) {
const ids = getRecentToolIds().filter((x) => x !== id);
ids.unshift(id);
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids.slice(0, MAX_RECENT)));
}
function MorePage({ onSelectView, onOpenImageTool }: MorePageProps) {
const [filter, setFilter] = useState<FilterKey>("all");
const [recentIds, setRecentIds] = useState<string[]>(getRecentToolIds);
useEffect(() => {
setRecentIds(getRecentToolIds());
}, []);
const openTool = useCallback((tool: MoreTool) => {
if (!tool.ready) return;
pushRecentToolId(tool.id);
setRecentIds(getRecentToolIds());
if (tool.imageTool && onOpenImageTool) {
onOpenImageTool(tool.imageTool);
return;
}
if (tool.target && onSelectView) {
onSelectView(tool.target);
}
}, [onOpenImageTool, onSelectView]);
const openFeaturedTool = useCallback((tool: FeaturedTool) => {
pushRecentToolId(tool.id);
setRecentIds(getRecentToolIds());
if (tool.imageTool && onOpenImageTool) {
onOpenImageTool(tool.imageTool);
return;
}
if (tool.target && onSelectView) {
onSelectView(tool.target);
}
}, [onOpenImageTool, onSelectView]);
const filteredTools = tools.filter((t) => {
if (t.featured) return false;
if (filter === "all") return true;
if (filter === "upcoming") return !t.ready;
return t.category === filter;
});
const filterCounts: Record<FilterKey, number> = {
all: tools.filter((t) => !t.featured).length,
image: tools.filter((t) => !t.featured && t.category === "image").length,
video: tools.filter((t) => !t.featured && t.category === "video").length,
upcoming: tools.filter((t) => !t.featured && !t.ready).length,
};
const recentTools = recentIds
.map((id) => tools.find((t) => t.id === id))
.filter((t): t is MoreTool => Boolean(t) && (t?.ready ?? false));
const groupedTools = filteredTools.reduce<Record<ToolCategory, MoreTool[]>>((acc, t) => {
if (!acc[t.category]) acc[t.category] = [];
acc[t.category].push(t);
return acc;
}, {} as Record<ToolCategory, MoreTool[]>);
const activeFilterLabel = filters.find((item) => item.key === filter)?.label ?? "全部";
const hasGroupedTools = (["image", "video"] as ToolCategory[]).some((cat) => groupedTools[cat]?.length);
return (
<div className="more-page-v2">
<header className="more-page-v2__header">
<div className="more-page-v2__title-group">
<span className="more-page-v2__eyebrow">AI Tool Hub</span>
<h1></h1>
</div>
<div className="more-page-v2__header-meta" aria-label="工具盒概览">
<span>{tools.filter((tool) => tool.ready).length} </span>
<span>{featuredTools.length} </span>
</div>
<nav className="more-page-v2__filters" aria-label="工具分类筛选">
{filters.map((f) => (
<button
key={f.key}
type="button"
className={filter === f.key ? "is-active" : ""}
aria-pressed={filter === f.key}
onClick={() => setFilter(f.key)}
>
<span>{f.label}</span>
<em>{filterCounts[f.key]}</em>
</button>
))}
</nav>
</header>
<div className="more-page-v2__scroll">
{recentTools.length > 0 && filter === "all" && (
<section className="more-page-v2__section">
<h2 className="more-page-v2__section-title">
<ClockCircleOutlined /> 使
<span>使</span>
</h2>
<div className="more-page-v2__recent-row">
{recentTools.map((tool) => (
<button
key={tool.id}
type="button"
className="more-card more-card--recent"
aria-label={`打开最近使用工具:${tool.title}`}
onClick={() => openTool(tool)}
>
<span className="more-card__icon">{tool.icon}</span>
<span className="more-card__recent-body">
<strong>{tool.title}</strong>
<small>{categoryLabels[tool.category]}</small>
</span>
</button>
))}
</div>
</section>
)}
{filter === "all" && (
<section className="more-page-v2__section more-page-v2__featured">
<h2 className="more-page-v2__section-title">
<ThunderboltOutlined />
</h2>
<div className="more-page-v2__featured-grid">
{featuredTools.map((tool) => (
<button
key={tool.id}
type="button"
className="more-card more-card--featured"
style={{ "--card-gradient": tool.gradient } as CSSProperties}
aria-label={`打开核心工具:${tool.title}${tool.desc}`}
onClick={() => openFeaturedTool(tool)}
>
<span className="more-card__featured-icon">{tool.icon}</span>
<div className="more-card__featured-body">
<span className="more-card__featured-kicker">{tool.kicker}</span>
<strong>{tool.title}</strong>
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
<span className="more-card__featured-desc">{tool.desc}</span>
<span className="more-card__steps" aria-hidden="true">
{tool.steps.map((step) => (
<span key={step}>{step}</span>
))}
</span>
<span className="more-card__outcome">{tool.outcome}</span>
<span className="more-card__cta">使 </span>
</div>
</button>
))}
</div>
</section>
)}
{(["image", "video"] as ToolCategory[]).map((cat) => {
const group = groupedTools[cat];
if (!group || group.length === 0) return null;
return (
<section key={cat} className="more-page-v2__section">
<h2 className="more-page-v2__section-title">
{categoryIcons[cat]} {categoryLabels[cat]}
<span>{group.length}</span>
</h2>
<div className="more-page-v2__grid">
{group.map((tool) => (
<button
key={tool.id}
type="button"
className={`more-card${tool.ready ? " more-card--ready" : " more-card--pending"}`}
aria-label={tool.ready ? `打开工具:${tool.title}${tool.text}` : `${tool.title}暂未开放`}
onClick={() => openTool(tool)}
disabled={!tool.ready}
>
<span className="more-card__icon">{tool.icon}</span>
<span className="more-card__topline">
{tool.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
</span>
<strong>{tool.title}</strong>
<ToolComparePanel scene={toolCompareScenes[tool.id]} />
<span className="more-card__desc">{tool.text}</span>
<span className="more-card__use-case">{tool.useCase}</span>
<span className="more-card__action"> </span>
{tool.badge && <span className="more-card__badge">{tool.badge}</span>}
</button>
))}
</div>
</section>
);
})}
{!hasGroupedTools && (
<section className="more-page-v2__section">
<div className="more-page-v2__empty">
<span className="more-page-v2__empty-icon">
<ClockCircleOutlined />
</span>
<strong>{activeFilterLabel}</strong>
<p>线</p>
<button type="button" className="more-page-v2__empty-action" onClick={() => setFilter("all")}>
</button>
</div>
</section>
)}
</div>
</div>
);
}
export default MorePage;