1
This commit is contained in:
@@ -1035,6 +1035,15 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => undefined),
|
||||
});
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Reflect.deleteProperty(globalThis as Record<string, unknown>, 'BarcodeDetector');
|
||||
window.wx = undefined;
|
||||
document
|
||||
.querySelectorAll(
|
||||
@@ -1832,10 +1841,68 @@ test('profile daily task shortcut opens task center and claims reward', async ()
|
||||
});
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile task center keeps only the highest priority actionable task', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockGetRpgProfileTasks.mockResolvedValueOnce(
|
||||
mockBuildTaskCenter({
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'claimed_low',
|
||||
title: '低优先级已完成',
|
||||
description: '',
|
||||
eventKey: 'profile.task.claimed_low',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 1,
|
||||
rewardPoints: 5,
|
||||
status: 'claimed',
|
||||
dayKey: 20260503,
|
||||
claimedAt: '2026-05-03T08:01:00Z',
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
},
|
||||
{
|
||||
taskId: 'claimable_mid',
|
||||
title: '中优先级可领取',
|
||||
description: '',
|
||||
eventKey: 'profile.task.claimable_mid',
|
||||
cycle: 'daily',
|
||||
threshold: 2,
|
||||
progressCount: 2,
|
||||
rewardPoints: 10,
|
||||
status: 'claimable',
|
||||
dayKey: 20260503,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
},
|
||||
{
|
||||
taskId: 'incomplete_high',
|
||||
title: '高优先级未完成',
|
||||
description: '',
|
||||
eventKey: 'profile.task.incomplete_high',
|
||||
cycle: 'daily',
|
||||
threshold: 3,
|
||||
progressCount: 1,
|
||||
rewardPoints: 20,
|
||||
status: 'incomplete',
|
||||
dayKey: 20260503,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
expect(await screen.findByText('中优先级可领取')).toBeTruthy();
|
||||
expect(screen.queryByText('高优先级未完成')).toBeNull();
|
||||
expect(screen.queryByText('低优先级已完成')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile total play time card always uses hours', () => {
|
||||
@@ -1882,21 +1949,35 @@ test('profile stats cards are centered without update timestamp', () => {
|
||||
});
|
||||
|
||||
test('mobile profile page matches the reference layout sections', async () => {
|
||||
mockWechatMobileLayout();
|
||||
mockNarrowMobileLayout();
|
||||
|
||||
const { container } = renderProfileView(vi.fn(), {
|
||||
walletBalance: 70,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
}, { createdAt: buildFreshProfileCreatedAt() });
|
||||
});
|
||||
|
||||
const profilePage = container.querySelector('.platform-profile-page');
|
||||
expect(profilePage).toBeTruthy();
|
||||
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
|
||||
expect(profilePage?.classList.contains('platform-page-stage')).toBe(false);
|
||||
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
|
||||
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
|
||||
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
|
||||
|
||||
const topbar = container.querySelector('.platform-mobile-topbar');
|
||||
expect(topbar).toBeTruthy();
|
||||
expect(
|
||||
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(topbar as HTMLElement).getByRole('button', { name: '打开设置' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(topbar as HTMLElement).queryByRole('button', {
|
||||
name: /充值/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const membershipCard = screen.getByRole('button', { name: '查看权益' });
|
||||
expect(membershipCard.className).toContain('platform-profile-membership-card');
|
||||
expect(
|
||||
@@ -1914,6 +1995,7 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(
|
||||
within(statPanel).getByRole('button', { name: /泥点余额\s*70/u }).className,
|
||||
).toContain('platform-profile-stat-card');
|
||||
expect(statPanel.querySelectorAll('.platform-profile-stat-card__icon')).toHaveLength(3);
|
||||
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
|
||||
@@ -1953,18 +2035,11 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
});
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
await within(secondaryShortcuts).findByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
|
||||
const profileHeader = profilePage?.querySelector('.platform-profile-header');
|
||||
expect(profileHeader).toBeTruthy();
|
||||
@@ -1982,6 +2057,46 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile scan action opens camera scanner instead of recharge panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const stopTrack = vi.fn();
|
||||
const stream = {
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
} as unknown as MediaStream;
|
||||
const getUserMedia = vi.fn(async () => stream);
|
||||
|
||||
mockNarrowMobileLayout();
|
||||
Object.defineProperty(globalThis, 'BarcodeDetector', {
|
||||
configurable: true,
|
||||
value: class {
|
||||
async detect() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
|
||||
renderProfileView();
|
||||
const topbar = document.querySelector('.platform-mobile-topbar');
|
||||
expect(topbar).toBeTruthy();
|
||||
|
||||
await user.click(
|
||||
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '扫码' })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(getUserMedia).toHaveBeenCalledWith({
|
||||
audio: false,
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
});
|
||||
});
|
||||
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
@@ -2195,7 +2310,7 @@ test('opens reward code modal from profile action on mobile', async () => {
|
||||
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile page shows legal entries and ICP record link', async () => {
|
||||
test('profile page shows legal entries and hides archive shortcuts', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
@@ -2221,18 +2336,9 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
expect(
|
||||
within(settingsRegion).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const secondaryShortcuts = screen.getByRole('region', {
|
||||
name: '次级入口',
|
||||
});
|
||||
expect(
|
||||
within(secondaryShortcuts).getByRole('button', { name: /存档/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(secondaryShortcuts).queryByRole('button', { name: /填邀请码/u }),
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(
|
||||
@@ -2697,7 +2803,7 @@ test('logged out mobile shell defaults to discover tab', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('logged out recommend tab opens login modal and shows cover only', async () => {
|
||||
test('logged out recommend tab opens recommend runtime directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
@@ -2715,17 +2821,17 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
container.querySelector('.platform-recommend-cover-only'),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-entry-shell--recommend'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
|
||||
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
expect(screen.getByLabelText('奇幻拼图 作品信息')).toBeTruthy();
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('logged out recommend cover opens login modal again', async () => {
|
||||
test('logged out recommend meta keeps gallery detail gated', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const { openLoginModal } = renderStatefulLoggedOutHomeView({
|
||||
@@ -2741,12 +2847,9 @@ test('logged out recommend cover opens login modal again', async () => {
|
||||
await user.click(
|
||||
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /登录后游玩 奇幻拼图/u }),
|
||||
);
|
||||
await user.click(screen.getByLabelText('奇幻拼图 作品信息'));
|
||||
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(2);
|
||||
expect(openLoginModal).toHaveBeenLastCalledWith();
|
||||
expect(openLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3082,7 +3185,7 @@ test('mobile recommend meta loads real author avatar from public user summary',
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document
|
||||
.querySelector('.platform-recommend-cover-only__author img')
|
||||
.querySelector('.platform-recommend-work-meta__avatar img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,AUTHOR');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
@@ -123,6 +122,7 @@ import {
|
||||
SquareImageCropModal,
|
||||
type SquareImageCropRect,
|
||||
} from '../common/SquareImageCropModal';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_WORK_TAG,
|
||||
@@ -131,7 +131,6 @@ import {
|
||||
findPublicWorkForHistoryEntry,
|
||||
isEdutainmentEntryEnabled,
|
||||
} from '../platform-entry/platformEdutainmentVisibility';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
@@ -225,6 +224,8 @@ const HERO_SURFACE_CLASS =
|
||||
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
|
||||
const MOBILE_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const MOBILE_PROFILE_PAGE_STAGE_CLASS =
|
||||
'platform-remap-surface min-w-0 space-y-4 pb-2';
|
||||
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
|
||||
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
|
||||
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
|
||||
@@ -253,9 +254,36 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
|
||||
claimable: 2,
|
||||
incomplete: 1,
|
||||
disabled: 0,
|
||||
claimed: -1,
|
||||
};
|
||||
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
|
||||
|
||||
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
|
||||
return tasks
|
||||
.map((task, index) => ({ task, index }))
|
||||
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
|
||||
.sort(
|
||||
(left, right) =>
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
|
||||
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
|
||||
left.index - right.index,
|
||||
)
|
||||
.slice(0, 1)
|
||||
.map(({ task }) => task);
|
||||
}
|
||||
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type BarcodeDetectorLike = {
|
||||
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
||||
};
|
||||
type BarcodeDetectorConstructorLike = new (options?: {
|
||||
formats?: string[];
|
||||
}) => BarcodeDetectorLike;
|
||||
type RechargeTab = 'points' | 'membership';
|
||||
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||
type WechatPayResult = {
|
||||
@@ -269,6 +297,13 @@ type RechargePaymentResult = {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
|
||||
const maybeDetector = (globalThis as unknown as {
|
||||
BarcodeDetector?: BarcodeDetectorConstructorLike;
|
||||
}).BarcodeDetector;
|
||||
return typeof maybeDetector === 'function' ? maybeDetector : null;
|
||||
}
|
||||
type NativeWechatPaymentState = WechatNativePayment & {
|
||||
orderId: string;
|
||||
isConfirming: boolean;
|
||||
@@ -756,69 +791,6 @@ function WorldCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendCoverOnlyCard({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
onClick,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
authorAvatarUrl?: string | null;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const typeLabel = describePublicGalleryCardKind(entry);
|
||||
const authorName = entry.authorDisplayName.trim() || '玩家';
|
||||
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={`登录后游玩 ${entry.worldName}`}
|
||||
className="platform-recommend-cover-only"
|
||||
>
|
||||
{coverImage ? (
|
||||
<PlatformWorkCoverArtwork
|
||||
entry={entry}
|
||||
imageSrc={coverImage}
|
||||
fallbackSrc={fallbackCoverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.04),rgba(0,0,0,0.42))]" />
|
||||
<div className="platform-recommend-cover-only__body">
|
||||
<span className="platform-public-work-card__kind">{typeLabel}</span>
|
||||
<span className="platform-recommend-cover-only__title">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="platform-recommend-cover-only__author">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="platform-public-work-card__author-avatar"
|
||||
>
|
||||
{normalizedAuthorAvatarUrl ? (
|
||||
<img
|
||||
src={normalizedAuthorAvatarUrl}
|
||||
alt=""
|
||||
className="platform-public-work-card__author-avatar-image"
|
||||
/>
|
||||
) : (
|
||||
authorAvatarLabel
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{authorName}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CreationLibraryCard({
|
||||
entry,
|
||||
onClick,
|
||||
@@ -3283,7 +3255,7 @@ function ProfileTaskCenterModal({
|
||||
onRetry: () => void;
|
||||
onClaim: (taskId: string) => void;
|
||||
}) {
|
||||
const tasks = center?.tasks ?? [];
|
||||
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
|
||||
const walletBalance = center?.walletBalance ?? fallbackBalance;
|
||||
|
||||
return (
|
||||
@@ -3459,6 +3431,160 @@ function RewardCodeRedeemModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileQrScannerModal({
|
||||
error,
|
||||
result,
|
||||
onClose,
|
||||
onError,
|
||||
onResult,
|
||||
}: {
|
||||
error: string | null;
|
||||
result: string | null;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
onResult: (value: string) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current;
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
let scanTimer: number | null = null;
|
||||
const detectorCtor = getBarcodeDetectorConstructor();
|
||||
const detector = detectorCtor
|
||||
? new detectorCtor({ formats: ['qr_code'] })
|
||||
: null;
|
||||
|
||||
const clearScanTimer = () => {
|
||||
if (scanTimer !== null) {
|
||||
window.clearTimeout(scanTimer);
|
||||
scanTimer = null;
|
||||
}
|
||||
};
|
||||
const stopCamera = () => {
|
||||
const stream = streamRef.current;
|
||||
streamRef.current = null;
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
videoElement.srcObject = null;
|
||||
};
|
||||
|
||||
const scanVideo = async () => {
|
||||
if (!isMounted || !detector || videoElement.readyState < 2) {
|
||||
if (isMounted && detector) {
|
||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const codes = await detector.detect(videoElement);
|
||||
const rawValue = codes[0]?.rawValue?.trim();
|
||||
if (rawValue) {
|
||||
clearScanTimer();
|
||||
stopCamera();
|
||||
onResult(rawValue);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
onError('扫码识别失败,请调整二维码位置');
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const startCamera = async () => {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
||||
onError('当前浏览器不支持摄像头扫码');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
});
|
||||
|
||||
if (!isMounted) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = stream;
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
if (!detector) {
|
||||
onError('当前浏览器暂不支持二维码识别');
|
||||
return;
|
||||
}
|
||||
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
|
||||
} catch {
|
||||
onError('无法打开摄像头,请检查权限');
|
||||
}
|
||||
};
|
||||
|
||||
void startCamera();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearScanTimer();
|
||||
stopCamera();
|
||||
};
|
||||
}, [onError, onResult]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="扫码"
|
||||
>
|
||||
<div className="platform-qr-scanner-modal 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 className="text-base font-black">扫码</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭扫码"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
<div className="platform-qr-scanner-modal__viewport">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-cover"
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
<span className="platform-qr-scanner-modal__frame" />
|
||||
</div>
|
||||
{result ? (
|
||||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
已识别:{result}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileReferralModal({
|
||||
panel,
|
||||
center,
|
||||
@@ -3936,6 +4062,9 @@ export function RpgEntryHomeView({
|
||||
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
|
||||
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
|
||||
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
|
||||
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
|
||||
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
|
||||
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
|
||||
const [profilePopupPanel, setProfilePopupPanel] =
|
||||
useState<ProfilePopupPanel | null>(null);
|
||||
const [referralCenter, setReferralCenter] =
|
||||
@@ -4702,6 +4831,16 @@ export function RpgEntryHomeView({
|
||||
setTaskClaimSuccess(null);
|
||||
loadTaskCenter();
|
||||
};
|
||||
const openQrScannerPanel = () => {
|
||||
if (!authUi?.user) {
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setQrScannerError(null);
|
||||
setQrScannerResult(null);
|
||||
setIsQrScannerOpen(true);
|
||||
};
|
||||
const loadReferralCenter = useCallback(() => {
|
||||
setIsLoadingReferral(true);
|
||||
setIsReferralCenterInitialized(false);
|
||||
@@ -5264,23 +5403,6 @@ export function RpgEntryHomeView({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const openActiveRecommendEntry = useCallback(() => {
|
||||
if (!activeRecommendEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
openRecommendGalleryDetail(activeRecommendEntry);
|
||||
}, [
|
||||
activeRecommendEntry,
|
||||
authUi,
|
||||
isAuthenticated,
|
||||
openRecommendGalleryDetail,
|
||||
]);
|
||||
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -5924,28 +6046,10 @@ export function RpgEntryHomeView({
|
||||
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
|
||||
|
||||
const profileContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-profile-page`}>
|
||||
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section className="platform-profile-header">
|
||||
<div className="platform-profile-header__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargeOrRewardCodeModal}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="打开充值入口"
|
||||
>
|
||||
<ScanLine className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openSettingsModal()}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="打开设置"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<img
|
||||
src={profileStillLifeImage}
|
||||
alt=""
|
||||
@@ -6198,36 +6302,21 @@ export function RpgEntryHomeView({
|
||||
icon={Settings}
|
||||
onClick={() => authUi.openSettingsModal()}
|
||||
/>
|
||||
<ProfileSettingsRow
|
||||
label="存档"
|
||||
icon={Archive}
|
||||
onClick={() => setProfilePopupPanel('saveArchives')}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="platform-profile-secondary-shortcuts"
|
||||
aria-label="次级入口"
|
||||
>
|
||||
<ProfileSecondaryShortcutButton
|
||||
label="存档"
|
||||
subLabel={
|
||||
saveEntries.length > 0
|
||||
? `${saveEntries.length}个可继续`
|
||||
: '继续游玩'
|
||||
}
|
||||
icon={Archive}
|
||||
onClick={() => setProfilePopupPanel('saveArchives')}
|
||||
/>
|
||||
{canShowReferralRedeemShortcut ? (
|
||||
{canShowReferralRedeemShortcut ? (
|
||||
<section
|
||||
className="platform-profile-secondary-shortcuts"
|
||||
aria-label="次级入口"
|
||||
>
|
||||
<ProfileSecondaryShortcutButton
|
||||
label="填邀请码"
|
||||
subLabel="新用户奖励"
|
||||
icon={Ticket}
|
||||
onClick={() => openProfilePopupPanel('redeem')}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
@@ -6695,6 +6784,22 @@ export function RpgEntryHomeView({
|
||||
onClose={() => setIsCategoryFilterPanelOpen(false)}
|
||||
/>
|
||||
) : null;
|
||||
const qrScannerModal: ReactNode = isQrScannerOpen ? (
|
||||
<ProfileQrScannerModal
|
||||
error={qrScannerError}
|
||||
result={qrScannerResult}
|
||||
onClose={() => {
|
||||
setIsQrScannerOpen(false);
|
||||
setQrScannerError(null);
|
||||
setQrScannerResult(null);
|
||||
}}
|
||||
onError={setQrScannerError}
|
||||
onResult={(value) => {
|
||||
setQrScannerError(null);
|
||||
setQrScannerResult(value);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
@@ -6706,7 +6811,26 @@ export function RpgEntryHomeView({
|
||||
{!isMobileRecommendTab ? (
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{isAuthenticated && activeTab === 'create' ? (
|
||||
{isAuthenticated && activeTab === 'profile' ? (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openQrScannerPanel}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="扫码"
|
||||
>
|
||||
<ScanLine className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi?.openSettingsModal()}
|
||||
className="platform-profile-header__icon-button"
|
||||
aria-label="打开设置"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : isAuthenticated && activeTab === 'create' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
@@ -6799,6 +6923,7 @@ export function RpgEntryHomeView({
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{rechargePaymentResultModal}
|
||||
{qrScannerModal}
|
||||
{categoryFilterDialog}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
|
||||
@@ -5685,26 +5685,17 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
box-shadow: 0 20px 50px rgba(112, 57, 30, 0.12);
|
||||
}
|
||||
|
||||
.platform-profile-header__actions {
|
||||
position: absolute;
|
||||
right: 0.8rem;
|
||||
top: 0.72rem;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border: 0;
|
||||
border: 1px solid rgba(232, 214, 201, 0.82);
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
background: rgba(255, 252, 248, 0.9);
|
||||
color: #1e120c;
|
||||
box-shadow: 0 8px 18px rgba(112, 57, 30, 0.06);
|
||||
}
|
||||
|
||||
.platform-profile-scene-decor {
|
||||
@@ -5725,8 +5716,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding-top: 2.6rem;
|
||||
padding-right: 6.75rem;
|
||||
padding-top: 0.2rem;
|
||||
padding-right: 4.25rem;
|
||||
}
|
||||
|
||||
.platform-profile-edit-button {
|
||||
@@ -5825,14 +5816,40 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 243, 230, 0.9);
|
||||
color: #bc5f34;
|
||||
}
|
||||
|
||||
.platform-qr-scanner-modal {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: var(--platform-modal-fill);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
0 24px 80px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.platform-qr-scanner-modal__viewport {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 1.1rem;
|
||||
background: rgba(18, 16, 14, 0.92);
|
||||
}
|
||||
|
||||
.platform-qr-scanner-modal__frame {
|
||||
position: absolute;
|
||||
inset: 18%;
|
||||
border: 2px solid rgba(255, 244, 230, 0.92);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 0 0 999px rgba(0, 0, 0, 0.18),
|
||||
0 0 24px rgba(244, 138, 70, 0.28);
|
||||
}
|
||||
|
||||
.platform-profile-daily-task-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6011,15 +6028,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
border-radius: 1.4rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__actions {
|
||||
right: 0.64rem;
|
||||
top: 0.6rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__identity {
|
||||
padding-top: 2.45rem;
|
||||
padding-right: 4.9rem;
|
||||
padding-top: 0;
|
||||
padding-right: 3.55rem;
|
||||
}
|
||||
|
||||
.platform-profile-header__identity-row {
|
||||
@@ -6103,8 +6114,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-profile-stat-card__icon {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
width: 1.95rem;
|
||||
height: 1.95rem;
|
||||
}
|
||||
|
||||
.platform-profile-stat-card__value {
|
||||
|
||||
Reference in New Issue
Block a user