fec8b626f5
H5 新增 openHostExternalUrl 并归一化外链 URL 备案号和资产调试原图在 native_app 中优先交给宿主打开 补充 HostBridge 外链测试和宿主壳文档
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
import { ChevronRight } from 'lucide-react';
|
|
import type { ComponentType, MouseEvent, ReactNode } from 'react';
|
|
|
|
import type { ProfileDashboardCardKey } from '../../../packages/shared/src/contracts/runtime';
|
|
import {
|
|
getHostRuntime,
|
|
openHostExternalUrl,
|
|
} from '../../services/host-bridge/hostBridge';
|
|
import {
|
|
ICP_RECORD_NUMBER,
|
|
ICP_RECORD_URL,
|
|
LEGAL_DOCUMENTS,
|
|
type LegalDocumentId,
|
|
} from '../common/legalDocuments';
|
|
import { PlatformNavigableListItem } from '../common/PlatformNavigableListItem';
|
|
|
|
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 (
|
|
<PlatformNavigableListItem
|
|
onClick={onClick}
|
|
className="platform-profile-settings-row px-4 py-3 transition"
|
|
leading={
|
|
<span className="platform-profile-settings-row__icon">
|
|
<Icon className="h-4 w-4" />
|
|
</span>
|
|
}
|
|
bodyClassName="flex min-w-0 items-center"
|
|
trailing={
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
|
}
|
|
>
|
|
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
|
|
{label}
|
|
</span>
|
|
</PlatformNavigableListItem>
|
|
);
|
|
}
|
|
|
|
type ProfileLegalSectionProps = {
|
|
onOpenDocument: (documentId: LegalDocumentId) => void;
|
|
};
|
|
|
|
export function ProfileLegalSection({
|
|
onOpenDocument,
|
|
}: ProfileLegalSectionProps) {
|
|
const openIcpRecord = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
if (getHostRuntime().kind !== 'native_app') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
void openHostExternalUrl({ url: ICP_RECORD_URL }).then((opened) => {
|
|
if (!opened) {
|
|
window.open(ICP_RECORD_URL, '_blank', 'noreferrer');
|
|
}
|
|
});
|
|
};
|
|
|
|
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"
|
|
onClick={(event) => {
|
|
void openIcpRecord(event);
|
|
}}
|
|
className="platform-profile-legal-strip__record"
|
|
>
|
|
{ICP_RECORD_NUMBER}
|
|
</a>
|
|
</section>
|
|
);
|
|
}
|