Files
Genarrative/src/components/platform-entry/PlatformActiveProfileView.tsx
T
kdletters 7ea463ed08 退役旧创作模板业务并保留数据壳
保留 SpacetimeDB 历史表、迁移白名单与最小兼容读取定义
移除旧创作前后端、worker、业务过程及纯业务 crate 的编译依赖
恢复现役创作、项目、我的入口及桌面移动导航
收紧 Vite、TypeScript、ESLint、Vitest 与静态资源退役边界
补齐开发栈、网关、原生壳和文档退役约束
2026-07-18 22:02:22 +08:00

685 lines
21 KiB
TypeScript

import {
Camera,
Coins,
History,
KeyRound,
MessageCircle,
Pencil,
Settings,
Ticket,
UserRound,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import { updateAuthProfile } from '../../services/authService';
import {
canUseNativeHostCapability,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { SquareImageCropModal } from '../common/SquareImageCropModal';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
type SquareImageCropRect,
} from '../common/squareImageCropModel';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { resolveActivePublicUserCode } from './platformActiveProfileModel';
import { PlatformProfileModalShell } from './PlatformProfileModalShell';
import {
ProfileLegalSection,
ProfileSettingsRow,
ProfileShortcutButton,
ProfileStatCard,
ProfileStatCardSkeleton,
} from './PlatformProfilePrimitives';
type PlatformActiveProfileViewProps = {
dashboard: ProfileDashboardSummary | null;
isLoadingDashboard: boolean;
onLogin: () => void;
onOpenApiKeys: () => void;
onOpenCommunity: () => void;
onOpenFeedback: () => void;
onOpenRecharge: () => void;
onOpenRewardCode: () => void;
onOpenSettings: () => void;
onOpenWalletLedger: () => void;
onUserUpdated: (user: AuthUser) => void;
user: AuthUser | null | undefined;
};
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
function hostAvatarImageResultToFile(result: HostFileImportImageResult) {
const binary = atob(result.base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], result.fileName, { type: result.mimeType });
}
function validateProfileDisplayName(value: string) {
const normalized = value.trim();
if (!normalized) {
return '请输入昵称';
}
const length = Array.from(normalized).length;
if (length < 2 || length > 20) {
return '昵称需要 2 到 20 位';
}
if (!/^[\u4e00-\u9fffa-zA-Z0-9_]+$/u.test(normalized)) {
return '昵称仅支持中文、英文、数字和下划线';
}
return null;
}
function readImageIntrinsicSize(src: string) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve({
width: image.naturalWidth,
height: image.naturalHeight,
});
};
image.onerror = () => reject(new Error('图片读取失败'));
image.src = src;
});
}
function loadAvatarFile(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('图片读取失败'));
return;
}
resolve(reader.result);
};
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}
function cropAvatarImage(params: {
source: string;
cropX: number;
cropY: number;
cropSize: number;
}) {
return new Promise<string>((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = AVATAR_OUTPUT_SIZE;
canvas.height = AVATAR_OUTPUT_SIZE;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('头像裁剪失败'));
return;
}
context.drawImage(
image,
params.cropX,
params.cropY,
params.cropSize,
params.cropSize,
0,
0,
AVATAR_OUTPUT_SIZE,
AVATAR_OUTPUT_SIZE,
);
resolve(canvas.toDataURL('image/png'));
};
image.onerror = () => reject(new Error('头像裁剪失败'));
image.src = params.source;
});
}
function ProfileNicknameModal({
value,
error,
isSaving,
onChange,
onClose,
onSubmit,
}: {
value: string;
error: string | null;
isSaving: boolean;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}) {
return (
<PlatformProfileModalShell
title="修改昵称"
onClose={onClose}
closeLabel="关闭昵称修改"
closeVariant="profileCompact"
panelClassName="platform-remap-surface !max-w-sm rounded-[1.4rem]"
bodyClassName="px-5 py-5"
footerClassName="grid grid-cols-2 gap-3 px-5 pb-5 pt-0 sm:px-5"
footer={
<>
<PlatformActionButton tone="secondary" onClick={onClose}>
取消
</PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
{isSaving ? '保存中' : '保存'}
</PlatformActionButton>
</>
}
>
<label className="block">
<span className="sr-only">新昵称</span>
<PlatformTextField
autoFocus
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
maxLength={20}
surface="editorDark"
size="lg"
density="roomy"
className="rounded-2xl border-white/12 bg-white/10 text-[var(--platform-text-strong)] focus:border-[var(--platform-surface-hover-border)]"
placeholder="输入新昵称"
/>
</label>
{error ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
className="mt-3 rounded-2xl border-rose-400/25 text-rose-600"
>
{error}
</PlatformStatusMessage>
) : null}
</PlatformProfileModalShell>
);
}
function formatDashboardCount(value: number) {
return Math.max(0, Math.round(value)).toLocaleString('zh-CN');
}
function formatTotalPlayTime(value: number) {
const hours = Math.max(0, Math.round(value / 360000) / 10);
return `${hours.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
})}小时`;
}
export function PlatformActiveProfileView({
dashboard,
isLoadingDashboard,
onLogin,
onOpenApiKeys,
onOpenCommunity,
onOpenFeedback,
onOpenRecharge,
onOpenRewardCode,
onOpenSettings,
onOpenWalletLedger,
onUserUpdated,
user,
}: PlatformActiveProfileViewProps) {
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const { copyState, copyText } = useCopyFeedback();
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
const [nicknameInput, setNicknameInput] = useState('');
const [nicknameError, setNicknameError] = useState<string | null>(null);
const [isSavingNickname, setIsSavingNickname] = useState(false);
const [avatarSource, setAvatarSource] = useState<string | null>(null);
const [avatarImageSize, setAvatarImageSize] = useState<{
width: number;
height: number;
} | null>(null);
const [avatarCrop, setAvatarCrop] = useState<SquareImageCropRect>({
x: 0,
y: 0,
size: 1,
});
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const openNicknameModal = () => {
if (!user) {
onLogin();
return;
}
setNicknameInput(user.displayName);
setNicknameError(null);
setIsNicknameModalOpen(true);
};
const submitNickname = () => {
if (!user || isSavingNickname) {
return;
}
const validationError = validateProfileDisplayName(nicknameInput);
if (validationError) {
setNicknameError(validationError);
return;
}
setIsSavingNickname(true);
setNicknameError(null);
void updateAuthProfile({ displayName: nicknameInput.trim() })
.then((nextUser) => {
onUserUpdated(nextUser);
setIsNicknameModalOpen(false);
})
.catch((error: unknown) => {
setNicknameError(
error instanceof Error ? error.message : '昵称保存失败',
);
})
.finally(() => setIsSavingNickname(false));
};
const openAvatarPicker = () => {
if (!user) {
onLogin();
return;
}
setAvatarError(null);
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
try {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
handleAvatarFileChange(
hostAvatarImageResultToFile(importedImageFile),
);
} catch (error: unknown) {
setAvatarError(
error instanceof Error ? error.message : '头像图片导入失败',
);
}
})();
return;
}
avatarFileInputRef.current?.click();
};
const handleAvatarFileChange = (file: File | null) => {
if (avatarFileInputRef.current) {
avatarFileInputRef.current.value = '';
}
if (!file) {
return;
}
if (!AVATAR_ALLOWED_TYPES.has(file.type)) {
setAvatarError('头像仅支持 jpg、png、webp');
return;
}
if (file.size > AVATAR_MAX_FILE_SIZE) {
setAvatarError('头像图片不能超过 5MB');
return;
}
setAvatarError(null);
void loadAvatarFile(file)
.then(async (source) => {
const imageSize = await readImageIntrinsicSize(source);
setAvatarSource(source);
setAvatarImageSize(imageSize);
setAvatarCrop(buildCenteredSquareImageCropRect(imageSize));
})
.catch((error: unknown) => {
setAvatarError(
error instanceof Error ? error.message : '头像图片读取失败',
);
});
};
const updateAvatarCrop = useCallback(
(nextCrop: SquareImageCropRect) => {
if (!avatarImageSize) {
return;
}
setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop));
},
[avatarImageSize],
);
const submitAvatar = () => {
if (
!avatarSource ||
!avatarImageSize ||
avatarCrop.size <= 0 ||
isSavingAvatar
) {
return;
}
setIsSavingAvatar(true);
setAvatarError(null);
void cropAvatarImage({
source: avatarSource,
cropX: avatarCrop.x,
cropY: avatarCrop.y,
cropSize: avatarCrop.size,
})
.then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl }))
.then((nextUser) => {
onUserUpdated(nextUser);
setAvatarSource(null);
setAvatarImageSize(null);
})
.catch((error: unknown) => {
setAvatarError(error instanceof Error ? error.message : '头像上传失败');
})
.finally(() => setIsSavingAvatar(false));
};
if (!user) {
return (
<main
className="platform-profile-page platform-remap-surface w-full"
aria-label="我的"
>
<section className="platform-profile-header">
<img
src={profileStillLifeImage}
alt=""
aria-hidden="true"
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<img
src={profileMascotImage}
alt=""
aria-hidden="true"
className="platform-profile-avatar h-[5.15rem] w-[5.15rem] shrink-0 rounded-full object-cover"
/>
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
尚未登录
</div>
<PlatformActionButton className="mt-3" onClick={onLogin}>
登录
</PlatformActionButton>
</div>
</div>
</div>
</section>
</main>
);
}
const avatarUrl = user.avatarUrl?.trim() || null;
const publicUserCode = resolveActivePublicUserCode(user);
return (
<main
className="platform-profile-page platform-remap-surface w-full space-y-4 pb-2"
aria-label="我的"
>
<section className="platform-profile-header">
<img
src={profileStillLifeImage}
alt=""
aria-hidden="true"
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<button
type="button"
className="platform-profile-avatar relative h-[5.15rem] w-[5.15rem] shrink-0 overflow-hidden rounded-full"
aria-label="上传头像"
onClick={openAvatarPicker}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<img
src={profileMascotImage}
alt=""
className="h-full w-full object-cover"
/>
)}
<span className="platform-profile-camera absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full">
<Camera className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</button>
<input
ref={avatarFileInputRef}
type="file"
aria-label="上传头像"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) =>
handleAvatarFileChange(event.target.files?.[0] ?? null)
}
/>
<div className="platform-profile-header__text min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
{user.displayName}
</div>
<PlatformIconButton
label="修改昵称"
icon={<Pencil className="h-3.5 w-3.5" />}
onClick={openNicknameModal}
className="platform-profile-edit-button"
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
<span>陶泥号: {publicUserCode}</span>
<CopyFeedbackButton
state={copyState}
idleLabel="复制"
showIcon={false}
className="platform-profile-copy-button"
onClick={() => {
void copyText(publicUserCode);
}}
/>
</div>
</div>
</div>
</div>
</section>
<section className="platform-profile-stats-panel" aria-label="我的数据">
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
</>
) : (
<>
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value={
dashboard
? formatDashboardCount(dashboard.walletBalance)
: '暂不可用'
}
icon={Coins}
imageSrc={profilePointImage}
onClick={onOpenWalletLedger}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
value={
dashboard
? formatTotalPlayTime(dashboard.totalPlayTimeMs)
: '暂不可用'
}
icon={History}
imageSrc={profileClockImage}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
value={
dashboard
? `${formatDashboardCount(dashboard.playedWorldCount)}个`
: '暂不可用'
}
icon={UserRound}
imageSrc={profileGamepadImage}
/>
</>
)}
</div>
</section>
<section
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="platform-profile-shortcut-grid grid w-full grid-cols-4">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={onOpenRecharge}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
icon={Ticket}
imageSrc={profileGiftImage}
onClick={onOpenRewardCode}
/>
<ProfileShortcutButton
label="玩家社区"
subLabel="交流心得"
icon={MessageCircle}
imageSrc={profileCommunityImage}
onClick={onOpenCommunity}
/>
<ProfileShortcutButton
label="反馈与建议"
subLabel="帮我们优化产品"
icon={MessageCircle}
imageSrc={profileFeedbackImage}
onClick={onOpenFeedback}
/>
</div>
</section>
<section
className="platform-profile-settings-panel"
aria-label="设置入口"
>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={onOpenSettings}
/>
<ProfileSettingsRow
label="开发者 API Key"
icon={KeyRound}
onClick={onOpenApiKeys}
/>
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
onClose={() => setActiveLegalDocumentId(null)}
/>
{isNicknameModalOpen ? (
<ProfileNicknameModal
value={nicknameInput}
error={nicknameError}
isSaving={isSavingNickname}
onChange={(value) => {
setNicknameInput(value);
setNicknameError(null);
}}
onClose={() => setIsNicknameModalOpen(false)}
onSubmit={submitNickname}
/>
) : null}
{avatarSource && avatarImageSize ? (
<SquareImageCropModal
source={avatarSource}
imageSize={avatarImageSize}
cropRect={avatarCrop}
titleId="profile-avatar-crop-title"
labels={{
title: '裁剪头像',
close: '关闭头像裁剪',
editor: '头像裁剪操作区',
previewAlt: '头像裁剪预览',
cancel: '取消',
submit: '上传',
saving: '上传中',
}}
error={avatarError}
isSaving={isSavingAvatar}
onCropRectChange={updateAvatarCrop}
onClose={() => {
setAvatarSource(null);
setAvatarImageSize(null);
setAvatarError(null);
}}
onSubmit={submitAvatar}
/>
) : null}
{avatarError && !avatarSource ? (
<div className="pointer-events-none fixed left-1/2 top-5 z-[90] w-[min(92vw,22rem)] -translate-x-1/2 rounded-2xl border border-rose-400/25 bg-white px-4 py-3 text-center text-sm font-semibold text-rose-600 shadow-2xl">
{avatarError}
</div>
) : null}
</main>
);
}
export default PlatformActiveProfileView;