抽离个人中心展示原子组件
新增 PlatformProfilePrimitives 收口个人中心统计卡快捷入口设置行与法律入口 RpgEntryHomeView 改为复用平台级个人中心展示组件 补充组件测试并更新前端收口文档与共享决策
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
177
src/components/platform-entry/PlatformProfilePrimitives.tsx
Normal file
177
src/components/platform-entry/PlatformProfilePrimitives.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user