抽离个人中心展示原子组件

新增 PlatformProfilePrimitives 收口个人中心统计卡快捷入口设置行与法律入口
RpgEntryHomeView 改为复用平台级个人中心展示组件
补充组件测试并更新前端收口文档与共享决策
This commit is contained in:
2026-06-10 18:31:52 +08:00
parent 17cf65a1a3
commit c975d41d46
5 changed files with 270 additions and 162 deletions
File diff suppressed because one or more lines are too long
@@ -233,6 +233,7 @@
19.3.9. 个人中心昵称旁的铅笔入口迁移到 `PlatformIconButton`;页面继续保留 `.platform-profile-edit-button` 局部 class 控制 1.45rem 紧凑尺寸、边框与浅色底,但按钮语义、默认 `type="button"` 和共享 icon chrome 统一由公共组件承接,不再在 `RpgEntryHomeView` 里手写原生图标按钮。
19.3.10. RPG 首页推荐运行态卡片底部的点赞 / 分享 / 改造入口迁移到 `PlatformIconButton`;推荐卡继续保留 `.platform-recommend-work-meta__action*` 局部 class 控制透明圆角按钮尺寸、间距和玩法主题色,同时显式保留 `onPointerDown` / `onClick` 里的 `stopPropagation`,避免图标动作把推荐卡纵向拖拽切换误触发。后续任何耦合 swipe / drag 手势的图标动作都沿用“共享按钮承接语义,本地 class 保留视觉与手势隔离”的策略。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "logged in recommend runtime preloads adjacent work previews and drag switches like video feed"`
19.3.11. 创作中心公开作品卡右上角的分享快按钮迁移到 `PlatformIconButton`;作品卡继续保留 `.creation-work-card__quick-action-button` 局部 class 承接卡片角落定位和尺寸,并显式保留 `stopPropagation`、关闭 swipe action、清理 `suppressOpenRef` 与分享回调顺序,避免右上角分享入口误触整卡打开或遗留左滑状态。验证命令:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`
19.3.12. RPG 首页个人中心的统计卡、统计骨架、常用功能入口、设置行与法律信息入口抽离到 `src/components/platform-entry/PlatformProfilePrimitives.tsx``RpgEntryHomeView` 只继续保留账户数据、图片资源、点击回调和打开弹层的控制器,不再把这一组纯展示原子和个人中心页面编排混在同一个 7k+ 首页文件里。组件级验证新增 `src/components/platform-entry/PlatformProfilePrimitives.test.tsx`,并继续复用 `RpgEntryHomeView.recharge.test.tsx` 的个人中心集成断言。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePrimitives.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile stats cards are centered without update timestamp|profile page shows legal entries and hides archive shortcuts"`
19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。
19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。
20. 平台方形上传入口和紧凑虚线新增入口迁移到 `PlatformUploadTile`,上传后的图片预览迁移到 `PlatformUploadPreviewCard`;反馈页上传凭证入口 / 预览、敲木鱼工作台新增功德词条入口、通用创作图片面板的提示词参考图缩略图、抓大鹅封面编辑参考图缩略图、通用输入 Composer 已选参考图条、creation-agent 已选参考图条和拼图结果页关卡引用图横条已先迁移。方形缩略图使用默认 `layout="square"`,横向“已选择参考图 / 文件名 / 素材名 / 移除”条使用 `layout="inline"`;只读引用图条不传 `onRemove`,避免公共组件额外渲染删除入口。后续继续收口结果页素材上传、工作台参考图上传、紧凑虚线新增入口等上传 / 动作块时,业务页只保留文件选择、预览数组、预览回调、删除回调、校验逻辑或新增回调,上传方块外观、主副文案、缩略图壳、预览按钮、标题行、横向已选条、移除按钮和禁用态统一由 Module 承接;工具栏中的小图标上传仍继续使用 `PlatformIconButton asChild="label"`
@@ -0,0 +1,84 @@
/* @vitest-environment jsdom */
import { Settings } from 'lucide-react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, test, vi } from 'vitest';
import { ICP_RECORD_NUMBER, LEGAL_DOCUMENTS } from '../common/legalDocuments';
import {
ProfileLegalSection,
ProfileSettingsRow,
ProfileShortcutButton,
ProfileStatCard,
} from './PlatformProfilePrimitives';
function TestIcon({ className }: { className?: string }) {
return <span className={className}>I</span>;
}
describe('PlatformProfilePrimitives', () => {
test('ProfileStatCard reports its dashboard card key on click', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value="88"
icon={TestIcon}
onClick={onClick}
/>,
);
await user.click(screen.getByRole('button', { name: /\s*88/u }));
expect(onClick).toHaveBeenCalledWith('wallet');
});
test('ProfileShortcutButton keeps shortcut label and sub label visible', () => {
render(
<ProfileShortcutButton
label="玩家社区"
subLabel="交流心得"
icon={TestIcon}
onClick={vi.fn()}
/>,
);
const button = screen.getByRole('button', { name: //u });
expect(button.className).toContain('platform-profile-shortcut-button');
expect(screen.getByText('交流心得')).toBeTruthy();
});
test('ProfileSettingsRow and ProfileLegalSection keep their click affordances', async () => {
const user = userEvent.setup();
const onSettingsClick = vi.fn();
const onOpenDocument = vi.fn();
const firstLegalDocument = LEGAL_DOCUMENTS[0];
if (!firstLegalDocument) {
throw new Error('expected legal documents fixtures');
}
render(
<>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={onSettingsClick}
/>
<ProfileLegalSection onOpenDocument={onOpenDocument} />
</>,
);
await user.click(screen.getByRole('button', { name: //u }));
expect(onSettingsClick).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: firstLegalDocument.title }),
);
expect(onOpenDocument).toHaveBeenCalledWith(firstLegalDocument.id);
expect(screen.getByText(ICP_RECORD_NUMBER)).toBeTruthy();
});
});
@@ -0,0 +1,177 @@
import { ChevronRight } from 'lucide-react';
import type { ComponentType, ReactNode } from 'react';
import type { ProfileDashboardCardKey } from '../../../packages/shared/src/contracts/runtime';
import {
ICP_RECORD_NUMBER,
ICP_RECORD_URL,
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
type ProfileStatCardProps = {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
imageSrc?: string;
};
export function ProfileStatCard({
cardKey,
label,
value,
onClick,
icon,
imageSrc,
}: ProfileStatCardProps) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
</button>
);
}
export function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
type ProfileShortcutButtonProps = {
label: string;
subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
imageSrc?: string;
};
export function ProfileShortcutButton({
label,
subLabel,
icon,
onClick,
imageSrc,
}: ProfileShortcutButtonProps) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
>
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
</button>
);
}
type ProfileSettingsRowProps = {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
};
export function ProfileSettingsRow({
label,
icon,
onClick,
}: ProfileSettingsRowProps) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
type ProfileLegalSectionProps = {
onOpenDocument: (documentId: LegalDocumentId) => void;
};
export function ProfileLegalSection({
onOpenDocument,
}: ProfileLegalSectionProps) {
return (
<section className="platform-profile-legal-strip" aria-label="法律信息">
<div className="platform-profile-legal-strip__links">
{LEGAL_DOCUMENTS.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => onOpenDocument(document.id)}
className="platform-profile-legal-strip__link"
>
{document.title}
{index < LEGAL_DOCUMENTS.length - 1 ? (
<span
aria-hidden="true"
className="platform-profile-legal-strip__divider"
/>
) : null}
</button>
))}
</div>
<a
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
</a>
</section>
);
}
+7 -162
View File
@@ -115,9 +115,6 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
ICP_RECORD_NUMBER,
ICP_RECORD_URL,
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -151,6 +148,13 @@ import {
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import {
ProfileLegalSection,
ProfileSettingsRow,
ProfileShortcutButton,
ProfileStatCard,
ProfileStatCardSkeleton,
} from '../platform-entry/PlatformProfilePrimitives';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
@@ -2620,165 +2624,6 @@ function cropAvatarImage(params: {
});
}
function ProfileStatCard({
cardKey,
label,
value,
onClick,
icon,
imageSrc,
}: {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
imageSrc?: string;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
</button>
);
}
function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
function ProfileShortcutButton({
label,
subLabel,
icon,
onClick,
imageSrc,
}: {
label: string;
subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
imageSrc?: string;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
>
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
</button>
);
}
function ProfileSettingsRow({
label,
icon,
onClick,
}: {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function ProfileLegalSection({
onOpenDocument,
}: {
onOpenDocument: (documentId: LegalDocumentId) => void;
}) {
return (
<section className="platform-profile-legal-strip" aria-label="法律信息">
<div className="platform-profile-legal-strip__links">
{LEGAL_DOCUMENTS.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => onOpenDocument(document.id)}
className="platform-profile-legal-strip__link"
>
{document.title}
{index < LEGAL_DOCUMENTS.length - 1 ? (
<span
aria-hidden="true"
className="platform-profile-legal-strip__divider"
/>
) : null}
</button>
))}
</div>
<a
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
</a>
</section>
);
}
function ProfileReferralUserAvatar({
name,
avatarUrl,