1
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user