This commit is contained in:
2026-05-14 14:21:17 +08:00
parent 7a75f5d612
commit d33c937ebc
191 changed files with 1916 additions and 1549 deletions

View File

@@ -88,6 +88,12 @@ import {
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../common/SquareImageCropModal';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
@@ -2286,201 +2292,9 @@ function ProfileNicknameModal({
);
}
function ProfileAvatarCropModal({
source,
imageSize,
scale,
cropX,
cropY,
error,
isSaving,
onScaleChange,
onCropChange,
onClose,
onSubmit,
}: {
source: string;
imageSize: { width: number; height: number };
scale: number;
cropX: number;
cropY: number;
error: string | null;
isSaving: boolean;
onScaleChange: (value: number) => void;
onCropChange: (nextCrop: { x: number; y: number }) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragStartRef = useRef<{
pointerId: number;
clientX: number;
clientY: number;
cropX: number;
cropY: number;
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
const cropSize = Math.min(imageSize.width, imageSize.height) / scale;
const maxCropX = Math.max(0, imageSize.width - cropSize);
const maxCropY = Math.max(0, imageSize.height - cropSize);
const backgroundSize = `${(imageSize.width / cropSize) * 100}% ${(imageSize.height / cropSize) * 100}%`;
const backgroundPosition = `${maxCropX > 0 ? (cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (cropY / maxCropY) * 100 : 50}%`;
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current;
const preview = previewRef.current;
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
return;
}
const rect = preview.getBoundingClientRect();
const sourcePixelsPerPreviewPixel = cropSize / Math.max(1, rect.width);
onCropChange({
x:
dragStart.cropX -
(event.clientX - dragStart.clientX) * sourcePixelsPerPreviewPixel,
y:
dragStart.cropY -
(event.clientY - dragStart.clientY) * sourcePixelsPerPreviewPixel,
});
};
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
if (dragStartRef.current?.pointerId === event.pointerId) {
dragStartRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="profile-avatar-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id="profile-avatar-crop-title" className="text-base font-black">
</div>
<button
type="button"
aria-label="关闭头像裁剪"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.4rem] border border-white/12 bg-cover bg-center"
style={{
backgroundImage: `url("${source}")`,
backgroundSize,
backgroundPosition,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
}}
role="img"
aria-label="头像裁剪预览"
onPointerDown={(event) => {
dragStartRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
cropX,
cropY,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={updateDragCrop}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
/>
<div className="mt-5 space-y-4">
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="1"
max="3"
step="0.01"
value={scale}
onChange={(event) => onScaleChange(Number(event.target.value))}
className="w-full"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="0"
max={maxCropX}
step="1"
value={Math.min(cropX, maxCropX)}
onChange={(event) =>
onCropChange({ x: Number(event.target.value), y: cropY })
}
className="w-full"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="0"
max={maxCropY}
step="1"
value={Math.min(cropY, maxCropY)}
onChange={(event) =>
onCropChange({ x: cropX, y: Number(event.target.value) })
}
className="w-full"
/>
</label>
</div>
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? '上传中' : '上传'}
</button>
</div>
</div>
</div>
</div>
);
}
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
new_user_registration_reward: '注册赠送',
points_recharge: '点充值',
points_recharge: '点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
@@ -2587,7 +2401,7 @@ function RechargeProductCard({
const submitting = submittingProductId === product.productId;
const value =
product.kind === 'points'
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}`
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}`
: `${product.durationDays}`;
return (
@@ -2662,7 +2476,7 @@ function ProfileRechargeModal({
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{center
? `${center.walletBalance}点 · ${memberLabel}`
? `${center.walletBalance}点 · ${memberLabel}`
: '读取中'}
</div>
</div>
@@ -2682,7 +2496,7 @@ function ProfileRechargeModal({
onClick={() => onTabChange('points')}
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
>
</button>
<button
type="button"
@@ -2767,7 +2581,7 @@ function WalletLedgerModal({
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭点账单"
aria-label="关闭点账单"
>
×
</button>
@@ -2776,10 +2590,10 @@ function WalletLedgerModal({
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
LEDGER
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{balance}</span>
<span>{balance}</span>
</div>
</div>
@@ -2889,7 +2703,7 @@ function ProfileTaskCenterModal({
<div>
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{walletBalance}
{walletBalance}
</div>
</div>
<button
@@ -3180,7 +2994,7 @@ function ProfileReferralModal({
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm font-semibold leading-6 text-amber-900">
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}点。`}
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}点。`}
</div>
<div></div>
</div>
@@ -3522,8 +3336,11 @@ export function RpgEntryHomeView({
width: number;
height: number;
} | null>(null);
const [avatarScale, setAvatarScale] = useState(1);
const [avatarCrop, setAvatarCrop] = useState({ x: 0, y: 0 });
const [avatarCrop, setAvatarCrop] = useState<SquareImageCropRect>({
x: 0,
y: 0,
size: 1,
});
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
@@ -3634,9 +3451,6 @@ export function RpgEntryHomeView({
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const avatarCropSize = avatarImageSize
? Math.min(avatarImageSize.width, avatarImageSize.height) / avatarScale
: 0;
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
const totalPlayTime = formatTotalPlayTimeHours(
profileDashboard?.totalPlayTimeMs ?? 0,
@@ -3889,14 +3703,9 @@ export function RpgEntryHomeView({
void loadAvatarFile(file)
.then(async (source) => {
const imageSize = await readImageIntrinsicSize(source);
const cropSize = Math.min(imageSize.width, imageSize.height);
setAvatarSource(source);
setAvatarImageSize(imageSize);
setAvatarScale(1);
setAvatarCrop({
x: Math.max(0, (imageSize.width - cropSize) / 2),
y: Math.max(0, (imageSize.height - cropSize) / 2),
});
setAvatarCrop(buildCenteredSquareImageCropRect(imageSize));
})
.catch((error: unknown) => {
setAvatarError(
@@ -3904,54 +3713,21 @@ export function RpgEntryHomeView({
);
});
};
const updateAvatarScale = useCallback(
(nextScale: number) => {
const updateAvatarCrop = useCallback(
(nextCrop: SquareImageCropRect) => {
if (!avatarImageSize) {
return;
}
const normalizedScale = Math.min(3, Math.max(1, nextScale));
const nextCropSize =
Math.min(avatarImageSize.width, avatarImageSize.height) /
normalizedScale;
setAvatarScale(normalizedScale);
setAvatarCrop((current) => ({
x: Math.min(
current.x,
Math.max(0, avatarImageSize.width - nextCropSize),
),
y: Math.min(
current.y,
Math.max(0, avatarImageSize.height - nextCropSize),
),
}));
setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop));
},
[avatarImageSize],
);
const updateAvatarCrop = useCallback(
(nextCrop: { x: number; y: number }) => {
if (!avatarImageSize || avatarCropSize <= 0) {
return;
}
setAvatarCrop({
x: Math.min(
Math.max(0, nextCrop.x),
Math.max(0, avatarImageSize.width - avatarCropSize),
),
y: Math.min(
Math.max(0, nextCrop.y),
Math.max(0, avatarImageSize.height - avatarCropSize),
),
});
},
[avatarCropSize, avatarImageSize],
);
const submitAvatar = () => {
if (
!avatarSource ||
!avatarImageSize ||
avatarCropSize <= 0 ||
avatarCrop.size <= 0 ||
isSavingAvatar
) {
return;
@@ -3963,7 +3739,7 @@ export function RpgEntryHomeView({
source: avatarSource,
cropX: avatarCrop.x,
cropY: avatarCrop.y,
cropSize: avatarCropSize,
cropSize: avatarCrop.size,
})
.then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl }))
.then((nextUser) => {
@@ -3984,7 +3760,7 @@ export function RpgEntryHomeView({
.catch((error: unknown) => {
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取点账单失败',
error instanceof Error ? error.message : '读取点账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
@@ -4206,7 +3982,7 @@ export function RpgEntryHomeView({
void redeemRpgProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} `);
setRewardCodeSuccess(`已到账 ${response.amountGranted} `);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
@@ -4225,7 +4001,7 @@ export function RpgEntryHomeView({
void claimRpgProfileTaskReward(taskId)
.then((response) => {
setTaskCenter(response.center);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} `);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} `);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
@@ -5240,6 +5016,7 @@ export function RpgEntryHomeView({
<input
ref={avatarFileInputRef}
type="file"
aria-label="上传头像"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) =>
@@ -5262,7 +5039,7 @@ export function RpgEntryHomeView({
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
<span> {publicUserCode}</span>
<span> {publicUserCode}</span>
<button
type="button"
onClick={copyProfilePublicUserCode}
@@ -5287,7 +5064,7 @@ export function RpgEntryHomeView({
<Coins className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">/</div>
<div className="text-[10px] opacity-80">/</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -5306,7 +5083,7 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="点"
label="点"
value="暂不可用"
icon={Coins}
onClick={openWalletLedgerPanel}
@@ -5330,7 +5107,7 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="点"
label="点"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
onClick={openWalletLedgerPanel}
@@ -5377,7 +5154,7 @@ export function RpgEntryHomeView({
/>
<ProfileShortcutButton
label="充值"
subLabel="点/会员"
subLabel="点/会员"
icon={Coins}
onClick={openRechargeModal}
/>
@@ -5793,16 +5570,23 @@ export function RpgEntryHomeView({
/>
) : null}
{avatarSource && avatarImageSize ? (
<ProfileAvatarCropModal
<SquareImageCropModal
source={avatarSource}
imageSize={avatarImageSize}
scale={avatarScale}
cropX={avatarCrop.x}
cropY={avatarCrop.y}
cropRect={avatarCrop}
titleId="profile-avatar-crop-title"
labels={{
title: '裁剪头像',
close: '关闭头像裁剪',
editor: '头像裁剪操作区',
previewAlt: '头像裁剪预览',
cancel: '取消',
submit: '上传',
saving: '上传中',
}}
error={avatarError}
isSaving={isSavingAvatar}
onScaleChange={updateAvatarScale}
onCropChange={updateAvatarCrop}
onCropRectChange={updateAvatarCrop}
onClose={() => {
setAvatarSource(null);
setAvatarImageSize(null);