Improve local auth env handling and fallbacks

Allow local env files to reliably override authentication feature flags (SMS/WeChat) by whitelisting keys in scripts/dev-utils.mjs and adding a unit test. Add SMS checks to scripts/check-api-server-env.mjs. Make server config.parse_bool tolerant of shell-wrapped quoted values (e.g. '"true"') and add tests so SMS_AUTH_ENABLED is parsed correctly when shells supply quotes. Update docs to clarify SMS env behaviour, restart requirements, and add guidance + a CSS fallback for old mobile browsers (QQ/X5) so public cover images render even when aspect-ratio is unsupported. Also include related frontend test and component adjustments and add puzzle onboarding handlers/endpoints in server-rs/crates/api-server/src/puzzle.rs.
This commit is contained in:
2026-05-18 23:13:49 +08:00
parent 4c10c181e3
commit d1adfa3406
22 changed files with 4309 additions and 52 deletions

View File

@@ -316,7 +316,7 @@ test('auth gate does not auto-create a guest account when dev guest switch is no
expect(await screen.findByText('应用内容')).toBeTruthy();
});
test('auth gate keeps password entry available when login options are empty', async () => {
test('auth gate keeps sms and password entries available when login options are empty', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
@@ -336,12 +336,19 @@ test('auth gate keeps password entry available when login options are empty', as
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
});
test('auth gate falls back to password entry when login options request fails', async () => {
test('auth gate keeps sms and password entries available when login options request fails', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockRejectedValue(
@@ -357,6 +364,13 @@ test('auth gate falls back to password entry when login options request fails',
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(within(dialog).getByRole('tab', { name: '短信登录' })).toBeTruthy();
expect(within(dialog).getByRole('tab', { name: '密码登录' })).toBeTruthy();
expect(within(dialog).getByLabelText('验证码')).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '获取验证码' }),
).toBeTruthy();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
});

View File

@@ -61,7 +61,7 @@ type AuthStatus =
| 'ready'
| 'error';
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
@@ -76,11 +76,13 @@ function normalizeAvailableLoginMethods(
): AuthLoginMethod[] {
const normalizedMethods = Array.from(new Set(methods ?? []));
// 密码登录由 Rust auth entry 固定承载,不依赖短信或微信环境开关
// 当 login-options 联调失败或配置返回空数组时,仍要保留账号入口,避免登录弹窗失去可操作方式。
return normalizedMethods.length > 0
? normalizedMethods
: FALLBACK_LOGIN_METHODS;
// 登录面板的核心入口必须稳定展示login-options 只补充微信环境相关入口
return Array.from(
new Set<AuthLoginMethod>([
...REQUIRED_LOGIN_METHODS,
...normalizedMethods,
]),
);
}
type AuthHydrateSessionResult =
@@ -367,9 +369,9 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
setUser(null);
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
setError(callbackResult?.error ?? '');
setStatus('unauthenticated');

View File

@@ -80,8 +80,8 @@ export function LoginScreen({
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const passwordLoginEnabled = true;
const phoneLoginEnabled = true;
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');

View File

@@ -1718,6 +1718,10 @@ function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) {
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
}
function isPersistedDraftGenerating(value: string | null | undefined) {
return value?.trim() === 'generating';
}
function resolveProfileWalletBalance(
dashboard: { walletBalance?: number | null } | null | undefined,
) {
@@ -8750,7 +8754,7 @@ export function PlatformEntryFlowShellImpl({
item.sourceSessionId,
buildPuzzleResultWorkId(item.sourceSessionId),
buildPuzzleResultProfileId(item.sourceSessionId),
]);
]) || isPersistedDraftGenerating(item.generationStatus);
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
@@ -8897,7 +8901,7 @@ export function PlatformEntryFlowShellImpl({
item.workId,
item.profileId,
item.sourceSessionId,
]);
]) || isPersistedDraftGenerating(item.generationStatus);
const backgroundTask = getMatch3DBackgroundCompileTask(
item.sourceSessionId,
@@ -8972,7 +8976,10 @@ export function PlatformEntryFlowShellImpl({
setMatch3DFormDraftPayload(null);
setMatch3DProfile(null);
setMatch3DGenerationState(
createMiniGameDraftGenerationState('match3d'),
createMiniGameDraftGenerationStateFromStartedAt(
'match3d',
parseDraftGenerationStartedAtMs(item.updatedAt),
),
);
enterCreateTab();
selectionStageRef.current = 'match3d-generating';

View File

@@ -3432,6 +3432,73 @@ test('running match3d persisted draft reopens progress instead of unfinished res
);
});
test('persisted generating match3d draft opens generation progress after refresh', async () => {
const user = userEvent.setup();
const persistedGeneratingWork: Match3DWorkSummary = {
workId: 'match3d-work-generating',
profileId: 'match3d-profile-generating',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-generating',
gameName: '生成中抓鹅',
themeText: '霓虹水果摊',
summary: '刷新后仍应回到抓大鹅生成面板。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-18T12:05:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
generatedItemAssets: [],
};
vi.mocked(listMatch3DWorks).mockResolvedValue({
items: [persistedGeneratingWork],
});
vi.mocked(match3dCreationClient.getSession).mockResolvedValueOnce({
session: buildMockMatch3DAgentSession({
sessionId: 'match3d-session-generating',
draft: {
profileId: 'match3d-profile-generating',
gameName: '生成中抓鹅',
themeText: '霓虹水果摊',
summary: '刷新后仍应回到抓大鹅生成面板。',
tags: ['水果', '抓大鹅'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
generatedItemAssets: [],
},
stage: 'draft_ready',
lastAssistantReply: '正在生成抓大鹅素材。',
updatedAt: '2026-05-18T12:05:00.000Z',
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(
await screen.findByRole('button', { name: /继续创作《生成中抓鹅》/u }),
);
await waitFor(() => {
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-session-generating',
);
});
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-generating',
);
});
test('running match3d form generation keeps other creation templates available', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -6410,6 +6477,59 @@ test('puzzle draft result back button returns to creation hub', async () => {
expect(screen.queryByText('拼图结果页')).toBeNull();
});
test('persisted generating puzzle draft opens generation progress after refresh', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [
{
workId: 'puzzle-work-session-generating',
profileId: 'puzzle-profile-session-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-generating',
authorDisplayName: '测试玩家',
workTitle: '生成中拼图',
workDescription: '刷新后仍应回到生成面板。',
levelName: '生成中拼图',
summary: '刷新后仍应回到生成面板。',
themeTags: ['雨夜'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-05-18T12:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
generationStatus: 'generating',
},
],
});
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 42,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
}),
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
'puzzle-session-generating',
);
});
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
test('published puzzle work card restores its source session for editing', async () => {
const user = userEvent.setup();

View File

@@ -387,16 +387,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
fallbackSrc,
alt,
className,
...rest
}: {
src?: string | null;
fallbackSrc?: string | null;
alt?: string;
className?: string;
}) =>
src ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
<img
src={src}
data-fallback-src={fallbackSrc ?? undefined}
alt={alt ?? ''}
className={className}
{...rest}
/>
) : null,
}));
@@ -2901,6 +2909,36 @@ test('mobile discover recommend feed only rotates the card closest to screen cen
);
});
test('mobile discover recommend feed renders cover fallback for legacy browsers', async () => {
renderStatefulLoggedOutHomeView({
latestEntries: [
{
...puzzlePublicEntry,
coverImageSrc:
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
},
],
});
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
const card = within(discoverPanel).getByRole('button', { name: //u });
const cover = card.querySelector('.platform-public-work-card__cover');
const image = within(card).getByRole('img');
expect(cover).toBeTruthy();
expect(cover?.className).toContain('platform-public-work-card__cover');
expect(image.getAttribute('src')).toBe(
'/generated-puzzle-assets/puzzle-session-1/cover/image.png',
);
expect(image.getAttribute('data-fallback-src')).toBe(
'/creation-type-references/puzzle.webp',
);
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();

View File

@@ -31,6 +31,7 @@ import {
UserRound,
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import {
type ComponentType,
type CSSProperties,
@@ -42,7 +43,6 @@ import {
useRef,
useState,
} from 'react';
import QRCode from 'qrcode';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
@@ -58,13 +58,13 @@ import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
WechatNativePayment,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
@@ -137,6 +137,7 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -392,11 +393,13 @@ function usePlatformDesktopLayout() {
function ResolvedAssetBackdrop({
src,
fallbackSrc,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
@@ -404,6 +407,7 @@ function ResolvedAssetBackdrop({
return (
<ResolvedAssetImage
src={src}
fallbackSrc={fallbackSrc}
alt={alt}
aria-hidden={ariaHidden}
className={className}
@@ -522,6 +526,7 @@ function WorldCard({
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const fallbackAssetCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const coverSlides = useMemo(() => {
if (!enableCoverCarousel) {
return fallbackCoverImage
@@ -606,6 +611,7 @@ function WorldCard({
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
fallbackSrc={fallbackAssetCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
@@ -692,6 +698,7 @@ function RecommendCoverOnlyCard({
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
@@ -708,6 +715,7 @@ function RecommendCoverOnlyCard({
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
fallbackSrc={fallbackCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>

View File

@@ -13,7 +13,9 @@ import {
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
@@ -46,6 +48,32 @@ test('platform work display text limits names and tags by character count', () =
).toEqual(['超长机关', '星桥']);
});
test('platform public cards use play type reference images as cover fallback', () => {
const puzzleCard: PlatformPuzzleGalleryCard = {
sourceType: 'puzzle',
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
publicWorkCode: 'PZ-PUZZLE1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '机关拼图',
subtitle: '拼图关卡',
summaryText: '公开作品',
coverImageSrc: '/generated-puzzle-assets/session/cover/image.png',
themeTags: ['拼图'],
playCount: 1,
remixCount: 0,
likeCount: 0,
visibility: 'published',
publishedAt: '2026-05-18T00:00:00.000Z',
updatedAt: '2026-05-18T00:00:00.000Z',
};
expect(resolvePlatformWorldFallbackCoverImage(puzzleCard)).toBe(
'/creation-type-references/puzzle.webp',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',

View File

@@ -446,6 +446,36 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldFallbackCoverImage(
entry: PlatformWorldCardLike,
) {
if (isPuzzleGalleryEntry(entry)) {
return '/creation-type-references/puzzle.webp';
}
if (isMatch3DGalleryEntry(entry)) {
return '/creation-type-references/match3d.webp';
}
if (isSquareHoleGalleryEntry(entry)) {
return '/creation-type-references/square-hole.webp';
}
if (isVisualNovelGalleryEntry(entry)) {
return '/creation-type-references/visual-novel.webp';
}
if (isBigFishGalleryEntry(entry)) {
return '/creation-type-references/big-fish.webp';
}
if (isEdutainmentGalleryEntry(entry)) {
return '/creation-type-references/creative-agent.webp';
}
return '/creation-type-references/rpg.webp';
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {

View File

@@ -1226,6 +1226,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%);
}
.platform-public-work-card__cover::before {
content: '';
display: block;
padding-top: 56.25%;
}
.platform-public-work-card__body {
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%);
}
@@ -4902,6 +4908,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
min-height: min(58vh, 28rem);
}
.platform-public-work-card--immersive .platform-public-work-card__cover::before {
padding-top: 122%;
}
.platform-public-work-card--immersive .platform-public-work-card__body {
min-height: 8rem;
padding: 0.9rem 0.95rem 1rem;

View File

@@ -25,15 +25,15 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成关卡名称',
'生成首关画面',
'生成UI背景',
'并行生成素材',
'校验背景资源',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'读取画面描述,建立可编辑草稿与首关结构。',
);
expect(progress?.estimatedRemainingMs).toBe(130_500);
expect(progress?.estimatedRemainingMs).toBe(298_500);
expect(progress?.overallProgress).toBeGreaterThan(0);
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
});
@@ -49,17 +49,20 @@ describe('miniGameDraftGenerationProgress', () => {
};
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 96_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 126_000);
const uiProgress = buildMiniGameDraftGenerationProgress(state, 282_000);
const writeBackProgress = buildMiniGameDraftGenerationProgress(
state,
296_000,
);
expect(imageProgress?.phaseId).toBe('puzzle-images');
expect(imageProgress?.estimatedRemainingMs).toBe(107_000);
expect(imageProgress?.estimatedRemainingMs).toBe(275_000);
expect(imageProgress?.steps[1]?.status).toBe('completed');
expect(imageProgress?.steps[2]?.status).toBe('active');
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
expect(writeBackProgress?.estimatedRemainingMs).toBe(7_000);
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
expect(writeBackProgress?.steps[4]?.status).toBe('active');
});
@@ -74,7 +77,7 @@ describe('miniGameDraftGenerationProgress', () => {
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 200_000);
const progress = buildMiniGameDraftGenerationProgress(state, 360_000);
expect(progress?.phaseId).toBe('puzzle-select-image');
expect(progress?.overallProgress).toBe(98);

View File

@@ -93,15 +93,15 @@ const PUZZLE_STEPS = [
},
{
id: 'puzzle-images',
label: '生成首关画面',
detail: '调用图片模型生成适合切块的正方形首图。',
weight: 42,
label: '并行生成素材',
detail: '同时生成首关画面与 9:16 纯背景。',
weight: 74,
},
{
id: 'puzzle-ui-background',
label: '生成UI背景',
detail: '生成不含槽位和控件的 9:16 纯背景。',
weight: 32,
label: '校验背景资源',
detail: '确认首关图和 UI 背景都已写入资产库。',
weight: 0,
},
{
id: 'puzzle-select-image',
@@ -111,7 +111,7 @@ const PUZZLE_STEPS = [
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const PUZZLE_ESTIMATED_WAIT_MS = 132_000;
const PUZZLE_ESTIMATED_WAIT_MS = 5 * 60_000;
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
const PUZZLE_PHASE_TIMELINE: Array<{
@@ -127,8 +127,8 @@ const PUZZLE_PHASE_TIMELINE: Array<{
}> = [
{ phase: 'compile', durationMs: 12_000 },
{ phase: 'puzzle-level-name', durationMs: 8_000 },
{ phase: 'puzzle-images', durationMs: 70_000 },
{ phase: 'puzzle-ui-background', durationMs: 32_000 },
{ phase: 'puzzle-images', durationMs: 260_000 },
{ phase: 'puzzle-ui-background', durationMs: 10_000 },
{ phase: 'puzzle-select-image', durationMs: 10_000 },
];