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 行
This commit is contained in:
2026-06-07 11:42:00 +08:00
parent 5bdeac20fb
commit 4530058648
6 changed files with 2514 additions and 111 deletions
+256 -5
View File
@@ -4,6 +4,7 @@ import {
CheckCircleFilled,
CloseOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
@@ -17,7 +18,8 @@ import {
ShareAltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
@@ -26,6 +28,7 @@ import { isServerRequestError } from "../../api/serverConnection";
import { ossAssets } from "../../data/ossAssets";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore";
import { downloadResultAsset } from "../workbench/workbenchDownload";
interface ProfilePageProps {
session: WebUserSession | null;
@@ -41,11 +44,16 @@ interface ProfilePageProps {
onOpenWorkbench: () => void;
onOpenCommunity: () => void;
onDeleteProject?: (project: WebProjectSummary) => void;
onOpenProject?: (project: WebProjectSummary) => void;
onRemoveWork?: (task: WebGenerationPreviewTask) => void;
}
type AuthTab = "password" | "email" | "phone";
type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
type ProfileDetailSelection =
| { kind: "work"; item: WebGenerationPreviewTask }
| { kind: "asset"; item: SavedAssetItem };
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = ossAssets.brand.logo;
@@ -210,6 +218,8 @@ function ProfilePage({
onOpenWorkbench,
onOpenCommunity,
onDeleteProject,
onOpenProject,
onRemoveWork,
}: ProfilePageProps) {
const isLoggedIn = Boolean(session);
const userId = session?.user.id;
@@ -253,6 +263,10 @@ function ProfilePage({
const [bioEditBackup, setBioEditBackup] = useState("");
const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null);
const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
const [detailSelection, setDetailSelection] = useState<ProfileDetailSelection | null>(null);
const [detailNotice, setDetailNotice] = useState<string | null>(null);
const [isDeletingDetail, setIsDeletingDetail] = useState(false);
const [isDownloadingDetail, setIsDownloadingDetail] = useState(false);
const completedTasks = tasks.filter((task) => task.status === "completed");
const visibleWorks = completedTasks.length ? completedTasks : tasks.slice(0, 6);
@@ -635,6 +649,213 @@ function ProfilePage({
setBioStatusNotice(null);
};
const handleInteractiveCardKeyDown = (event: KeyboardEvent<HTMLElement>, action: () => void) => {
if (event.target !== event.currentTarget) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
action();
};
const openDetailSelection = (selection: ProfileDetailSelection) => {
setDetailNotice(null);
setIsDeletingDetail(false);
setIsDownloadingDetail(false);
setDetailSelection(selection);
};
const closeDetailSelection = () => {
if (isDeletingDetail || isDownloadingDetail) return;
setDetailSelection(null);
setDetailNotice(null);
};
useEffect(() => {
if (!detailSelection) return undefined;
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") closeDetailSelection();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [detailSelection, isDeletingDetail, isDownloadingDetail]);
useEffect(() => {
if (!detailSelection || typeof document === "undefined") return undefined;
const { body, documentElement } = document;
const previousBodyOverflow = body.style.overflow;
const previousRootOverscroll = documentElement.style.overscrollBehavior;
body.style.overflow = "hidden";
documentElement.style.overscrollBehavior = "contain";
return () => {
body.style.overflow = previousBodyOverflow;
documentElement.style.overscrollBehavior = previousRootOverscroll;
};
}, [detailSelection]);
const handleDownloadSelectedDetail = async () => {
if (!detailSelection || isDownloadingDetail) return;
const isWork = detailSelection.kind === "work";
const item = detailSelection.item;
const url = isWork ? item.outputUrl : item.imageUrl || item.url || "";
if (!url) {
setDetailNotice("暂无可下载的媒体文件");
return;
}
const isVideo = isWork ? item.type === "video" : item.type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(url);
const taskId = isWork ? item.id : item.sourceTaskId || undefined;
const name = isWork ? item.title : item.name;
setIsDownloadingDetail(true);
setDetailNotice("正在准备下载...");
try {
const status = await downloadResultAsset(url, name, isVideo, taskId);
setDetailNotice(status === "saved" ? "已保存到本地" : "已开始保存到本地");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setDetailNotice("已取消下载");
} else {
setDetailNotice(error instanceof Error ? error.message : "下载失败,请稍后重试");
}
} finally {
setIsDownloadingDetail(false);
}
};
const handleDeleteSelectedDetail = async () => {
if (!detailSelection || isDeletingDetail) return;
if (detailSelection.kind === "work") {
onRemoveWork?.(detailSelection.item);
setDetailNotice("已从当前代表作列表移除");
setDetailSelection(null);
return;
}
setIsDeletingDetail(true);
setDetailNotice(null);
try {
await assetClient.delete(detailSelection.item.id, { cleanupUserData: true });
setSavedAssets((current) => current.filter((asset) => asset.id !== detailSelection.item.id));
setDetailSelection(null);
setAssetNotice(`已删除 ${detailSelection.item.name}`);
} catch (error) {
setDetailNotice(formatProfileLoadError(error, "资产删除失败"));
} finally {
setIsDeletingDetail(false);
}
};
const renderDetailMedia = (url: string | null | undefined, type: "image" | "video" | "asset") => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov|m4v)(\?|#|$)/i.test(mediaUrl);
if (!mediaUrl) {
return (
<div className="profile-page__detail-placeholder">
{type === "video" ? <PlayCircleOutlined /> : <FileImageOutlined />}
<span></span>
</div>
);
}
return isVideoPreview ? (
<video className="profile-page__detail-media" src={mediaUrl} controls playsInline />
) : (
<img className="profile-page__detail-media" src={mediaUrl} alt="" />
);
};
const renderDetailModal = () => {
if (!detailSelection) return null;
const modalTarget = typeof document === "undefined" ? null : document.querySelector(".web-shell") || document.body;
if (!modalTarget) return null;
const isWork = detailSelection.kind === "work";
const title = isWork ? detailSelection.item.title : detailSelection.item.name;
const description = isWork ? detailSelection.item.prompt : detailSelection.item.description;
const mediaUrl = isWork ? detailSelection.item.outputUrl : detailSelection.item.imageUrl || detailSelection.item.url;
const mediaType = isWork
? detailSelection.item.type === "video" ? "video" : "image"
: detailSelection.item.type === "video" ? "video" : "asset";
return createPortal(
<div className="profile-page__detail-overlay" role="dialog" aria-modal="true" aria-labelledby="profile-detail-title">
<button type="button" className="profile-page__detail-backdrop" aria-label="关闭详情" onClick={closeDetailSelection} />
<section className="profile-page__detail-panel">
<header className="profile-page__detail-head">
<div>
<span className="profile-page__detail-eyebrow">{isWork ? "代表作详情" : "资产详情"}</span>
<h2 id="profile-detail-title">{title}</h2>
</div>
<button type="button" className="profile-page__detail-close" aria-label="关闭详情" onClick={closeDetailSelection}>
<CloseOutlined />
</button>
</header>
<div className="profile-page__detail-body">
<div className="profile-page__detail-preview">
{renderDetailMedia(mediaUrl, mediaType)}
</div>
<div className="profile-page__detail-info">
<p>{description || "暂无描述"}</p>
<dl>
<div>
<dt>{isWork ? "类型" : "资产类型"}</dt>
<dd>{isWork ? formatTaskType(detailSelection.item.type) : formatAssetType(detailSelection.item.type)}</dd>
</div>
<div>
<dt></dt>
<dd>{isWork ? formatTaskStatus(detailSelection.item.status) : formatAssetStatus(detailSelection.item.status)}</dd>
</div>
<div>
<dt>{isWork ? "创建时间" : "更新时间"}</dt>
<dd>{formatProfileDate(isWork ? detailSelection.item.createdAt : detailSelection.item.updatedAt)}</dd>
</div>
{!isWork ? (
<div>
<dt></dt>
<dd>{detailSelection.item.tags?.length ? detailSelection.item.tags.join(" / ") : "服务器素材"}</dd>
</div>
) : null}
</dl>
{detailNotice ? <span className="profile-page__detail-notice">{detailNotice}</span> : null}
</div>
</div>
<footer className="profile-page__detail-actions">
<button
type="button"
className="profile-page__detail-action profile-page__detail-action--primary"
onClick={() => void handleDownloadSelectedDetail()}
disabled={isDownloadingDetail}
>
<DownloadOutlined />
{isDownloadingDetail ? "下载中..." : "下载"}
</button>
<button type="button" className="profile-page__detail-action profile-page__detail-action--secondary" onClick={onOpenWorkbench}>
<EditOutlined />
{isWork ? "继续编辑" : "使用素材"}
</button>
<button
type="button"
className="profile-page__detail-action profile-page__detail-action--danger"
onClick={() => void handleDeleteSelectedDetail()}
disabled={isDeletingDetail}
>
<DeleteOutlined />
{isDeletingDetail ? "删除中..." : isWork ? "移除代表作" : "删除资产"}
</button>
</footer>
</section>
</div>,
modalTarget,
);
};
const renderEmptyState = (text: string, actionLabel: string, action: () => void) => (
<div className="profile-page__empty-state">
<span className="profile-page__empty-mark" aria-hidden="true">
@@ -680,7 +901,15 @@ function ProfilePage({
<div className="profile-page__works-scroll">
<div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card profile-page__media-card">
<article
key={task.id}
className="profile-page__list-card profile-page__media-card profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`查看代表作 ${task.title}`}
onClick={() => openDetailSelection({ kind: "work", item: task })}
onKeyDown={(event) => handleInteractiveCardKeyDown(event, () => openDetailSelection({ kind: "work", item: task }))}
>
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
@@ -705,7 +934,17 @@ function ProfilePage({
return projects.length ? (
<div className="profile-page__list-grid motion-stagger">
{projects.map((project) => (
<article key={project.id} className="profile-page__list-card profile-page__media-card">
<article
key={project.id}
className="profile-page__list-card profile-page__media-card profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`打开项目 ${project.name}`}
onClick={() => (onOpenProject ? onOpenProject(project) : onOpenWorkbench())}
onKeyDown={(event) =>
handleInteractiveCardKeyDown(event, () => (onOpenProject ? onOpenProject(project) : onOpenWorkbench()))
}
>
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
@@ -715,7 +954,10 @@ function ProfilePage({
type="button"
className="profile-page__delete-project"
aria-label={`删除项目 ${project.name}`}
onClick={() => onDeleteProject(project)}
onClick={(event) => {
event.stopPropagation();
onDeleteProject(project);
}}
>
<DeleteOutlined />
</button>
@@ -739,7 +981,15 @@ function ProfilePage({
return savedAssets.length ? (
<div className="profile-page__list-grid">
{savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card profile-page__media-card">
<article
key={asset.id}
className="profile-page__list-card profile-page__media-card profile-page__interactive-card"
role="button"
tabIndex={0}
aria-label={`查看资产 ${asset.name}`}
onClick={() => openDetailSelection({ kind: "asset", item: asset })}
onKeyDown={(event) => handleInteractiveCardKeyDown(event, () => openDetailSelection({ kind: "asset", item: asset }))}
>
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head">
@@ -959,6 +1209,7 @@ function ProfilePage({
</div>
</main>
</div>
{renderDetailModal()}
</section>
);
}